Skip to content

Commit 521bf99

Browse files
fix(ui): clear stale sidebar run state after chat final (#101293)
Merged via squash. Prepared head SHA: 32a33eb Co-authored-by: fuller-stack-dev <[email protected]> Reviewed-by: @fuller-stack-dev
1 parent cb9de7d commit 521bf99

6 files changed

Lines changed: 420 additions & 52 deletions

File tree

ui/src/e2e/chat-run-lifecycle.e2e.test.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,50 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
3838
await server?.close();
3939
});
4040

41-
it("keeps Send visible when a stale active row outlives the terminal toast", async () => {
41+
it("clears shared session activity when chat final arrives first", async () => {
4242
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
4343
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
4444
const currentPage = await context.newPage();
4545
page = currentPage;
46-
const gateway = await installMockGateway(currentPage);
46+
const gateway = await installMockGateway(currentPage, {
47+
historyMessages: [
48+
{
49+
content: [{ text: "Ready for run lifecycle verification.", type: "text" }],
50+
role: "assistant",
51+
timestamp: Date.now(),
52+
},
53+
],
54+
});
4755

4856
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
57+
await currentPage
58+
.getByText("Ready for run lifecycle verification.")
59+
.waitFor({ timeout: 10_000 });
4960
await gateway.waitForRequest("sessions.list");
50-
await currentPage.locator(".agent-chat__composer-combobox textarea").fill("finish this run");
61+
await currentPage.locator(".agent-chat__input textarea").fill("finish this run");
5162
await currentPage.getByRole("button", { name: "Send message" }).click();
5263
const send = await gateway.waitForRequest("chat.send");
5364
const params = send.params as { idempotencyKey?: unknown };
5465
expect(typeof params.idempotencyKey).toBe("string");
5566
const runId = params.idempotencyKey as string;
5667

5768
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
69+
const mainSession = currentPage.locator(".sidebar-recent-session").filter({ hasText: "Main" });
70+
await gateway.emitGatewayEvent("sessions.changed", {
71+
activeRunIds: [runId],
72+
hasActiveRun: true,
73+
key: "main",
74+
kind: "direct",
75+
reason: "lifecycle",
76+
startedAt: Date.now() - 1_000,
77+
status: "running",
78+
updatedAt: Date.now(),
79+
});
80+
await mainSession.locator(".session-run-spinner").waitFor();
81+
5882
await gateway.emitChatFinal({ runId, text: "Run complete." });
5983
await currentPage.getByText("Run complete.", { exact: true }).waitFor();
60-
await currentPage.getByRole("button", { name: "Send message" }).waitFor();
84+
await expect.poll(() => mainSession.locator(".session-run-spinner").count()).toBe(0);
6185

6286
await gateway.emitGatewayEvent("sessions.changed", {
6387
activeRunIds: [runId],
@@ -70,10 +94,11 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
7094
updatedAt: Date.now(),
7195
});
7296
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
97+
await expect.poll(() => mainSession.locator(".session-run-spinner").count()).toBe(0);
7398

7499
await currentPage.waitForTimeout(CHAT_RUN_STATUS_TOAST_DURATION_MS + 250);
75100
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
76-
expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1);
101+
expect(await mainSession.locator(".session-run-spinner").count()).toBe(0);
77102

78103
await gateway.emitGatewayEvent("sessions.changed", {
79104
key: "agent:main:another-session",
@@ -83,7 +108,7 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
83108
updatedAt: Date.now(),
84109
});
85110
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
86-
expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1);
111+
await expect.poll(() => mainSession.locator(".session-run-spinner").count()).toBe(0);
87112

88113
// Re-publish after the former 10-second suppression window. The completed
89114
// run identity stays terminal until the Gateway publishes different state.
@@ -99,6 +124,6 @@ describeControlUiE2e("Control UI chat run lifecycle", () => {
99124
updatedAt: Date.now(),
100125
});
101126
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
102-
expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1);
127+
await expect.poll(() => mainSession.locator(".session-run-spinner").count()).toBe(0);
103128
});
104129
});

ui/src/lib/sessions/index.test.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22
import type { GatewayBrowserClient } from "../../api/gateway.ts";
33
import type { GatewayEventFrame } from "../../api/gateway.ts";
44
import type { SessionsListResult } from "../../api/types.ts";
5-
import { createSessionCapability } from "./index.ts";
5+
import { createSessionCapability, reconcileSessionRunTerminal } from "./index.ts";
66

