feat: session busy indicator — daemon tracks mid-turn state (agent_start/settled), busy field on session rows + live broadcast; pulsing pip in sidebar and cards (265 web tests, daemon 95.4%)
This commit is contained in:
@@ -100,6 +100,7 @@ type SessionView struct {
|
|||||||
Repo *string `json:"repo"`
|
Repo *string `json:"repo"`
|
||||||
StartedAt int64 `json:"startedAt"`
|
StartedAt int64 `json:"startedAt"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
Busy bool `json:"busy"`
|
||||||
LastEventAt int64 `json:"lastEventAt"`
|
LastEventAt int64 `json:"lastEventAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +343,10 @@ type Hub struct {
|
|||||||
agents map[string]*agentConn
|
agents map[string]*agentConn
|
||||||
webs map[*webClient]struct{}
|
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 lets the API push spawn job snapshots to web clients.
|
||||||
SpawnStatus func() []SpawnJob
|
SpawnStatus func() []SpawnJob
|
||||||
|
|
||||||
@@ -353,6 +358,7 @@ func NewHub(store *Store) *Hub {
|
|||||||
store: store,
|
store: store,
|
||||||
agents: make(map[string]*agentConn),
|
agents: make(map[string]*agentConn),
|
||||||
webs: make(map[*webClient]struct{}),
|
webs: make(map[*webClient]struct{}),
|
||||||
|
busy: make(map[string]bool),
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
ReadBufferSize: 4096,
|
ReadBufferSize: 4096,
|
||||||
WriteBufferSize: 4096,
|
WriteBufferSize: 4096,
|
||||||
@@ -392,6 +398,7 @@ func (h *Hub) SessionsView() []SessionView {
|
|||||||
LastEventAt: row.LastEventAt,
|
LastEventAt: row.LastEventAt,
|
||||||
}
|
}
|
||||||
_, v.Online = h.agents[row.ID]
|
_, v.Online = h.agents[row.ID]
|
||||||
|
v.Busy = h.busy[row.ID]
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -607,6 +614,18 @@ func (h *Hub) handleEvent(f frame) {
|
|||||||
log.Printf("hub: touch session %s: %v", f.sessionID, err)
|
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))
|
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 {
|
if err := h.store.SetOnline(ac.sessionID, false); err != nil {
|
||||||
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
|
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
|
||||||
}
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.busy, ac.sessionID)
|
||||||
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
h.BroadcastSessionList()
|
h.BroadcastSessionList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
_ = 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
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const sessions: SessionListItem[] = [
|
|||||||
repo: "g/a",
|
repo: "g/a",
|
||||||
startedAt: 10,
|
startedAt: 10,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,7 @@ const sessions: SessionListItem[] = [
|
|||||||
repo: "g/b",
|
repo: "g/b",
|
||||||
startedAt: 20,
|
startedAt: 20,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
},
|
},
|
||||||
// bare session: exercises null-name/null-repo fallbacks
|
// bare session: exercises null-name/null-repo fallbacks
|
||||||
@@ -54,10 +56,23 @@ const sessions: SessionListItem[] = [
|
|||||||
repo: null,
|
repo: null,
|
||||||
startedAt: 30,
|
startedAt: 30,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: null,
|
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 {
|
function seedApi(): void {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.endsWith("/stats"))
|
if (url.endsWith("/stats"))
|
||||||
@@ -290,3 +305,21 @@ describe("conn banner + sidebar sections", () => {
|
|||||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ function SidebarSection({
|
|||||||
style={{ display: "inline-block" }}
|
style={{ display: "inline-block" }}
|
||||||
/>
|
/>
|
||||||
<span className="link-label">{s.name ?? s.repo ?? s.id}</span>
|
<span className="link-label">{s.name ?? s.repo ?? s.id}</span>
|
||||||
|
{s.online && s.busy && (
|
||||||
|
<span className="busy-pip" aria-label="agent working" title="agent working" />
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const sessions: SessionListItem[] = [
|
|||||||
repo: null,
|
repo: null,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function session(p: Partial<SessionListItem>): SessionListItem {
|
|||||||
repo: null,
|
repo: null,
|
||||||
startedAt: 100,
|
startedAt: 100,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
...p,
|
...p,
|
||||||
};
|
};
|
||||||
@@ -107,6 +108,7 @@ describe("SessionsView", () => {
|
|||||||
cwd: "/fallback",
|
cwd: "/fallback",
|
||||||
startedAt: 100,
|
startedAt: 100,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -496,3 +498,17 @@ describe("SessionsView delete session", () => {
|
|||||||
expect(screen.getByLabelText("Delete session dead")).toBeInTheDocument();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ function SessionCard({
|
|||||||
<span className="card-body">
|
<span className="card-body">
|
||||||
<span className="card-title" style={{ display: "block" }}>
|
<span className="card-title" style={{ display: "block" }}>
|
||||||
{s.name ?? s.repo ?? s.cwd}
|
{s.name ?? s.repo ?? s.cwd}
|
||||||
|
{s.online && s.busy && (
|
||||||
|
<span
|
||||||
|
className="busy-pip"
|
||||||
|
aria-label="agent working"
|
||||||
|
title="agent working"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="card-meta" style={{ display: "flex" }}>
|
<span className="card-meta" style={{ display: "flex" }}>
|
||||||
{s.repo !== null && <span>{s.repo}</span>}
|
{s.repo !== null && <span>{s.repo}</span>}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ describe("SpawnView spawn+poll", () => {
|
|||||||
repo: "g/proj",
|
repo: "g/proj",
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1429,3 +1429,20 @@ mark.hit {
|
|||||||
padding: 11px 13px;
|
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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface SessionInfo {
|
|||||||
/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
|
/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
|
||||||
export interface SessionListItem extends SessionInfo {
|
export interface SessionListItem extends SessionInfo {
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
busy: boolean;
|
||||||
lastEventAt: number | null;
|
lastEventAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ describe("useSessions", () => {
|
|||||||
id: "s1",
|
id: "s1",
|
||||||
name: "one",
|
name: "one",
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
},
|
},
|
||||||
] as SessionListItem[];
|
] as SessionListItem[];
|
||||||
const fetchMock = mockFetchJson((url) => {
|
const fetchMock = mockFetchJson((url) => {
|
||||||
@@ -417,6 +418,7 @@ describe("useSessions", () => {
|
|||||||
id: "s9",
|
id: "s9",
|
||||||
name: "x",
|
name: "x",
|
||||||
online: true,
|
online: true,
|
||||||
|
busy: false,
|
||||||
} as SessionListItem,
|
} as SessionListItem,
|
||||||
];
|
];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user