diff --git a/internal/dashboard/activity.go b/internal/dashboard/activity.go index e36b3c4..c69358c 100644 --- a/internal/dashboard/activity.go +++ b/internal/dashboard/activity.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strings" "time" ) @@ -54,6 +56,12 @@ const ( // for display. func projectActivity(workspace string, containerUp, tmuxUp bool, mitmWebPort int) (string, int) { if !containerUp { + // No sandbox container — but a host-terminal Claude may be running on the + // host (the ChatDock replacement), which has no proxy.log or mitmweb to count. + // Derive its working/waiting from how recently its transcript grew instead. + if act := hostTerminalActivity(workspace); act != "" { + return act, 0 + } return "off", 0 } @@ -78,6 +86,61 @@ func projectActivity(workspace string, containerUp, tmuxUp bool, mitmWebPort int return "waiting", hits } +// hostTerminalActivity classifies a host-terminal Claude session by the freshness +// of its transcript, for the Work/landing dot when there's no container (hence no +// proxy.log / mitmweb) to count. Returns: +// - "": no live host -claude session — caller falls back to "off" +// - "working": the transcript grew within activityIdleAfter (Claude is mid-turn) +// - "waiting": session live but the transcript has been quiet (idle at the prompt, +// or nothing said yet) +// +// Transcript mtime is the signal because the host terminal is a raw PTY — there's +// no event stream to observe like the merge-job emit() clock. Claude Code appends +// to the session JSONL as a turn streams, so a recent mtime ≈ "working"; it stops +// growing between turns, giving "waiting". Same idle threshold as the streamed dot. +func hostTerminalActivity(workspace string) string { + if !hostSessionLive(claudeShellSession(workspace)) { + return "" + } + dir := hostClaudeProjectsDir() + if dir == "" { + return "waiting" + } + mt, ok := newestTranscriptMtime(filepath.Join(dir, claudeProjectSlug(workspace))) + if !ok { + return "waiting" // session up, no transcript yet + } + if time.Since(mt) <= activityIdleAfter { + return "working" + } + return "waiting" +} + +// newestTranscriptMtime returns the most recent modification time among the .jsonl +// transcripts in sessDir. ok is false when the dir is absent or holds no transcript. +func newestTranscriptMtime(sessDir string) (time.Time, bool) { + entries, err := os.ReadDir(sessDir) + if err != nil { + return time.Time{}, false + } + var newest time.Time + found := false + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if mt := info.ModTime(); mt.After(newest) { + newest = mt + found = true + } + } + return newest, found +} + // mitmFlow is the subset of a mitmweb /flows entry we read. type mitmFlow struct { Request struct { diff --git a/internal/dashboard/activity_host_test.go b/internal/dashboard/activity_host_test.go new file mode 100644 index 0000000..6da9060 --- /dev/null +++ b/internal/dashboard/activity_host_test.go @@ -0,0 +1,60 @@ +package dashboard + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// newestTranscriptMtime picks the freshest .jsonl and ignores non-transcripts. +func TestNewestTranscriptMtime(t *testing.T) { + dir := t.TempDir() + + // Absent dir → not found. + if _, ok := newestTranscriptMtime(filepath.Join(dir, "nope")); ok { + t.Fatal("expected not-found for absent dir") + } + // Empty dir → not found. + if _, ok := newestTranscriptMtime(dir); ok { + t.Fatal("expected not-found for empty dir") + } + + old := filepath.Join(dir, "a.jsonl") + newer := filepath.Join(dir, "b.jsonl") + noise := filepath.Join(dir, "notes.txt") + for _, p := range []string{old, newer, noise} { + if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + } + base := time.Now().Add(-time.Hour) + os.Chtimes(old, base, base) + os.Chtimes(newer, base.Add(30*time.Minute), base.Add(30*time.Minute)) + // Make the .txt the newest of all — it must still be ignored. + os.Chtimes(noise, base.Add(50*time.Minute), base.Add(50*time.Minute)) + + mt, ok := newestTranscriptMtime(dir) + if !ok { + t.Fatal("expected a transcript mtime") + } + if want := base.Add(30 * time.Minute); !mt.Equal(want) { + t.Fatalf("newest mtime = %v, want %v (the .txt must be ignored)", mt, want) + } +} + +// hostTerminalActivity maps transcript freshness to working/waiting, and returns +// "" (→ caller shows "off") when no host -claude session is live. We can't easily +// fake a live tmux session in a unit test, so this covers the no-session path; +// the freshness mapping is exercised via newestTranscriptMtime above. +func TestHostTerminalActivityNoSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CORRAL_HOME", filepath.Join(home, ".corral")) + + // A workspace with no running tmux session must yield "" so projectActivity + // falls through to "off". + if got := hostTerminalActivity("/tmp/definitely-not-a-live-project"); got != "" { + t.Fatalf("hostTerminalActivity with no live session = %q, want \"\"", got) + } +}