77
function sessionsResult(sessions: SessionsListResult["sessions"], ts: number): SessionsListResult {
88
return {
@@ -118,6 +118,139 @@ describe("createSessionCapability", () => {
118118
sessions.dispose();
119119
});
120120

121+
it("publishes terminal run state to shared session subscribers", async () => {
122+
const key = "agent:main:main";
123+
const request = vi.fn(async (method: string) => {
124+
if (method !== "sessions.list") {
125+
throw new Error(`Unexpected request: ${method}`);
126+
}
127+
return sessionsResult(
128+
[
129+
{
130+
key,
131+
kind: "direct",
132+
updatedAt: 1,
133+
hasActiveRun: true,
134+
activeRunIds: ["run-1"],
135+
status: "running",
136+
startedAt: 100,
137+
},
138+
],
139+
1,
140+
);
141+
});
142+
const client = { request } as unknown as GatewayBrowserClient;
143+
const sessions = createSessionCapability({
144+
snapshot: {
145+
client,
146+
connected: true,
147+
sessionKey: key,
148+
assistantAgentId: "main",
149+
hello: null,
150+
},
151+
subscribe: () => () => undefined,
152+
subscribeEvents: () => () => undefined,
153+
});
154+
await sessions.refresh({ agentId: "main", force: true });
155+
156+
expect(
157+
sessions.reconcileRunTerminal({
158+
sessionKeys: ["main"],
159+
runId: "run-1",
160+
status: "done",
161+
endedAt: 160,
162+
}),
163+
).toBe(true);
164+
expect(sessions.state.result?.sessions[0]).toMatchObject({
165+
key,
166+
hasActiveRun: false,
167+
activeRunIds: [],
168+
status: "done",
169+
endedAt: 160,
170+
runtimeMs: 60,
171+
});
172+
173+
expect(
174+
sessions.reconcile({
175+
key,
176+
kind: "direct",
177+
updatedAt: 2,
178+
hasActiveRun: true,
179+
activeRunIds: ["run-2"],
180+
status: "running",
181+
startedAt: 200,
182+
}),
183+
).toBe(true);
184+
expect(
185+
sessions.reconcileRunTerminal({
186+
sessionKeys: ["main"],
187+
runId: "run-1",
188+
status: "done",
189+
endedAt: 260,
190+
}),
191+
).toBe(false);
192+
expect(sessions.state.result?.sessions[0]).toMatchObject({
193+
hasActiveRun: true,
194+
activeRunIds: ["run-2"],
195+
status: "running",
196+
});
197+
198+
expect(
199+
sessions.reconcile({
200+
key,
201+
kind: "direct",
202+
updatedAt: 3,
203+
hasActiveRun: true,
204+
status: "running",
205+
startedAt: 300,
206+
}),
207+
).toBe(true);
208+
expect(
209+
sessions.reconcileRunTerminal({
210+
sessionKeys: ["main"],
211+
runId: "run-1",
212+
status: "done",
213+
endedAt: 360,
214+
}),
215+
).toBe(false);
216+
expect(sessions.state.result?.sessions[0]).toMatchObject({
217+
hasActiveRun: true,
218+
status: "running",
219+
});
220+
expect(
221+
sessions.reconcileRunTerminal({
222+
sessionKeys: ["main"],
223+
status: "done",
224+
endedAt: 360,
225+
}),
226+
).toBe(false);
227+
sessions.dispose();
228+
});
229+
230+
it("preserves registry-active terminal rows without matching run identity", () => {
231+
const result = sessionsResult(
232+
[
233+
{
234+
key: "agent:main:main",
235+
kind: "direct",
236+
updatedAt: 1,
237+
hasActiveRun: true,
238+
status: "done",
239+
},
240+
],
241+
1,
242+
);
243+
244+
expect(
245+
reconcileSessionRunTerminal(result, {
246+
sessionKeys: ["main"],
247+
runId: "run-1",
248+
status: "done",
249+
endedAt: 160,
250+
}),
251+
).toBe(result);
252+
});
253+
121254
it("refreshes stale active rows after a terminal session message", async () => {
122255
const key = "agent:main:main";
123256
const request = vi

ui/src/lib/sessions/index.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
FastMode,
44
GatewaySessionRow,
55
SessionCompactionCheckpoint,
6+
SessionRunStatus,
67
SessionsCompactionBranchResult,
78
SessionsCompactionListResult,
89
SessionsCompactionRestoreResult,
@@ -11,6 +12,7 @@ import type {
1112
SessionWorkspaceGetResult,
1213
SessionWorkspaceListResult,
1314
} from "../../api/types.ts";
15+
import { isSessionRunActive } from "../session-run-state.ts";
1416
import {
1517
requestSessionCreate,
1618
resolveSessionCreateParams,
@@ -67,6 +69,13 @@ export type SessionRefreshOptions = SessionListOptions & {
6769
backgroundHydrate?: boolean;
6870
};
6971

72+
export type SessionRunTerminal = {
73+
sessionKeys: readonly string[];
74+
runId?: string | null;
75+
status: Exclude<SessionRunStatus, "running">;
76+
endedAt: number;
77+
};
78+
7079
export type SessionPatch = {
7180
label?: string | null;
7281
category?: string | null;
@@ -139,6 +148,7 @@ export type SessionCapability = {
139148
options?: SessionReconcileOptions,
140149
) => boolean;
141150
reconcileChanged: (payload: unknown, options?: SessionReconcileOptions) => SessionChangedResult;
151+
reconcileRunTerminal: (terminal: SessionRunTerminal) => boolean;
142152
refresh: (options?: SessionRefreshOptions) => Promise<void>;
143153
create: (params?: SessionCreateParams) => Promise<string | null>;
144154
patch: (
@@ -483,6 +493,66 @@ function canReconcileSessionEvent(options: SessionListOptions): boolean {
483493
);
484494
}
485495

496+
export function reconcileSessionRunTerminal(
497+
result: SessionsListResult | null,
498+
terminal: SessionRunTerminal,
499+
): SessionsListResult | null {
500+
const keys = terminal.sessionKeys.map((key) => key.trim()).filter(Boolean);
501+
if (!result || keys.length === 0) {
502+
return result;
503+
}
504+
const runId = terminal.runId?.trim() || null;
505+
let changed = false;
506+
const sessions = result.sessions.map((row): GatewaySessionRow => {
507+
if (!keys.some((key) => areUiSessionKeysEquivalent(row.key, key))) {
508+
return row;
509+
}
510+
if (row.hasActiveRun === true || isSessionRunActive(row)) {
511+
// Active rows without matching identity may describe a newer or embedded
512+
// run. Only terminalize an active row when this event owns its run ID.
513+
if (!runId || !row.activeRunIds?.includes(runId)) {
514+
return row;
515+
}
516+
}
517+
const remainingRunIds = runId ? row.activeRunIds?.filter((id) => id !== runId) : [];
518+
if (remainingRunIds?.length) {
519+
changed = true;
520+
return {
521+
...row,
522+
activeRunIds: remainingRunIds,
523+
hasActiveRun: true,
524+
status: "running" as const,
525+
};
526+
}
527+
const endedAt = row.endedAt ?? terminal.endedAt;
528+
const runtimeMs =
529+
typeof row.startedAt === "number" ? Math.max(0, endedAt - row.startedAt) : row.runtimeMs;
530+
const activeRunIds = row.activeRunIds?.length ? [] : row.activeRunIds;
531+
const abortedLastRun = terminal.status === "killed" ? true : row.abortedLastRun;
532+
if (
533+
row.hasActiveRun === false &&
534+
row.status === terminal.status &&
535+
row.endedAt === endedAt &&
536+
row.runtimeMs === runtimeMs &&
537+
row.activeRunIds === activeRunIds &&
538+
row.abortedLastRun === abortedLastRun
539+
) {
540+
return row;
541+
}
542+
changed = true;
543+
return {
544+
...row,
545+
activeRunIds,
546+
hasActiveRun: false,
547+
status: terminal.status,
548+
endedAt,
549+
runtimeMs,
550+
abortedLastRun,
551+
};
552+
});
553+
return changed ? { ...result, sessions } : result;
554+
}
555+
486556
export function createSessionCapability(gateway: SessionGateway): SessionCapability {
487557
let state: SessionState = {
488558
result: null,
@@ -739,6 +809,15 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
739809
return reconciled;
740810
};
741811

812+
const reconcileRunTerminal = (terminal: SessionRunTerminal): boolean => {
813+
const result = reconcileSessionRunTerminal(state.result, terminal);
814+
if (result === state.result) {
815+
return false;
816+
}
817+
publish({ ...state, result, error: null });
818+
return true;
819+
};
820+
742821
const remove = async (key: string, options: SessionDeleteOptions = {}): Promise<boolean> => {
743822
const client = gateway.snapshot.client;
744823
if (!client || !gateway.snapshot.connected || disposed) {
@@ -1031,6 +1110,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
10311110
list: requestList,
10321111
reconcile,
10331112
reconcileChanged,
1113+
reconcileRunTerminal,
10341114
refresh,
10351115
create,
10361116
patch,

ui/src/pages/chat/chat-gateway.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,7 @@ describe("handleChatEvent", () => {
797797
kind: "direct",
798798
updatedAt: 1,
799799
hasActiveRun: true,
800+
activeRunIds: ["run-1"],
800801
status: "running",
801802
startedAt: 100,
802803
},

0 commit comments

Comments
 (0)