diff --git a/daemon/hub.go b/daemon/hub.go index 4271279..da20052 100644 --- a/daemon/hub.go +++ b/daemon/hub.go @@ -100,6 +100,7 @@ type SessionView struct { Repo *string `json:"repo"` StartedAt int64 `json:"startedAt"` Online bool `json:"online"` + Busy bool `json:"busy"` LastEventAt int64 `json:"lastEventAt"` } @@ -342,6 +343,10 @@ type Hub struct { agents map[string]*agentConn webs map[*webClient]struct{} + // busy tracks sessions mid-turn (agent_start without agent_settled), + // surfaced on session rows so the UI can show a live activity pulse. + busy map[string]bool + // SpawnStatus lets the API push spawn job snapshots to web clients. SpawnStatus func() []SpawnJob @@ -353,6 +358,7 @@ func NewHub(store *Store) *Hub { store: store, agents: make(map[string]*agentConn), webs: make(map[*webClient]struct{}), + busy: make(map[string]bool), upgrader: websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, @@ -392,6 +398,7 @@ func (h *Hub) SessionsView() []SessionView { LastEventAt: row.LastEventAt, } _, v.Online = h.agents[row.ID] + v.Busy = h.busy[row.ID] out = append(out, v) } return out @@ -607,6 +614,18 @@ func (h *Hub) handleEvent(f frame) { log.Printf("hub: touch session %s: %v", f.sessionID, err) } } + // Track mid-turn state for the session-list activity pulse. + if f.typ == evAgentStart || f.typ == evAgentSettled { + busy := f.typ == evAgentStart + h.mu.Lock() + if h.busy[f.sessionID] != busy { + h.busy[f.sessionID] = busy + h.mu.Unlock() + go h.BroadcastSessionList() + } else { + h.mu.Unlock() + } + } h.publishEvent(f.sessionID, f.seq, json.RawMessage(f.raw)) } @@ -624,6 +643,9 @@ func (h *Hub) unregister(ac *agentConn) { if err := h.store.SetOnline(ac.sessionID, false); err != nil { log.Printf("hub: mark offline %s: %v", ac.sessionID, err) } + h.mu.Lock() + delete(h.busy, ac.sessionID) + h.mu.Unlock() } h.BroadcastSessionList() } diff --git a/daemon/hub_extra_test.go b/daemon/hub_extra_test.go index ef6f9b5..189ca8b 100644 --- a/daemon/hub_extra_test.go +++ b/daemon/hub_extra_test.go @@ -714,3 +714,34 @@ func TestWebResubscribeSplitsEventBatches(t *testing.T) { _ = agent2.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s2", "seq": 1, "ts": 11, "delta": "two"}) readBatch("s2") // event B must arrive as its own frame, never mixed with A's } + +func TestHubBusyFlagLifecycle(t *testing.T) { + ts, _, hub := newTestServerHub(t) + ws := dialAgent(t, ts) + defer ws.Close() + _ = ws.WriteJSON(helloFrame("busy-1")) + _ = readFrame(t, ws) + sendEv := func(typ string, seq int64) { + _ = ws.WriteJSON(map[string]any{ + "v": 1, "sessionId": "busy-1", "seq": seq, "ts": 1, "type": typ, + }) + } + sendEv("agent_start", 1) + waitFor(t, 3*time.Second, func() bool { + for _, s := range hub.SessionsView() { + if s.ID == "busy-1" { + return s.Busy + } + } + return false + }) + sendEv("agent_settled", 2) + waitFor(t, 3*time.Second, func() bool { + for _, s := range hub.SessionsView() { + if s.ID == "busy-1" { + return !s.Busy + } + } + return false + }) +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 0617e49..fc4ae30 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -29,6 +29,7 @@ const sessions: SessionListItem[] = [ repo: "g/a", startedAt: 10, online: true, + busy: false, lastEventAt: null, }, { @@ -41,6 +42,7 @@ const sessions: SessionListItem[] = [ repo: "g/b", startedAt: 20, online: true, + busy: false, lastEventAt: null, }, // bare session: exercises null-name/null-repo fallbacks @@ -54,10 +56,23 @@ const sessions: SessionListItem[] = [ repo: null, startedAt: 30, online: true, + busy: false, lastEventAt: null, }, ]; +function seedApiWith(list: unknown): void { + mockFetchJson((url) => { + if (url.endsWith("/stats")) + return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, sessionsCount: 0, onlineCount: 0 }; + if (url.includes("/api/sessions")) + return url.includes("/events") ? [] : list; + if (url.includes("/api/gitlab/status")) + return { connected: false, baseUrl: "https://gl" }; + return []; + }); +} + function seedApi(): void { mockFetchJson((url) => { if (url.endsWith("/stats")) @@ -290,3 +305,21 @@ describe("conn banner + sidebar sections", () => { expect(screen.queryByText(/reconnecting/i)).toBeNull(); }); }); + +describe("busy pip", () => { + it("sidebar shows activity pip only for online+busy sessions", async () => { + seedApiWith([ + { id: "b1", name: "working", online: true, busy: true, startedAt: 1, lastEventAt: 2 }, + { id: "b2", name: "idle", online: true, busy: false, startedAt: 1, lastEventAt: 2 }, + { id: "b3", name: "offlinebusy", online: false, busy: true, startedAt: 1, lastEventAt: 2 }, + ]); + seedSettings(); + renderApp("/"); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + act(() => FakeWebSocket.last().serverOpen()); + const sidebar = screen.getByLabelText("Sessions"); + const pips = sidebar.querySelectorAll(".busy-pip"); + expect(pips).toHaveLength(1); + expect(pips[0]?.closest("a")?.getAttribute("href")).toBe("/s/b1"); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 1c6ffe5..4544e38 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -78,6 +78,9 @@ function SidebarSection({ style={{ display: "inline-block" }} /> {s.name ?? s.repo ?? s.id} + {s.online && s.busy && ( + + )} ))} diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx index 75ba474..d2e89ac 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -33,6 +33,7 @@ const sessions: SessionListItem[] = [ repo: null, startedAt: 0, online: true, + busy: false, lastEventAt: 1, }, ]; diff --git a/web/src/SessionsView.test.tsx b/web/src/SessionsView.test.tsx index b67fd2b..a03b605 100644 --- a/web/src/SessionsView.test.tsx +++ b/web/src/SessionsView.test.tsx @@ -22,6 +22,7 @@ function session(p: Partial): SessionListItem { repo: null, startedAt: 100, online: true, + busy: false, lastEventAt: null, ...p, }; @@ -107,6 +108,7 @@ describe("SessionsView", () => { cwd: "/fallback", startedAt: 100, online: true, + busy: false, }), ], }); @@ -496,3 +498,17 @@ describe("SessionsView delete session", () => { expect(screen.getByLabelText("Delete session dead")).toBeInTheDocument(); }); }); + +describe("busy indicator on cards", () => { + it("card shows pip when online+busy, none otherwise", () => { + renderView({ + sessions: [ + session({ id: "w", name: "working", busy: true }), + session({ id: "i", name: "idle", busy: false }), + session({ id: "o", name: "off", busy: true, online: false }), + ], + }); + const pips = document.querySelectorAll(".busy-pip"); + expect(pips).toHaveLength(1); + }); +}); diff --git a/web/src/SessionsView.tsx b/web/src/SessionsView.tsx index 03c37e1..2b08444 100644 --- a/web/src/SessionsView.tsx +++ b/web/src/SessionsView.tsx @@ -42,9 +42,16 @@ function SessionCard({ title={s.online ? "online" : "offline"} /> - - {s.name ?? s.repo ?? s.cwd} - + + {s.name ?? s.repo ?? s.cwd} + {s.online && s.busy && ( + + )} + {s.repo !== null && {s.repo}} {s.model} diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index 996b2e6..c87563e 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -264,6 +264,7 @@ describe("SpawnView spawn+poll", () => { repo: "g/proj", startedAt: 1, online: true, + busy: false, lastEventAt: 1, }, ] diff --git a/web/src/index.css b/web/src/index.css index 93ddc19..49cb2d5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1429,3 +1429,20 @@ mark.hit { padding: 11px 13px; } } + +/* ---------- busy activity pip ---------- */ + +.busy-pip { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 7px; + border-radius: 50%; + background: var(--accent); + animation: busy-pulse 1.1s ease-in-out infinite; + vertical-align: middle; +} +@keyframes busy-pulse { + 0%, 100% { transform: scale(0.55); opacity: 0.45; box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5); } + 50% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 5px rgba(108, 140, 255, 0); } +} diff --git a/web/src/protocol.ts b/web/src/protocol.ts index af84e5c..ca9ee53 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -64,6 +64,7 @@ export interface SessionInfo { /** Session list row (REST `GET /api/sessions` + WS `session_list`). */ export interface SessionListItem extends SessionInfo { online: boolean; + busy: boolean; lastEventAt: number | null; } diff --git a/web/src/store.test.tsx b/web/src/store.test.tsx index 95306f0..56d77b5 100644 --- a/web/src/store.test.tsx +++ b/web/src/store.test.tsx @@ -117,6 +117,7 @@ describe("useSessions", () => { id: "s1", name: "one", online: true, + busy: false, }, ] as SessionListItem[]; const fetchMock = mockFetchJson((url) => { @@ -417,6 +418,7 @@ describe("useSessions", () => { id: "s9", name: "x", online: true, + busy: false, } as SessionListItem, ]; await act(async () => {