Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions internal/dashboard/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions internal/dashboard/activity_host_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading