fix(ui): filter sidebar recent sessions by selected agent

Fixes #88214.

Control UI dashboard Recent sessions now follows the selected agent, preserves legacy main sessions under stale identity, keeps unknown sessions unscoped, and scopes agent/default session refreshes before the session-list limit. Completed run refreshes now use the run's original session/agent target, global New Chat creates under the selected agent, and the agent switcher preserves last known target sessions across scoped refreshes without resurrecting deleted or archived sessions while accepting newer out-of-scope live rows into the switch cache. Also fixes a current-main lint issue around trusted approval params.

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
This commit is contained in:
zhang-guiping
2026-05-31 05:13:37 +08:00
committed by GitHub
parent 8b50cdd151
commit dbd3e10312
18 changed files with 825 additions and 53 deletions

View File

@@ -153,7 +153,7 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
chatModelSwitchPromises: {},
chatModelsLoading: false,
chatModelCatalog: [],
refreshSessionsAfterChat: new Set<string>(),
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<unknown>(() => 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<unknown>(() => 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<unknown>(() => 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<unknown>();
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);

View File

@@ -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<unknown>;
requestUpdate?: () => void;
refreshSessionsAfterChat: Set<string>;
refreshSessionsAfterChat: Map<string, ChatSessionRefreshTarget>;
pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null;
chatSubmitGuards?: Map<string, Promise<void>>;
assistantAgentId?: string | null;
@@ -250,6 +250,12 @@ function resolveSelectedGlobalAgentId(
return agentId ? normalizeAgentId(agentId) : undefined;
}
function resolveDefaultAgentIdForList(host: Pick<ChatHost, "agentsList" | "hello">): 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<ChatHost, "assistantAgentId" | "agentsList" | "hello">,
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<ChatHost, "assistantAgentId" | "agentsList" | "hello">,
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),

View File

@@ -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<string>(),
refreshSessionsAfterChat: new Map(),
execApprovalQueue: [],
execApprovalError: null,
updateAvailable: null,

View File

@@ -183,7 +183,7 @@ function createHost(): TestGatewayHost {
toolStreamById: new Map(),
toolStreamOrder: [],
toolStreamSyncTimer: null,
refreshSessionsAfterChat: new Set<string>(),
refreshSessionsAfterChat: new Map(),
chatSideResultTerminalRuns: new Set<string>(),
execApprovalQueue: [],
execApprovalBusy: false,

View File

@@ -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<string>(),
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();

View File

@@ -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<string>;
refreshSessionsAfterChat: Map<string, ChatSessionRefreshTarget>;
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(

View File

@@ -191,7 +191,7 @@ function createState(overrides: Partial<AppViewState> = {}): 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"]);
});
});

View File

@@ -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<typeof vi.fn> })

View File

@@ -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<boolean> {
const nextSessionKey = await createSessionAndRefresh(
state as unknown as Parameters<typeof createSessionAndRefresh>[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<boolean> {
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[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;
}

View File

@@ -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(),

View File

@@ -133,6 +133,8 @@ export type AppViewState = {
chatSessionPickerLoading: boolean;
chatSessionPickerError: string | null;
chatSessionPickerResult: SessionsListResult | null;
sessionsResultAgentId?: string | null;
chatAgentSessionRowsByAgent?: Record<string, SessionsListResult["sessions"]>;
announceSessionSwitch?: (sessionKey: string, label: string) => void;
chatQueue: ChatQueueItem[];
chatQueueBySession: Record<string, ChatQueueItem[]>;
@@ -460,7 +462,7 @@ export type AppViewState = {
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
refreshSessionsAfterChat: Map<string, import("./ui-types.js").ChatSessionRefreshTarget>;
connect: () => void;
setTab: (tab: Tab) => void;
setChatMobileControlsOpen: (

View File

@@ -657,7 +657,7 @@ export class OpenClawApp extends LitElement {
controlUiResponsivenessObserver: { disconnect: () => void } | null = null;
toolStreamById = new Map<string, ToolStreamEntry>();
toolStreamOrder: string[] = [];
refreshSessionsAfterChat = new Set<string>();
refreshSessionsAfterChat = new Map<string, import("./ui-types.js").ChatSessionRefreshTarget>();
chatSideResultTerminalRuns = new Set<string>();
basePath = "";
popStateHandler = () =>

View File

@@ -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<typeof loadSessions>[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<string, SessionsListResult["sessions"]>();
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<string, SessionsListResult["sessions"][number]>();
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) {

View File

@@ -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: {

View File

@@ -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<string, SessionsListResult["sessions"]>;
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<string, unknown>,
eventSession: Record<string, unknown> | 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<string, unknown>,
eventSession: Record<string, unknown> | 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,

View File

@@ -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) {

View File

@@ -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 = {

View File

@@ -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<HTMLSelectElement>(
'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<HTMLSelectElement>(
'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();