From dbd3e10312f8ec15f14cc942e0d29d4bc4f6eec8 Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Sun, 31 May 2026 05:13:37 +0800 Subject: [PATCH] fix(ui): filter sidebar recent sessions by selected agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/ui/app-chat.test.ts | 55 ++++- ui/src/ui/app-chat.ts | 45 +++- ui/src/ui/app-gateway-chat-load.node.test.ts | 16 +- ui/src/ui/app-gateway.node.test.ts | 2 +- ui/src/ui/app-gateway.sessions.node.test.ts | 32 ++- ui/src/ui/app-gateway.ts | 12 +- ui/src/ui/app-render.assistant-avatar.test.ts | 209 +++++++++++++++++- ui/src/ui/app-render.helpers.node.test.ts | 62 ++++++ ui/src/ui/app-render.helpers.ts | 27 ++- ui/src/ui/app-render.ts | 40 +++- ui/src/ui/app-view-state.ts | 4 +- ui/src/ui/app.ts | 2 +- ui/src/ui/chat/session-controls.ts | 78 +++++-- ui/src/ui/controllers/sessions.test.ts | 69 ++++++ ui/src/ui/controllers/sessions.ts | 116 +++++++++- ui/src/ui/session-key.ts | 13 ++ ui/src/ui/ui-types.ts | 5 + ui/src/ui/views/chat.test.ts | 91 ++++++++ 18 files changed, 825 insertions(+), 53 deletions(-) diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index c1dc62054a5..31b2f861ea3 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -153,7 +153,7 @@ function makeHost(overrides?: Partial): ChatHost { chatModelSwitchPromises: {}, chatModelsLoading: false, chatModelCatalog: [], - refreshSessionsAfterChat: new Set(), + refreshSessionsAfterChat: new Map(), toolStreamById: new Map(), toolStreamOrder: [], toolStreamSyncTimer: null, @@ -235,7 +235,7 @@ describe("refreshChat", () => { "sessions list payload", ); expect(sessionsListPayload).not.toHaveProperty("activeMinutes"); - expect(sessionsListPayload).not.toHaveProperty("agentId"); + expect(sessionsListPayload.agentId).toBe("main"); expect(sessionsListPayload.includeGlobal).toBe(true); expect(sessionsListPayload.includeUnknown).toBe(true); expect(sessionsListPayload.limit).toBe(50); @@ -302,6 +302,28 @@ describe("refreshChat", () => { expect(sessionsListPayload.includeGlobal).toBe(true); }); + it("scopes agent session refresh rows before the list limit", async () => { + const request = vi.fn(() => new Promise(() => undefined)); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "agent:work:dashboard", + agentsList: { defaultId: "main", mainKey: "main" }, + }); + + const refresh = refreshChat(host); + const outcome = await raceWithMacrotask(refresh); + + expect(outcome).toBe("resolved"); + const sessionsListPayload = findRequestPayload( + request as unknown as MockCallSource, + "sessions.list", + "agent direct sessions list payload", + ); + expect(sessionsListPayload.agentId).toBe("work"); + expect(sessionsListPayload.limit).toBe(50); + expect(sessionsListPayload.includeGlobal).toBe(true); + }); + it("uses hello default for global chat refresh before agents list loads", async () => { const request = vi.fn(() => new Promise(() => undefined)); const host = makeHost({ @@ -333,6 +355,33 @@ describe("refreshChat", () => { expect(sessionsListPayload.agentId).toBe("ops"); }); + it("keeps unknown chat refresh session rows unscoped", async () => { + const request = vi.fn(() => new Promise(() => undefined)); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "unknown", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + const refresh = refreshChat(host); + const outcome = await raceWithMacrotask(refresh); + + expect(outcome).toBe("resolved"); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "unknown", + limit: 100, + maxChars: 4000, + }); + const sessionsListPayload = findRequestPayload( + request as unknown as MockCallSource, + "sessions.list", + "unknown sessions list payload", + ); + expect(sessionsListPayload).not.toHaveProperty("agentId"); + expect(sessionsListPayload.includeUnknown).toBe(true); + }); + it("can wait for history without waiting for secondary metadata refreshes", async () => { const history = createDeferred(); const requestUpdate = vi.fn(); @@ -672,7 +721,7 @@ describe("refreshChat", () => { "sessions list payload", ); expect(sessionsListPayload).not.toHaveProperty("activeMinutes"); - expect(sessionsListPayload).not.toHaveProperty("agentId"); + expect(sessionsListPayload.agentId).toBe("main"); expect(sessionsListPayload.includeGlobal).toBe(true); expect(sessionsListPayload.includeUnknown).toBe(true); expect(sessionsListPayload.limit).toBe(50); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index aa233d898e0..b005f5f2708 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -45,7 +45,7 @@ import { isSessionRunActive } from "./session-run-state.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { SessionsListResult } from "./types.ts"; -import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; +import type { ChatAttachment, ChatQueueItem, ChatSessionRefreshTarget } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; @@ -77,7 +77,7 @@ export type ChatHost = ChatInputHistoryState & { sessionsShowArchived?: boolean; updateComplete?: Promise; requestUpdate?: () => void; - refreshSessionsAfterChat: Set; + refreshSessionsAfterChat: Map; pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null; chatSubmitGuards?: Map>; assistantAgentId?: string | null; @@ -250,6 +250,12 @@ function resolveSelectedGlobalAgentId( return agentId ? normalizeAgentId(agentId) : undefined; } +function resolveDefaultAgentIdForList(host: Pick): string { + return normalizeAgentId( + host.agentsList?.defaultId ?? readHelloDefaultAgentId(host) ?? DEFAULT_AGENT_ID, + ); +} + function scopedAgentIdForSession(host: ChatHost, sessionKey: string | undefined | null) { return isGlobalSessionKey(sessionKey) ? resolveSelectedGlobalAgentId(host) @@ -291,6 +297,32 @@ export function scopedAgentParamsForSession( return agentId ? { agentId } : {}; } +export function scopedAgentListParamsForSession( + host: Pick, + sessionKey: string, +) { + const parsed = parseAgentSessionKey(sessionKey); + const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey); + const agentId = + parsed?.agentId ?? + (normalizedSessionKey === "global" + ? resolveSelectedGlobalAgentId(host) + : normalizedSessionKey === "unknown" + ? undefined + : resolveDefaultAgentIdForList(host)); + return agentId ? { agentId: normalizeAgentId(agentId) } : {}; +} + +export function scopedAgentListParamsForRefreshTarget( + host: Pick, + target: ChatSessionRefreshTarget, +) { + const agentId = + normalizeOptionalString(target.agentId) ?? + scopedAgentListParamsForSession(host, target.sessionKey).agentId; + return agentId ? { agentId: normalizeAgentId(agentId) } : {}; +} + export async function handleAbortChat(host: ChatHost, opts?: ChatAbortOptions) { const activeRunId = host.chatRunId; const clearDraft = () => { @@ -593,12 +625,17 @@ async function sendQueuedChatMessage( } } if (prepared.refreshSessions) { + const refreshTarget = { + sessionKey, + agentId: prepared.agentId, + }; if (ack.status === "ok") { void loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), + ...scopedAgentListParamsForRefreshTarget(host, refreshTarget), }); } else { - host.refreshSessionsAfterChat.add(ack.runId); + host.refreshSessionsAfterChat.set(ack.runId, refreshTarget); } } discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments)); @@ -1299,7 +1336,7 @@ export async function refreshChat( const secondaryRefresh = Promise.allSettled([ loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), - ...scopedAgentParamsForSession(host, host.sessionKey), + ...scopedAgentListParamsForSession(host, host.sessionKey), }), refreshChatAvatar(host), refreshChatModels(host), diff --git a/ui/src/ui/app-gateway-chat-load.node.test.ts b/ui/src/ui/app-gateway-chat-load.node.test.ts index 046509bdb27..f6c415ba432 100644 --- a/ui/src/ui/app-gateway-chat-load.node.test.ts +++ b/ui/src/ui/app-gateway-chat-load.node.test.ts @@ -60,6 +60,20 @@ vi.mock("./app-chat.ts", () => ({ CHAT_SESSIONS_ACTIVE_MINUTES: 60, CHAT_SESSIONS_REFRESH_LIMIT: 50, createChatSessionsLoadOverrides: () => ({ activeMinutes: 60, limit: 50 }), + scopedAgentListParamsForSession: (_host: unknown, sessionKey: string) => { + const [, agentId] = sessionKey.split(":"); + return sessionKey.startsWith("agent:") && agentId ? { agentId } : {}; + }, + scopedAgentListParamsForRefreshTarget: ( + _host: unknown, + target: { sessionKey: string; agentId?: string }, + ) => { + if (target.agentId) { + return { agentId: target.agentId }; + } + const [, agentId] = target.sessionKey.split(":"); + return target.sessionKey.startsWith("agent:") && agentId ? { agentId } : {}; + }, clearPendingQueueItemsForRun: vi.fn(), flushChatQueueForEvent: vi.fn(), hasReconnectableQueuedChatSends: vi.fn(() => false), @@ -175,7 +189,7 @@ function createHost(tab: Tab) { toolStreamOrder: [], toolStreamSyncTimer: null, pendingAbort: null, - refreshSessionsAfterChat: new Set(), + refreshSessionsAfterChat: new Map(), execApprovalQueue: [], execApprovalError: null, updateAvailable: null, diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 4d233979ce6..31b6a838d5e 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -183,7 +183,7 @@ function createHost(): TestGatewayHost { toolStreamById: new Map(), toolStreamOrder: [], toolStreamSyncTimer: null, - refreshSessionsAfterChat: new Set(), + refreshSessionsAfterChat: new Map(), chatSideResultTerminalRuns: new Set(), execApprovalQueue: [], execApprovalBusy: false, diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 6f6d1d0e3d4..5960d9582ca 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -15,6 +15,28 @@ vi.mock("./app-chat.ts", () => ({ createChatSessionsLoadOverrides: () => ({ activeMinutes: 10, limit: 25 }), scopedAgentParamsForSession: (host: { assistantAgentId?: string | null }, sessionKey: string) => sessionKey === "global" && host.assistantAgentId ? { agentId: host.assistantAgentId } : {}, + scopedAgentListParamsForSession: ( + host: { assistantAgentId?: string | null }, + sessionKey: string, + ) => { + const [, agentId] = sessionKey.split(":"); + if (sessionKey.startsWith("agent:") && agentId) { + return { agentId }; + } + return sessionKey === "global" && host.assistantAgentId + ? { agentId: host.assistantAgentId } + : {}; + }, + scopedAgentListParamsForRefreshTarget: ( + _host: { assistantAgentId?: string | null }, + target: { sessionKey: string; agentId?: string }, + ) => { + if (target.agentId) { + return { agentId: target.agentId }; + } + const [, agentId] = target.sessionKey.split(":"); + return target.sessionKey.startsWith("agent:") && agentId ? { agentId } : {}; + }, clearPendingQueueItemsForRun: clearPendingQueueItemsForRunMock, flushChatQueueForEvent: flushChatQueueForEventMock, refreshChatAvatar: vi.fn(), @@ -136,7 +158,7 @@ function createHost() { sessionKey: "main", chatRunId: null, toolStreamOrder: [], - refreshSessionsAfterChat: new Set(), + refreshSessionsAfterChat: new Map(), execApprovalQueue: [], execApprovalError: null, updateAvailable: null, @@ -153,7 +175,7 @@ describe("handleGatewayEvent sessions.changed", () => { handleChatEventMock.mockReset().mockReturnValue("final"); const host = createHost(); host.sessionKey = "agent:ops:main"; - host.refreshSessionsAfterChat.add("run-1"); + host.refreshSessionsAfterChat.set("run-1", { sessionKey: "agent:ops:main" }); handleGatewayEvent(host, { type: "event", @@ -165,6 +187,7 @@ describe("handleGatewayEvent sessions.changed", () => { expect(loadSessionsMock).toHaveBeenCalledWith(host, { activeMinutes: 10, limit: 25, + agentId: "ops", }); }); @@ -173,8 +196,8 @@ describe("handleGatewayEvent sessions.changed", () => { handleChatEventMock.mockReset().mockReturnValue("final"); const host = createHost(); host.sessionKey = "global"; - host.assistantAgentId = "work"; - host.refreshSessionsAfterChat.add("run-1"); + host.assistantAgentId = "main"; + host.refreshSessionsAfterChat.set("run-1", { sessionKey: "global", agentId: "work" }); handleGatewayEvent(host, { type: "event", @@ -685,6 +708,7 @@ describe("handleGatewayEvent session.message", () => { expect(loadSessionsMock).toHaveBeenCalledWith(host, { activeMinutes: 10, limit: 25, + agentId: "qa", publishChatRunStatus: false, }); await Promise.resolve(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index cadf75e6f90..d46a449c355 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -10,7 +10,9 @@ import { hasReconnectableQueuedChatSends, markQueuedChatSendsWaitingForReconnect, refreshChatAvatar, + scopedAgentListParamsForRefreshTarget, retryReconnectableQueuedChatSends, + scopedAgentListParamsForSession, scopedAgentParamsForSession, } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; @@ -84,6 +86,7 @@ import type { StatusSummary, UpdateAvailable, } from "./types.ts"; +import type { ChatSessionRefreshTarget } from "./ui-types.ts"; function isGenericBrowserFetchFailure(message: string): boolean { return /^(?:typeerror:\s*)?(?:fetch failed|failed to fetch)$/i.test(message.trim()); @@ -122,7 +125,7 @@ type GatewayHost = { sessionsShowArchived: boolean; chatRunId: string | null; pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null; - refreshSessionsAfterChat: Set; + refreshSessionsAfterChat: Map; sessionsLoading?: boolean; execApprovalQueue: ExecApprovalRequest[]; execApprovalBusy: boolean; @@ -794,12 +797,13 @@ function handleTerminalChatEvent( payload?.runId, ); const runId = payload?.runId; - if (runId && host.refreshSessionsAfterChat.has(runId)) { + const refreshTarget = runId ? host.refreshSessionsAfterChat.get(runId) : undefined; + if (runId && refreshTarget) { host.refreshSessionsAfterChat.delete(runId); if (state === "final") { void loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), - ...scopedAgentParamsForSession(host, host.sessionKey), + ...scopedAgentListParamsForRefreshTarget(host, refreshTarget), }); } } @@ -972,7 +976,7 @@ function handleSessionMessageGatewayEvent( const runIdBeforeRefresh = host.chatRunId; void loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), - ...scopedAgentParamsForSession(host, host.sessionKey), + ...scopedAgentListParamsForSession(host, host.sessionKey), publishChatRunStatus: false, }).finally(() => replayDeferredSessionMessageReloadAfterSessionsRefresh( diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index 4fde05c8782..740f4d4f911 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -191,7 +191,7 @@ function createState(overrides: Partial = {}): AppViewState { dreamingRestartConfirmLoading: false, dreamingStatusError: null, client: null, - refreshSessionsAfterChat: new Set(), + refreshSessionsAfterChat: new Map(), connect: vi.fn(), setTab: vi.fn(), setTheme: vi.fn(), @@ -305,4 +305,211 @@ describe("renderApp assistant avatar routing", () => { expect(container.querySelector(".shell")).toBeInstanceOf(HTMLElement); }); + + it("filters sidebar recent sessions to the active chat agent", () => { + const container = document.createElement("div"); + + render( + renderApp( + createState({ + tab: "chat", + sessionKey: "agent:work:main", + assistantAgentId: "work", + agentsList: { + defaultId: "main", + agents: [ + { id: "main", name: "Main" }, + { id: "work", name: "Work" }, + ], + } as AppViewState["agentsList"], + sessionsResult: { + ts: 0, + path: "", + count: 3, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "agent:main:dashboard:old", + kind: "direct", + label: "Main old", + updatedAt: 30, + }, + { + key: "agent:work:dashboard:new", + kind: "direct", + label: "Work new", + updatedAt: 20, + }, + { + key: "agent:work:dashboard:older", + kind: "direct", + label: "Work older", + updatedAt: 10, + }, + ], + } as AppViewState["sessionsResult"], + }), + ), + container, + ); + + const labels = Array.from(container.querySelectorAll(".sidebar-recent-session__name")).map( + (node) => node.textContent?.trim(), + ); + expect(labels).toEqual(["Work new", "Work older"]); + }); + + it("keeps legacy main sessions tied to the default agent when identity is stale", () => { + const container = document.createElement("div"); + + render( + renderApp( + createState({ + tab: "chat", + sessionKey: "main", + assistantAgentId: "work", + agentsList: { + defaultId: "main", + agents: [ + { id: "main", name: "Main" }, + { id: "work", name: "Work" }, + ], + } as AppViewState["agentsList"], + sessionsResult: { + ts: 0, + path: "", + count: 3, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "main", + kind: "direct", + label: "Main legacy", + updatedAt: 30, + }, + { + key: "agent:main:dashboard:old", + kind: "direct", + label: "Main old", + updatedAt: 20, + }, + { + key: "agent:work:dashboard:new", + kind: "direct", + label: "Work new", + updatedAt: 10, + }, + ], + } as AppViewState["sessionsResult"], + }), + ), + container, + ); + + const labels = Array.from(container.querySelectorAll(".sidebar-recent-session__name")).map( + (node) => node.textContent?.trim(), + ); + expect(labels).toEqual(["Main legacy", "Main old"]); + }); + + it("uses hello default agent for global sidebar sessions before agent list hydration", () => { + const container = document.createElement("div"); + + render( + renderApp( + createState({ + tab: "chat", + sessionKey: "global", + assistantAgentId: null, + agentsList: null, + hello: { + snapshot: { + sessionDefaults: { + defaultAgentId: "ops", + }, + }, + } as AppViewState["hello"], + sessionsResult: { + ts: 0, + path: "", + count: 2, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "agent:main:dashboard:old", + kind: "direct", + label: "Main old", + updatedAt: 20, + }, + { + key: "agent:ops:dashboard:new", + kind: "direct", + label: "Ops new", + updatedAt: 10, + }, + ], + } as AppViewState["sessionsResult"], + }), + ), + container, + ); + + const labels = Array.from(container.querySelectorAll(".sidebar-recent-session__name")).map( + (node) => node.textContent?.trim(), + ); + expect(labels).toEqual(["Ops new"]); + }); + + it("keeps unknown sidebar sessions unscoped", () => { + const container = document.createElement("div"); + + render( + renderApp( + createState({ + tab: "chat", + sessionKey: "unknown", + assistantAgentId: "work", + agentsList: { + defaultId: "main", + agents: [ + { id: "main", name: "Main" }, + { id: "work", name: "Work" }, + ], + } as AppViewState["agentsList"], + sessionsResult: { + ts: 0, + path: "", + count: 3, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "agent:main:dashboard:old", + kind: "direct", + label: "Main old", + updatedAt: 30, + }, + { + key: "agent:work:dashboard:new", + kind: "direct", + label: "Work new", + updatedAt: 20, + }, + { + key: "unknown", + kind: "unknown", + label: "Unknown sentinel", + updatedAt: 10, + }, + ], + } as AppViewState["sessionsResult"], + }), + ), + container, + ); + + const labels = Array.from(container.querySelectorAll(".sidebar-recent-session__name")).map( + (node) => node.textContent?.trim(), + ); + expect(labels).toEqual(["Main old", "Work new"]); + }); }); diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index e77f6c1b2cc..0031d746492 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -28,6 +28,27 @@ vi.mock("./app-chat.ts", () => ({ includeUnknown: true, showArchived: false, }), + scopedAgentParamsForSession: (state: unknown, sessionKey: string) => { + if (sessionKey === "global") { + return { + agentId: (state as { assistantAgentId?: string | null }).assistantAgentId ?? "main", + }; + } + const [, agentId] = sessionKey.split(":"); + return sessionKey.startsWith("agent:") && agentId ? { agentId } : {}; + }, + scopedAgentListParamsForSession: (state: unknown, sessionKey: string) => { + if (sessionKey === "global") { + return { + agentId: (state as { assistantAgentId?: string | null }).assistantAgentId ?? "main", + }; + } + if (sessionKey === "unknown") { + return {}; + } + const [, agentId] = sessionKey.split(":"); + return sessionKey.startsWith("agent:") && agentId ? { agentId } : { agentId: "main" }; + }, refreshChat: refreshChatMock, refreshChatAvatar: refreshChatAvatarMock, })); @@ -786,6 +807,7 @@ describe("createChatSession", () => { includeGlobal: true, includeUnknown: true, showArchived: false, + agentId: "ops", }, ); expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat"); @@ -798,6 +820,45 @@ describe("createChatSession", () => { expect(loadChatHistoryMock).toHaveBeenCalledWith(state); }); + it("creates selected global sessions under the same agent used for refresh", async () => { + const state = createChatSessionState({ + sessionKey: "global", + assistantAgentId: "work", + sessionsResult: { + ts: 0, + path: "", + count: 1, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [row({ key: "global", kind: "global" })], + }, + }); + createSessionAndRefreshMock.mockResolvedValue("agent:work:dashboard:new-chat"); + refreshChatAvatarMock.mockResolvedValue(undefined); + refreshSlashCommandsMock.mockResolvedValue(undefined); + loadChatHistoryMock.mockResolvedValue(undefined); + loadSessionsMock.mockResolvedValue(undefined); + + await createChatSession(state); + + expect(createSessionAndRefreshMock).toHaveBeenCalledWith( + state, + { + agentId: "work", + parentSessionKey: "global", + emitCommandHooks: true, + }, + { + activeMinutes: 120, + limit: 50, + includeGlobal: true, + includeUnknown: true, + showArchived: false, + agentId: "work", + }, + ); + expect(state.sessionKey).toBe("agent:work:dashboard:new-chat"); + }); + it("preserves draft and attachment edits made while session creation is in flight", async () => { const state = createChatSessionState(); const updatedAttachments = [ @@ -988,6 +1049,7 @@ describe("switchChatSession", () => { includeGlobal: true, includeUnknown: true, showArchived: false, + agentId: "main", }); expect( (state as unknown as { announceSessionSwitch: ReturnType }) diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index ee283151e2e..74e0b1dcb08 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,6 +1,12 @@ import { html, nothing } from "lit"; import { t } from "../i18n/index.ts"; -import { createChatSessionsLoadOverrides, refreshChat, refreshChatAvatar } from "./app-chat.ts"; +import { + createChatSessionsLoadOverrides, + refreshChat, + refreshChatAvatar, + scopedAgentParamsForSession, + scopedAgentListParamsForSession, +} from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts"; @@ -22,6 +28,7 @@ import { icons } from "./icons.ts"; import { iconForTab, isSettingsTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import { isCronSessionKey, parseSessionKey, resolveSessionDisplayName } from "./session-display.ts"; import { + isSessionKeyTiedToAgent, normalizeAgentId, parseAgentSessionKey, resolveAgentIdFromSessionKey, @@ -710,12 +717,15 @@ export async function createChatSession(state: AppViewState): Promise { const nextSessionKey = await createSessionAndRefresh( state as unknown as Parameters[0], { - agentId: resolveAgentIdFromSessionKey(previousSessionKey), + agentId: + scopedAgentParamsForSession(state, previousSessionKey).agentId ?? + resolveAgentIdFromSessionKey(previousSessionKey), parentSessionKey, emitCommandHooks: parentSessionKey !== undefined ? true : undefined, }, { ...createChatSessionsLoadOverrides(state), + ...scopedAgentListParamsForSession(state, previousSessionKey), }, ); if ( @@ -744,6 +754,7 @@ export async function createChatSession(state: AppViewState): Promise { async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { ...createChatSessionsLoadOverrides(state), + ...scopedAgentListParamsForSession(state, state.sessionKey), }); } @@ -756,16 +767,12 @@ function countHiddenCronSessions(state: AppViewState, sessions: SessionsListResu parseAgentSessionKey(state.sessionKey)?.agentId ?? state.agentsList?.defaultId ?? "main", ); const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - const isTiedToActiveAgent = (key: string) => { - const parsed = parseAgentSessionKey(key); - if (parsed) { - return normalizeAgentId(parsed.agentId) === activeAgentId; - } - return activeAgentId === defaultAgentId; - }; return sessions.sessions.filter( - (s) => isCronSessionKey(s.key) && s.key !== state.sessionKey && isTiedToActiveAgent(s.key), + (s) => + isCronSessionKey(s.key) && + s.key !== state.sessionKey && + isSessionKeyTiedToAgent(s.key, activeAgentId, defaultAgentId), ).length; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f2ace2b7205..8b676f5f5d9 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -6,6 +6,7 @@ import { createChatSessionsLoadOverrides, hasAbortableSessionRun, refreshChat, + scopedAgentListParamsForSession, scopedAgentParamsForSession, } from "./app-chat.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; @@ -155,7 +156,9 @@ import { isCronSessionKey, resolveSessionDisplayName } from "./session-display.t import "./components/dashboard-header.ts"; import { buildAgentMainSessionKey, + isSessionKeyTiedToAgent, isSubagentSessionKey, + normalizeAgentId, parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; @@ -252,7 +255,40 @@ function isSidebarSessionBusy(state: AppViewState) { ); } +function resolveSidebarDefaultAgentId(state: AppViewState): string { + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + return normalizeAgentId( + state.agentsList?.defaultId ?? snapshot?.sessionDefaults?.defaultAgentId ?? "main", + ); +} + +function resolveSidebarSelectedAgentId(state: AppViewState): string { + const parsed = parseAgentSessionKey(state.sessionKey); + if (parsed) { + return normalizeAgentId(parsed.agentId); + } + const sessionKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); + const fallbackAgentId = + sessionKey === "global" || sessionKey === "unknown" + ? (state.assistantAgentId ?? resolveSidebarDefaultAgentId(state)) + : resolveSidebarDefaultAgentId(state); + return normalizeAgentId(fallbackAgentId); +} + +function isSidebarSessionForSelectedAgent( + state: AppViewState, + row: GatewaySessionRow, + selectedAgentId: string, +): boolean { + return isSessionKeyTiedToAgent(row.key, selectedAgentId, resolveSidebarDefaultAgentId(state)); +} + function resolveSidebarRecentSessions(state: AppViewState): GatewaySessionRow[] { + const selectedAgentId = resolveSidebarSelectedAgentId(state); + const shouldFilterByAgent = + normalizeOptionalString(state.sessionKey)?.toLowerCase() !== "unknown"; return (state.sessionsResult?.sessions ?? []) .filter( (row) => @@ -262,7 +298,8 @@ function resolveSidebarRecentSessions(state: AppViewState): GatewaySessionRow[] row.kind !== "cron" && !isCronSessionKey(row.key) && !isSubagentSessionKey(row.key) && - !row.spawnedBy, + !row.spawnedBy && + (!shouldFilterByAgent || isSidebarSessionForSelectedAgent(state, row, selectedAgentId)), ) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, 5); @@ -2910,6 +2947,7 @@ export function renderApp(state: AppViewState) { state.setTab("sessions" as import("./navigation.ts").Tab); void loadSessions(state, { ...createChatSessionsLoadOverrides(state), + ...scopedAgentListParamsForSession(state, state.sessionKey), }); }, onToggleRealtimeTalk: () => state.toggleRealtimeTalk(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index dd921387af8..b8859694b96 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -133,6 +133,8 @@ export type AppViewState = { chatSessionPickerLoading: boolean; chatSessionPickerError: string | null; chatSessionPickerResult: SessionsListResult | null; + sessionsResultAgentId?: string | null; + chatAgentSessionRowsByAgent?: Record; announceSessionSwitch?: (sessionKey: string, label: string) => void; chatQueue: ChatQueueItem[]; chatQueueBySession: Record; @@ -460,7 +462,7 @@ export type AppViewState = { overviewLogLines: string[]; overviewLogCursor: number; client: GatewayBrowserClient | null; - refreshSessionsAfterChat: Set; + refreshSessionsAfterChat: Map; connect: () => void; setTab: (tab: Tab) => void; setChatMobileControlsOpen: ( diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 21d7b528008..95a2c9848fc 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -657,7 +657,7 @@ export class OpenClawApp extends LitElement { controlUiResponsivenessObserver: { disconnect: () => void } | null = null; toolStreamById = new Map(); toolStreamOrder: string[] = []; - refreshSessionsAfterChat = new Set(); + refreshSessionsAfterChat = new Map(); chatSideResultTerminalRuns = new Set(); basePath = ""; popStateHandler = () => diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index ba86c188ec0..c78df4e5ee1 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -2,7 +2,7 @@ import { html } from "lit"; import { live } from "lit/directives/live.js"; import { repeat } from "lit/directives/repeat.js"; import { t } from "../../i18n/index.ts"; -import { createChatSessionsLoadOverrides } from "../app-chat.ts"; +import { createChatSessionsLoadOverrides, scopedAgentListParamsForSession } from "../app-chat.ts"; import type { AppViewState } from "../app-view-state.ts"; import { createChatModelOverride } from "../chat-model-ref.ts"; import { @@ -20,6 +20,7 @@ import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; import { isCronSessionKey, resolveSessionDisplayName } from "../session-display.ts"; import { buildAgentMainSessionKey, + isSessionKeyTiedToAgent, isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey, @@ -58,6 +59,7 @@ export function renderChatSessionSelect( onSwitchSession: ChatSessionSwitchHandler = () => undefined, options: { surface?: ChatSessionSelectSurface } = {}, ) { + rememberChatAgentSessionRows(state, state.sessionsResult); const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); const agentOptions = resolveChatAgentFilterOptions(state); const hasAgentSelect = agentOptions.length > 1; @@ -111,6 +113,7 @@ function resolveNextChatSessionOffset( async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { ...createChatSessionsLoadOverrides(state), + ...scopedAgentListParamsForSession(state, state.sessionKey), }); } @@ -1122,12 +1125,65 @@ function resolveChatAgentFilterId(state: AppViewState, sessionKey: string): stri return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main"); } -function isSessionKeyTiedToAgent(key: string, agentId: string, defaultAgentId: string): boolean { - const parsed = parseAgentSessionKey(key); - if (parsed) { - return normalizeAgentId(parsed.agentId) === agentId; +function resolvePreferredSessionCandidateAgentId( + row: SessionsListResult["sessions"][number], + defaultAgentId: string, +): string | null { + if (row.kind === "global" || row.kind === "unknown" || isCronSessionKey(row.key)) { + return null; } - return agentId === defaultAgentId; + if (isSubagentSessionKey(row.key) || row.spawnedBy) { + return null; + } + const parsed = parseAgentSessionKey(row.key); + return normalizeAgentId(parsed?.agentId ?? defaultAgentId); +} + +function rememberChatAgentSessionRows( + state: AppViewState, + sessions: SessionsListResult | null, +): void { + if (!sessions) { + return; + } + const rows = sessions.sessions; + const refreshedAgentId = normalizeOptionalString(state.sessionsResultAgentId); + const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); + const grouped = new Map(); + for (const row of rows) { + const agentId = resolvePreferredSessionCandidateAgentId(row, defaultAgentId); + if (!agentId) { + continue; + } + grouped.set(agentId, [...(grouped.get(agentId) ?? []), row]); + } + if (grouped.size === 0 && !refreshedAgentId) { + return; + } + state.chatAgentSessionRowsByAgent ??= {}; + if (refreshedAgentId) { + state.chatAgentSessionRowsByAgent[refreshedAgentId] = grouped.get(refreshedAgentId) ?? []; + } + for (const [agentId, agentRows] of grouped) { + state.chatAgentSessionRowsByAgent[agentId] = agentRows; + } +} + +function rowsForPreferredAgentSession( + state: AppViewState, + normalizedAgentId: string, + defaultAgentId: string, +): SessionsListResult["sessions"] { + const byKey = new Map(); + for (const row of state.chatAgentSessionRowsByAgent?.[normalizedAgentId] ?? []) { + byKey.set(row.key, row); + } + for (const row of state.sessionsResult?.sessions ?? []) { + if (resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId) { + byKey.set(row.key, row); + } + } + return [...byKey.values()]; } function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): string { @@ -1136,18 +1192,12 @@ function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): return state.sessionKey; } const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - const eligible = (state.sessionsResult?.sessions ?? []) + const eligible = rowsForPreferredAgentSession(state, normalizedAgentId, defaultAgentId) .filter((row) => { if (!isSessionKeyTiedToAgent(row.key, normalizedAgentId, defaultAgentId)) { return false; } - if (row.kind === "global" || row.kind === "unknown") { - return false; - } - if (isCronSessionKey(row.key)) { - return false; - } - return !isSubagentSessionKey(row.key) && !row.spawnedBy; + return resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId; }) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); if (eligible[0]?.key) { diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index ddb5548c839..f8beea375d0 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -1251,6 +1251,75 @@ describe("applySessionsChangedEvent", () => { expect(state.sessionsResult?.count).toBe(1); }); + it("removes deleted sessions from cached chat agent targets", () => { + const state = createState(async () => undefined, { + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], + }, + chatAgentSessionRowsByAgent: { + work: [ + { key: "agent:work:dashboard:deleted", kind: "direct", updatedAt: 3 }, + { key: "agent:work:main", kind: "direct", updatedAt: 1 }, + ], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "agent:work:dashboard:deleted", + reason: "delete", + ts: 2, + }); + + expect(applied).toEqual({ applied: true, change: "deleted" }); + expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ + "agent:main:main", + ]); + expect(state.chatAgentSessionRowsByAgent?.work?.map((session) => session.key)).toEqual([ + "agent:work:main", + ]); + }); + + it("keeps out-of-scope session events out of scoped results", () => { + const state = createState(async () => undefined, { + sessionsResultAgentId: "work", + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:work:main", kind: "direct", updatedAt: 1 }], + }, + chatAgentSessionRowsByAgent: { + ops: [{ key: "agent:ops:old", kind: "direct", updatedAt: 1 }], + }, + }); + + const applied = applySessionsChangedEvent(state, { + session: { + key: "agent:ops:main", + kind: "direct", + agentId: "ops", + updatedAt: 2, + }, + reason: "message", + ts: 2, + }); + + expect(applied).toEqual({ applied: true, change: "inserted" }); + expect(state.sessionsResult?.count).toBe(1); + expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ + "agent:work:main", + ]); + expect(state.chatAgentSessionRowsByAgent?.ops?.map((session) => session.key)).toEqual([ + "agent:ops:main", + "agent:ops:old", + ]); + }); + it("does not synthesize new sessions from partial events without a store-backed row", () => { const state = createState(async () => undefined, { sessionsResult: { diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index db92b66ca92..02696abbb92 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -3,7 +3,7 @@ import { type ChatRunUiStatus, } from "../chat/run-lifecycle.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "../gateway.ts"; -import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; +import { isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; import type { GatewaySessionRow, SessionCompactionCheckpoint, @@ -30,6 +30,8 @@ export type SessionsState = SessionsChatRunState & { connected: boolean; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; + sessionsResultAgentId?: string | null; + chatAgentSessionRowsByAgent?: Record; sessionsError: string | null; sessionsFilterActive: string; sessionsFilterLimit: string; @@ -175,10 +177,7 @@ function sessionsChangedGlobalAgentMatches( return true; } const eventSession = isRecord(payload.session) ? payload.session : null; - const rawAgentId = - (typeof payload.agentId === "string" && payload.agentId.trim()) || - (typeof eventSession?.agentId === "string" && eventSession.agentId.trim()); - const eventAgentId = rawAgentId ? normalizeAgentId(rawAgentId) : null; + const eventAgentId = readSessionsChangedEventAgentId(payload, eventSession); const selectedAgentId = resolveSelectedGlobalAgentId(state); if (eventAgentId) { return eventAgentId === selectedAgentId; @@ -186,6 +185,41 @@ function sessionsChangedGlobalAgentMatches( return selectedAgentId === resolveDefaultGlobalAgentId(state); } +function readSessionsChangedEventAgentId( + payload: Record, + eventSession: Record | null, +): string | null { + const rawAgentId = + (typeof payload.agentId === "string" && payload.agentId.trim()) || + (typeof eventSession?.agentId === "string" && eventSession.agentId.trim()); + return rawAgentId ? normalizeAgentId(rawAgentId) : null; +} + +function sessionsChangedResultScopeMatches( + state: SessionsState, + payload: Record, + eventSession: Record | null, + key: string, + existing: GatewaySessionRow | undefined, +): boolean { + const resultAgentId = + typeof state.sessionsResultAgentId === "string" && state.sessionsResultAgentId.trim() + ? normalizeAgentId(state.sessionsResultAgentId) + : null; + if (!resultAgentId) { + return true; + } + const eventAgentId = readSessionsChangedEventAgentId(payload, eventSession); + if (eventAgentId) { + return eventAgentId === resultAgentId; + } + const parsed = parseAgentSessionKey(key); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId) === resultAgentId; + } + return Boolean(existing); +} + function buildSelectedSessionMessageSubscriptionParams(state: SessionsState, key: string) { const agentId = resolveSelectedSessionMessageSubscriptionAgentId(state, key); return { @@ -446,6 +480,54 @@ function invalidateCheckpointCacheForKey(state: SessionsState, key: string) { state.sessionsCheckpointErrorByKey = nextErrors; } +function invalidateCachedChatAgentSessionRow(state: SessionsState, key: string): boolean { + const rowsByAgent = state.chatAgentSessionRowsByAgent; + if (!rowsByAgent) { + return false; + } + let removed = false; + for (const [agentId, rows] of Object.entries(rowsByAgent)) { + const nextRows = rows.filter((row) => row.key !== key); + if (nextRows.length === rows.length) { + continue; + } + rowsByAgent[agentId] = nextRows; + removed = true; + } + return removed; +} + +function resolveCachedChatAgentSessionRowAgentId( + state: SessionsState, + row: GatewaySessionRow, +): string | null { + if (row.kind === "global" || row.kind === "unknown" || row.kind === "cron") { + return null; + } + if (isSubagentSessionKey(row.key) || row.spawnedBy) { + return null; + } + const parsed = parseAgentSessionKey(row.key); + return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main"); +} + +function upsertCachedChatAgentSessionRow(state: SessionsState, row: GatewaySessionRow): boolean { + if (!state.sessionsShowArchived && isArchivedSessionRow(row)) { + return invalidateCachedChatAgentSessionRow(state, row.key); + } + const agentId = resolveCachedChatAgentSessionRowAgentId(state, row); + if (!agentId) { + return false; + } + state.chatAgentSessionRowsByAgent ??= {}; + const existingRows = state.chatAgentSessionRowsByAgent[agentId] ?? []; + state.chatAgentSessionRowsByAgent[agentId] = [ + row, + ...existingRows.filter((r) => r.key !== row.key), + ].toSorted(compareSessionRowsByUpdatedAt); + return true; +} + async function fetchSessionCompactionCheckpoints(state: SessionsState, key: string) { state.sessionsCheckpointLoadingKey = key; state.sessionsCheckpointErrorByKey = { @@ -563,9 +645,17 @@ export function applySessionsChangedEvent( const previousRows = state.sessionsResult.sessions; const existingIndex = previousRows.findIndex((row) => row.key === key); + const existing = existingIndex >= 0 ? previousRows[existingIndex] : undefined; if (payload.reason === "delete") { + const removedCachedRow = invalidateCachedChatAgentSessionRow(state, key); + if ( + !sessionsChangedGlobalAgentMatches(state, payload, key) || + !sessionsChangedResultScopeMatches(state, payload, eventSession, key, existing) + ) { + return removedCachedRow ? { applied: true, change: "deleted" } : { applied: false }; + } if (existingIndex < 0) { - return { applied: false }; + return removedCachedRow ? { applied: true, change: "deleted" } : { applied: false }; } state.sessionsResult = { ...state.sessionsResult, @@ -575,7 +665,9 @@ export function applySessionsChangedEvent( invalidateCheckpointCacheForKey(state, key); return { applied: true, change: "deleted" }; } - const existing = existingIndex >= 0 ? previousRows[existingIndex] : undefined; + const matchesResultScope = + sessionsChangedGlobalAgentMatches(state, payload, key) && + sessionsChangedResultScopeMatches(state, payload, eventSession, key, existing); const hasReliableSource = existingIndex >= 0 || eventSession !== null || typeof source.sessionId === "string"; if (!hasReliableSource) { @@ -615,9 +707,15 @@ export function applySessionsChangedEvent( if (nextRow.totalTokensFresh === false && !hasOwn(source, "totalTokens")) { delete nextRow.totalTokens; } + if (!matchesResultScope) { + return upsertCachedChatAgentSessionRow(state, nextRow) + ? { applied: true, change: existingIndex >= 0 ? "updated" : "inserted" } + : { applied: false }; + } if (!state.sessionsShowArchived && isArchivedSessionRow(nextRow)) { + const removedCachedRow = invalidateCachedChatAgentSessionRow(state, key); if (existingIndex < 0) { - return { applied: false }; + return removedCachedRow ? { applied: true, change: "deleted" } : { applied: false }; } state.sessionsResult = { ...state.sessionsResult, @@ -839,6 +937,7 @@ async function loadSessionsOnce( configuredAgentsOnly, }; const agentId = overrides?.agentId?.trim(); + const resultAgentId = agentId ? normalizeAgentId(agentId) : null; if (agentId) { params.agentId = agentId; } @@ -866,6 +965,7 @@ async function loadSessionsOnce( overrides?.append === true && offset > 0 && state.sessionsResult ? appendSessionsResult(state.sessionsResult, projected) : projected; + state.sessionsResultAgentId = resultAgentId; if (hasCurrentChatSession(state)) { reconcileChatRunFromCurrentSessionRow(state, { publishRunStatus: overrides?.publishChatRunStatus !== false, diff --git a/ui/src/ui/session-key.ts b/ui/src/ui/session-key.ts index 55ffc13c7c4..0030008be22 100644 --- a/ui/src/ui/session-key.ts +++ b/ui/src/ui/session-key.ts @@ -87,6 +87,19 @@ export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | nu return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); } +export function isSessionKeyTiedToAgent( + sessionKey: string | undefined | null, + agentId: string, + defaultAgentId: string = DEFAULT_AGENT_ID, +): boolean { + const normalizedAgentId = normalizeAgentId(agentId); + const parsed = parseAgentSessionKey(sessionKey); + if (parsed) { + return normalizeAgentId(parsed.agentId) === normalizedAgentId; + } + return normalizedAgentId === normalizeAgentId(defaultAgentId); +} + export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean { const raw = normalizeOptionalString(sessionKey) ?? ""; if (!raw) { diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 7c74d57a382..24d1287e68b 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -25,6 +25,11 @@ export type ChatQueueItem = { agentId?: string; }; +export type ChatSessionRefreshTarget = { + sessionKey: string; + agentId?: string; +}; + export const CRON_CHANNEL_LAST = "last"; export type CronFormState = { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index a478dcbbff9..6e49e1a784f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1374,6 +1374,97 @@ describe("chat session controls", () => { expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:dashboard:beta-recent"); }); + it("keeps agent switch targets after scoped session refreshes", () => { + const { state } = createChatHeaderState(); + const onSwitchSession = vi.fn(); + state.sessionKey = "agent:alpha:main"; + state.agentsList = { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Deep Chat" }, + { id: "beta", name: "Coding" }, + ], + }; + state.sessionsResult = { + ts: 0, + path: "", + count: 3, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [ + { key: "agent:alpha:main", kind: "direct", updatedAt: 4 }, + { key: "agent:beta:dashboard:beta-recent", kind: "direct", updatedAt: 3 }, + { key: "agent:beta:main", kind: "direct", updatedAt: 2 }, + ], + }; + const container = document.createElement("div"); + render(renderChatSessionSelect(state, onSwitchSession), container); + + state.sessionsResultAgentId = "alpha"; + state.sessionsResult = { + ts: 1, + path: "", + count: 1, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [{ key: "agent:alpha:main", kind: "direct", updatedAt: 5 }], + }; + render(renderChatSessionSelect(state, onSwitchSession), container); + + const agentSelect = container.querySelector( + 'select[data-chat-agent-filter="true"]', + ); + agentSelect!.value = "beta"; + agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:dashboard:beta-recent"); + }); + + it("clears cached agent switch targets after a scoped empty refresh", () => { + const { state } = createChatHeaderState(); + const onSwitchSession = vi.fn(); + state.sessionKey = "agent:alpha:main"; + state.agentsList = { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Deep Chat" }, + { id: "beta", name: "Coding" }, + ], + }; + state.sessionsResult = { + ts: 0, + path: "", + count: 2, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [ + { key: "agent:alpha:main", kind: "direct", updatedAt: 4 }, + { key: "agent:beta:dashboard:deleted", kind: "direct", updatedAt: 3 }, + ], + }; + const container = document.createElement("div"); + render(renderChatSessionSelect(state, onSwitchSession), container); + + state.sessionsResultAgentId = "beta"; + state.sessionsResult = { + ts: 1, + path: "", + count: 0, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [], + }; + render(renderChatSessionSelect(state, onSwitchSession), container); + + const agentSelect = container.querySelector( + 'select[data-chat-agent-filter="true"]', + ); + agentSelect!.value = "beta"; + agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:main"); + }); + it("renders selector labels from the active locale", async () => { await i18n.setLocale("zh-CN"); const { state } = createChatHeaderState();