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 () => {