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();