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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,9 @@ function SidebarSection({
|
||||
style={{ display: "inline-block" }}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ const sessions: SessionListItem[] = [
|
||||
repo: null,
|
||||
startedAt: 0,
|
||||
online: true,
|
||||
busy: false,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,6 +22,7 @@ function session(p: Partial<SessionListItem>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,9 +42,16 @@ function SessionCard({
|
||||
title={s.online ? "online" : "offline"}
|
||||
/>
|
||||
<span className="card-body">
|
||||
<span className="card-title" style={{ display: "block" }}>
|
||||
{s.name ?? s.repo ?? s.cwd}
|
||||
</span>
|
||||
<span className="card-title" style={{ display: "block" }}>
|
||||
{s.name ?? s.repo ?? s.cwd}
|
||||
{s.online && s.busy && (
|
||||
<span
|
||||
className="busy-pip"
|
||||
aria-label="agent working"
|
||||
title="agent working"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="card-meta" style={{ display: "flex" }}>
|
||||
{s.repo !== null && <span>{s.repo}</span>}
|
||||
<span>{s.model}</span>
|
||||
|
||||
@@ -264,6 +264,7 @@ describe("SpawnView spawn+poll", () => {
|
||||
repo: "g/proj",
|
||||
startedAt: 1,
|
||||
online: true,
|
||||
busy: false,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user