Skip to content

Commit dbd3e10

Browse files
fix(ui): filter sidebar recent sessions by selected agent
Fixes #88214. Control UI dashboard Recent sessions now follows the selected agent, preserves legacy main sessions under stale identity, keeps unknown sessions unscoped, and scopes agent/default session refreshes before the session-list limit. Completed run refreshes now use the run's original session/agent target, global New Chat creates under the selected agent, and the agent switcher preserves last known target sessions across scoped refreshes without resurrecting deleted or archived sessions while accepting newer out-of-scope live rows into the switch cache. Also fixes a current-main lint issue around trusted approval params. Co-authored-by: 张贵萍0668001030 <[email protected]>
1 parent 8b50cdd commit dbd3e10

18 files changed

Lines changed: 825 additions & 53 deletions

ui/src/ui/app-chat.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
153153
chatModelSwitchPromises: {},
154154
chatModelsLoading: false,
155155
chatModelCatalog: [],
156-
refreshSessionsAfterChat: new Set<string>(),
156+
refreshSessionsAfterChat: new Map(),
157157
toolStreamById: new Map(),
158158
toolStreamOrder: [],
159159
toolStreamSyncTimer: null,
@@ -235,7 +235,7 @@ describe("refreshChat", () => {
235235
"sessions list payload",
236236
);
237237
expect(sessionsListPayload).not.toHaveProperty("activeMinutes");
238-
expect(sessionsListPayload).not.toHaveProperty("agentId");
238+
expect(sessionsListPayload.agentId).toBe("main");
239239
expect(sessionsListPayload.includeGlobal).toBe(true);
240240
expect(sessionsListPayload.includeUnknown).toBe(true);
241241
expect(sessionsListPayload.limit).toBe(50);
@@ -302,6 +302,28 @@ describe("refreshChat", () => {
302302
expect(sessionsListPayload.includeGlobal).toBe(true);
303303
});
304304

305+
it("scopes agent session refresh rows before the list limit", async () => {
306+
const request = vi.fn(() => new Promise<unknown>(() => undefined));
307+
const host = makeHost({
308+
client: { request } as unknown as ChatHost["client"],
309+
sessionKey: "agent:work:dashboard",
310+
agentsList: { defaultId: "main", mainKey: "main" },
311+
});
312+
313+
const refresh = refreshChat(host);
314+
const outcome = await raceWithMacrotask(refresh);
315+
316+
expect(outcome).toBe("resolved");
317+
const sessionsListPayload = findRequestPayload(
318+
request as unknown as MockCallSource,
319+
"sessions.list",
320+
"agent direct sessions list payload",
321+
);
322+
expect(sessionsListPayload.agentId).toBe("work");
323+
expect(sessionsListPayload.limit).toBe(50);
324+
expect(sessionsListPayload.includeGlobal).toBe(true);
325+
});
326+
305327
it("uses hello default for global chat refresh before agents list loads", async () => {
306328
const request = vi.fn(() => new Promise<unknown>(() => undefined));
307329
const host = makeHost({
@@ -333,6 +355,33 @@ describe("refreshChat", () => {
333355
expect(sessionsListPayload.agentId).toBe("ops");
334356
});
335357

358+
it("keeps unknown chat refresh session rows unscoped", async () => {
359+
const request = vi.fn(() => new Promise<unknown>(() => undefined));
360+
const host = makeHost({
361+
client: { request } as unknown as ChatHost["client"],
362+
sessionKey: "unknown",
363+
assistantAgentId: "work",
364+
agentsList: { defaultId: "main" },
365+
});
366+
367+
const refresh = refreshChat(host);
368+
const outcome = await raceWithMacrotask(refresh);
369+
370+
expect(outcome).toBe("resolved");
371+
expect(request).toHaveBeenCalledWith("chat.history", {
372+
sessionKey: "unknown",
373+
limit: 100,
374+
maxChars: 4000,
375+
});
376+
const sessionsListPayload = findRequestPayload(
377+
request as unknown as MockCallSource,
378+
"sessions.list",
379+
"unknown sessions list payload",
380+
);
381+
expect(sessionsListPayload).not.toHaveProperty("agentId");
382+
expect(sessionsListPayload.includeUnknown).toBe(true);
383+
});
384+
336385
it("can wait for history without waiting for secondary metadata refreshes", async () => {
337386
const history = createDeferred<unknown>();
338387
const requestUpdate = vi.fn();
@@ -672,7 +721,7 @@ describe("refreshChat", () => {
672721
"sessions list payload",
673722
);
674723
expect(sessionsListPayload).not.toHaveProperty("activeMinutes");
675-
expect(sessionsListPayload).not.toHaveProperty("agentId");
724+
expect(sessionsListPayload.agentId).toBe("main");
676725
expect(sessionsListPayload.includeGlobal).toBe(true);
677726
expect(sessionsListPayload.includeUnknown).toBe(true);
678727
expect(sessionsListPayload.limit).toBe(50);

ui/src/ui/app-chat.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { isSessionRunActive } from "./session-run-state.ts";
4545
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
4646
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
4747
import type { SessionsListResult } from "./types.ts";
48-
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
48+
import type { ChatAttachment, ChatQueueItem, ChatSessionRefreshTarget } from "./ui-types.ts";
4949
import { generateUUID } from "./uuid.ts";
5050
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
5151

@@ -77,7 +77,7 @@ export type ChatHost = ChatInputHistoryState & {
7777
sessionsShowArchived?: boolean;
7878
updateComplete?: Promise<unknown>;
7979
requestUpdate?: () => void;
80-
refreshSessionsAfterChat: Set<string>;
80+
refreshSessionsAfterChat: Map<string, ChatSessionRefreshTarget>;
8181
pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null;
8282
chatSubmitGuards?: Map<string, Promise<void>>;
8383
assistantAgentId?: string | null;
@@ -250,6 +250,12 @@ function resolveSelectedGlobalAgentId(
250250
return agentId ? normalizeAgentId(agentId) : undefined;
251251
}
252252

253+
function resolveDefaultAgentIdForList(host: Pick<ChatHost, "agentsList" | "hello">): string {
254+
return normalizeAgentId(
255+
host.agentsList?.defaultId ?? readHelloDefaultAgentId(host) ?? DEFAULT_AGENT_ID,
256+
);
257+
}
258+
253259
function scopedAgentIdForSession(host: ChatHost, sessionKey: string | undefined | null) {
254260
return isGlobalSessionKey(sessionKey)
255261
? resolveSelectedGlobalAgentId(host)
@@ -291,6 +297,32 @@ export function scopedAgentParamsForSession(
291297
return agentId ? { agentId } : {};
292298
}
293299

300+
export function scopedAgentListParamsForSession(
301+
host: Pick<ChatHost, "assistantAgentId" | "agentsList" | "hello">,
302+
sessionKey: string,
303+
) {
304+
const parsed = parseAgentSessionKey(sessionKey);
305+
const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey);
306+
const agentId =
307+
parsed?.agentId ??
308+
(normalizedSessionKey === "global"
309+
? resolveSelectedGlobalAgentId(host)
310+
: normalizedSessionKey === "unknown"
311+
? undefined
312+
: resolveDefaultAgentIdForList(host));
313+
return agentId ? { agentId: normalizeAgentId(agentId) } : {};
314+
}
315+
316+
export function scopedAgentListParamsForRefreshTarget(
317+
host: Pick<ChatHost, "assistantAgentId" | "agentsList" | "hello">,
318+
target: ChatSessionRefreshTarget,
319+
) {
320+
const agentId =
321+
normalizeOptionalString(target.agentId) ??
322+
scopedAgentListParamsForSession(host, target.sessionKey).agentId;
323+
return agentId ? { agentId: normalizeAgentId(agentId) } : {};
324+
}
325+
294326
export async function handleAbortChat(host: ChatHost, opts?: ChatAbortOptions) {
295327
const activeRunId = host.chatRunId;
296328
const clearDraft = () => {
@@ -593,12 +625,17 @@ async function sendQueuedChatMessage(
593625
}
594626
}
595627
if (prepared.refreshSessions) {
628+
const refreshTarget = {
629+
sessionKey,
630+
agentId: prepared.agentId,
631+
};
596632
if (ack.status === "ok") {
597633
void loadSessions(host as unknown as SessionsState, {
598634
...createChatSessionsLoadOverrides(host),
635+
...scopedAgentListParamsForRefreshTarget(host, refreshTarget),
599636
});
600637
} else {
601-
host.refreshSessionsAfterChat.add(ack.runId);
638+
host.refreshSessionsAfterChat.set(ack.runId, refreshTarget);
602639
}
603640
}
604641
discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments));
@@ -1299,7 +1336,7 @@ export async function refreshChat(
12991336
const secondaryRefresh = Promise.allSettled([
13001337
loadSessions(host as unknown as SessionsState, {
13011338
...createChatSessionsLoadOverrides(host),
1302-
...scopedAgentParamsForSession(host, host.sessionKey),
1339+
...scopedAgentListParamsForSession(host, host.sessionKey),
13031340
}),
13041341
refreshChatAvatar(host),
13051342
refreshChatModels(host),

ui/src/ui/app-gateway-chat-load.node.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ vi.mock("./app-chat.ts", () => ({
6060
CHAT_SESSIONS_ACTIVE_MINUTES: 60,
6161
CHAT_SESSIONS_REFRESH_LIMIT: 50,
6262
createChatSessionsLoadOverrides: () => ({ activeMinutes: 60, limit: 50 }),
63+
scopedAgentListParamsForSession: (_host: unknown, sessionKey: string) => {
64+
const [, agentId] = sessionKey.split(":");
65+
return sessionKey.startsWith("agent:") && agentId ? { agentId } : {};
66+
},
67+
scopedAgentListParamsForRefreshTarget: (
68+
_host: unknown,
69+
target: { sessionKey: string; agentId?: string },
70+
) => {
71+
if (target.agentId) {
72+
return { agentId: target.agentId };
73+
}
74+
const [, agentId] = target.sessionKey.split(":");
75+
return target.sessionKey.startsWith("agent:") && agentId ? { agentId } : {};
76+
},
6377
clearPendingQueueItemsForRun: vi.fn(),
6478
flushChatQueueForEvent: vi.fn(),
6579
hasReconnectableQueuedChatSends: vi.fn(() => false),
@@ -175,7 +189,7 @@ function createHost(tab: Tab) {
175189
toolStreamOrder: [],
176190
toolStreamSyncTimer: null,
177191
pendingAbort: null,
178-
refreshSessionsAfterChat: new Set<string>(),
192+
refreshSessionsAfterChat: new Map(),
179193
execApprovalQueue: [],
180194
execApprovalError: null,
181195
updateAvailable: null,

ui/src/ui/app-gateway.node.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ function createHost(): TestGatewayHost {
183183
toolStreamById: new Map(),
184184
toolStreamOrder: [],
185185
toolStreamSyncTimer: null,
186-
refreshSessionsAfterChat: new Set<string>(),
186+
refreshSessionsAfterChat: new Map(),
187187
chatSideResultTerminalRuns: new Set<string>(),
188188
execApprovalQueue: [],
189189
execApprovalBusy: false,

ui/src/ui/app-gateway.sessions.node.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,28 @@ vi.mock("./app-chat.ts", () => ({
1515
createChatSessionsLoadOverrides: () => ({ activeMinutes: 10, limit: 25 }),
1616
scopedAgentParamsForSession: (host: { assistantAgentId?: string | null }, sessionKey: string) =>
1717
sessionKey === "global" && host.assistantAgentId ? { agentId: host.assistantAgentId } : {},
18+
scopedAgentListParamsForSession: (
19+
host: { assistantAgentId?: string | null },
20+
sessionKey: string,
21+
) => {
22+
const [, agentId] = sessionKey.split(":");
23+
if (sessionKey.startsWith("agent:") && agentId) {
24+
return { agentId };
25+
}
26+
return sessionKey === "global" && host.assistantAgentId
27+
? { agentId: host.assistantAgentId }
28+
: {};
29+
},
30+
scopedAgentListParamsForRefreshTarget: (
31+
_host: { assistantAgentId?: string | null },
32+
target: { sessionKey: string; agentId?: string },
33+
) => {
34+
if (target.agentId) {
35+
return { agentId: target.agentId };
36+
}
37+
const [, agentId] = target.sessionKey.split(":");
38+
return target.sessionKey.startsWith("agent:") && agentId ? { agentId } : {};
39+
},
1840
clearPendingQueueItemsForRun: clearPendingQueueItemsForRunMock,
1941
flushChatQueueForEvent: flushChatQueueForEventMock,
2042
refreshChatAvatar: vi.fn(),
@@ -136,7 +158,7 @@ function createHost() {
136158
sessionKey: "main",
137159
chatRunId: null,
138160
toolStreamOrder: [],
139-
refreshSessionsAfterChat: new Set<string>(),
161+
refreshSessionsAfterChat: new Map(),
140162
execApprovalQueue: [],
141163
execApprovalError: null,
142164
updateAvailable: null,
@@ -153,7 +175,7 @@ describe("handleGatewayEvent sessions.changed", () => {
153175
handleChatEventMock.mockReset().mockReturnValue("final");
154176
const host = createHost();
155177
host.sessionKey = "agent:ops:main";
156-
host.refreshSessionsAfterChat.add("run-1");
178+
host.refreshSessionsAfterChat.set("run-1", { sessionKey: "agent:ops:main" });
157179

158180
handleGatewayEvent(host, {
159181
type: "event",
@@ -165,6 +187,7 @@ describe("handleGatewayEvent sessions.changed", () => {
165187
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
166188
activeMinutes: 10,
167189
limit: 25,
190+
agentId: "ops",
168191
});
169192
});
170193

@@ -173,8 +196,8 @@ describe("handleGatewayEvent sessions.changed", () => {
173196
handleChatEventMock.mockReset().mockReturnValue("final");
174197
const host = createHost();
175198
host.sessionKey = "global";
176-
host.assistantAgentId = "work";
177-
host.refreshSessionsAfterChat.add("run-1");
199+
host.assistantAgentId = "main";
200+
host.refreshSessionsAfterChat.set("run-1", { sessionKey: "global", agentId: "work" });
178201

179202
handleGatewayEvent(host, {
180203
type: "event",
@@ -685,6 +708,7 @@ describe("handleGatewayEvent session.message", () => {
685708
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
686709
activeMinutes: 10,
687710
limit: 25,
711+
agentId: "qa",
688712
publishChatRunStatus: false,
689713
});
690714
await Promise.resolve();

ui/src/ui/app-gateway.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
hasReconnectableQueuedChatSends,
1111
markQueuedChatSendsWaitingForReconnect,
1212
refreshChatAvatar,
13+
scopedAgentListParamsForRefreshTarget,
1314
retryReconnectableQueuedChatSends,
15+
scopedAgentListParamsForSession,
1416
scopedAgentParamsForSession,
1517
} from "./app-chat.ts";
1618
import type { EventLogEntry } from "./app-events.ts";
@@ -84,6 +86,7 @@ import type {
8486
StatusSummary,
8587
UpdateAvailable,
8688
} from "./types.ts";
89+
import type { ChatSessionRefreshTarget } from "./ui-types.ts";
8790

8891
function isGenericBrowserFetchFailure(message: string): boolean {
8992
return /^(?:typeerror:\s*)?(?:fetch failed|failed to fetch)$/i.test(message.trim());
@@ -122,7 +125,7 @@ type GatewayHost = {
122125
sessionsShowArchived: boolean;
123126
chatRunId: string | null;
124127
pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null;
125-
refreshSessionsAfterChat: Set<string>;
128+
refreshSessionsAfterChat: Map<string, ChatSessionRefreshTarget>;
126129
sessionsLoading?: boolean;
127130
execApprovalQueue: ExecApprovalRequest[];
128131
execApprovalBusy: boolean;
@@ -794,12 +797,13 @@ function handleTerminalChatEvent(
794797
payload?.runId,
795798
);
796799
const runId = payload?.runId;
797-
if (runId && host.refreshSessionsAfterChat.has(runId)) {
800+
const refreshTarget = runId ? host.refreshSessionsAfterChat.get(runId) : undefined;
801+
if (runId && refreshTarget) {
798802
host.refreshSessionsAfterChat.delete(runId);
799803
if (state === "final") {
800804
void loadSessions(host as unknown as SessionsState, {
801805
...createChatSessionsLoadOverrides(host),
802-
...scopedAgentParamsForSession(host, host.sessionKey),
806+
...scopedAgentListParamsForRefreshTarget(host, refreshTarget),
803807
});
804808
}
805809
}
@@ -972,7 +976,7 @@ function handleSessionMessageGatewayEvent(
972976
const runIdBeforeRefresh = host.chatRunId;
973977
void loadSessions(host as unknown as SessionsState, {
974978
...createChatSessionsLoadOverrides(host),
975-
...scopedAgentParamsForSession(host, host.sessionKey),
979+
...scopedAgentListParamsForSession(host, host.sessionKey),
976980
publishChatRunStatus: false,
977981
}).finally(() =>
978982
replayDeferredSessionMessageReloadAfterSessionsRefresh(

0 commit comments

Comments
 (0)