From 750d0dcd9e7838bf65105902806f6a22f7c1c5f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 16:17:25 -0700 Subject: [PATCH] improve(ui): make warm session switching instant (#121625) * perf(ui): make warm session switching instant Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): harden retained session ownership Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * test(ui): scope retained session assertions Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): align generated image preview checks * fix(ui): preserve early transcript reading position Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): adopt latest-navigation-wins router Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * fix(ui): restore retained pane reactivity Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 * test(auto-reply): align item lifecycle expectations * fix(ci): repair current-main validation gates Amp-Thread-ID: https://ampcode.com/threads/T-019fe957-0e49-707f-859f-9600ef536125 --------- Co-authored-by: Amp --- config/knip.config.ts | 4 + package.json | 2 +- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 2 +- src/media/store.retry.test.ts | 3 +- src/media/store.test.ts | 4 +- test/vitest/vitest.ui-isolated-paths.mjs | 1 + ui/package.json | 2 +- ui/src/app/chat-attachment-handoff.test.ts | 83 +- ui/src/app/chat-attachment-handoff.ts | 38 +- ui/src/app/router-outlet-chat.test.ts | 29 +- ui/src/components/app-sidebar-base.ts | 9 +- .../app-sidebar-session-navigation.ts | 39 +- ui/src/e2e/board-fixture.e2e.test.ts | 2 +- ...chat-attachment-read-lifecycle.e2e.test.ts | 48 +- ui/src/e2e/chat-composer-redesign.e2e.test.ts | 249 +--- .../chat-flow.history-recovery.e2e.test.ts | 38 +- ui/src/e2e/chat-flow.media-files.e2e.test.ts | 10 +- .../chat-flow.models-reasoning.e2e.test.ts | 20 +- ...t-flow.navigation-presentation.e2e.test.ts | 39 +- ...chat-flow.sidebar-presentation.e2e.test.ts | 4 +- ui/src/e2e/chat-flow.test-support.ts | 8 +- ui/src/e2e/claude-sessions.e2e.test.ts | 36 +- ui/src/e2e/codex-sessions.e2e.test.ts | 7 +- .../e2e/external-session-catalogs.e2e.test.ts | 9 +- .../e2e/managed-media-base-path.e2e.test.ts | 6 +- ...ession-page.prompt-attachments.e2e.test.ts | 10 - .../session-management.archive.e2e.test.ts | 12 +- .../session-management.sidebar.e2e.test.ts | 4 +- ui/src/lib/sessions/navigation-handoff.ts | 51 + ui/src/pages/chat/attachment-payload-store.ts | 11 + .../chat/chat-composer-memory-fallback.ts | 2 +- ui/src/pages/chat/chat-gateway.test.ts | 21 + ui/src/pages/chat/chat-gateway.ts | 11 +- .../chat/chat-page-retained-sessions.test.ts | 428 +++++++ .../pages/chat/chat-page-retained-sessions.ts | 345 ++++++ ui/src/pages/chat/chat-page.test.ts | 80 +- ui/src/pages/chat/chat-page.ts | 197 ++-- .../chat/chat-pane-attachment-handoff.test.ts | 18 +- .../chat/chat-pane-attachment-handoff.ts | 15 +- ui/src/pages/chat/chat-pane-base.ts | 47 +- ui/src/pages/chat/chat-pane-board.test.ts | 16 +- ui/src/pages/chat/chat-pane-board.ts | 2 +- ...-pane-browser-annotation-lifecycle.test.ts | 35 + ui/src/pages/chat/chat-pane-context.ts | 7 +- ui/src/pages/chat/chat-pane-history.test.ts | 219 +--- ui/src/pages/chat/chat-pane-history.ts | 145 +-- ui/src/pages/chat/chat-pane-lifecycle.test.ts | 49 - ui/src/pages/chat/chat-pane-lifecycle.ts | 175 +-- ui/src/pages/chat/chat-pane-render.ts | 13 +- .../chat-pane-retained-presentation.test.ts | 231 ++++ .../chat/chat-pane-retained-presentation.ts | 156 +++ .../pages/chat/chat-pane-session-creation.ts | 135 +++ ui/src/pages/chat/chat-pane-session.ts | 163 +-- ui/src/pages/chat/chat-pane-shared.ts | 135 +++ .../pages/chat/chat-pane.message-cut.test.ts | 25 +- ui/src/pages/chat/chat-pane.test-support.ts | 5 +- ui/src/pages/chat/chat-pane.test.ts | 12 +- ui/src/pages/chat/chat-state-controller.ts | 46 +- ui/src/pages/chat/chat-state-route.ts | 232 +--- ui/src/pages/chat/chat-state.test.ts | 1002 +---------------- .../chat/components/chat-message.test.ts | 5 +- .../components/chat-thread.measure.test.ts | 88 +- ui/src/pages/chat/components/chat-thread.ts | 67 +- .../pages/chat/route-draft-focus-handoff.ts | 3 + ui/src/pages/chat/route.ts | 125 +- ui/src/pages/chat/scroll.test.ts | 52 +- ui/src/pages/chat/scroll.ts | 27 +- .../pages/chat/session-message-cache.test.ts | 67 +- ui/src/pages/chat/session-message-cache.ts | 119 +- .../pages/chat/terminal-message-identity.ts | 4 - ui/src/styles/chat/split-view.css | 27 + .../app-sidebar-cases/session-navigation.ts | 69 ++ .../app-sidebar-cases/sessions.ts | 1 + ui/src/test-helpers/control-ui-e2e.ts | 26 + 75 files changed, 2425 insertions(+), 3012 deletions(-) create mode 100644 ui/src/pages/chat/chat-page-retained-sessions.test.ts create mode 100644 ui/src/pages/chat/chat-page-retained-sessions.ts create mode 100644 ui/src/pages/chat/chat-pane-retained-presentation.test.ts create mode 100644 ui/src/pages/chat/chat-pane-retained-presentation.ts create mode 100644 ui/src/pages/chat/chat-pane-session-creation.ts create mode 100644 ui/src/test-helpers/app-sidebar-cases/session-navigation.ts diff --git a/config/knip.config.ts b/config/knip.config.ts index 6fb9fa628efe..2fb73128ce4e 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -412,6 +412,10 @@ const config = { "src/plugins/memory-state.ts": ["exports", "types"], "src/plugins/session-discussion-registry.ts": ["exports"], "src/tasks/detached-task-runtime-state.ts": ["exports"], + // Focused Control UI tests consume these explicit state-machine seams; + // production uses them through their owning module/controller. + "ui/src/pages/chat/chat-state-refresh.ts": ["exports"], + "ui/src/pages/chat/composer-persistence.ts": ["exports"], // Focused media tests consume these explicit seams; production uses the helpers in-module. "src/agents/embedded-agent-subscribe.handlers.lifecycle.ts": ["exports"], "src/gateway/server-methods/chat-webchat-media.ts": ["exports"], diff --git a/package.json b/package.json index 711a92b02851..0f2a6ddfeded 100644 --- a/package.json +++ b/package.json @@ -1576,7 +1576,7 @@ "crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup", "deadcode:dependencies": "pnpm deadcode:full", "deadcode:exports": "node --import tsx scripts/check-deadcode-exports.mts", - "deadcode:full": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --no-config-hints --exclude duplicates && pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.all-exports.config.ts --no-progress --reporter compact --no-config-hints --exclude duplicates", + "deadcode:full": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --no-config-hints --exclude duplicates && pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.all-exports.config.ts --no-progress --reporter compact --no-config-hints --exports --exclude duplicates", "deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies", "deadcode:report": "pnpm deadcode:full; pnpm deadcode:exports", "deadcode:unused-files": "node --import tsx scripts/check-deadcode-unused-files.mts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e737d6ced1de..3ecc21d29fe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2438,8 +2438,8 @@ importers: specifier: workspace:* version: link:../packages/session-url-contract '@openclaw/uirouter': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.1.1 + version: 0.1.1 '@openclaw/workboard-contract': specifier: workspace:* version: link:../packages/workboard-contract @@ -4163,8 +4163,8 @@ packages: peerDependencies: undici: '>=8.5.0 <9' - '@openclaw/uirouter@0.1.0': - resolution: {integrity: sha512-w5tNj2FIukVJqJ1wt5wiDbrI6DI4tOkUbtqnnU5Fl4EgnRKFJtfHI3WrWTWTTJlXbbrwGSMptyrSZmQVdRo83Q==} + '@openclaw/uirouter@0.1.1': + resolution: {integrity: sha512-aiZFvWmP/ndpS3em5xVtiPlj1k6asA8ueoNxGxekA1024EZlCwnICPZklG6g2sU4Wlntp9AYocLCr5/iuvL7hw==} engines: {node: ^22.18.0 || >=24.11.0} '@opentelemetry/api-logs@0.221.0': @@ -11448,7 +11448,7 @@ snapshots: dependencies: undici: 8.9.0 - '@openclaw/uirouter@0.1.0': {} + '@openclaw/uirouter@0.1.1': {} '@opentelemetry/api-logs@0.221.0': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b56a3aec895a..ea050d19072a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,7 @@ minimumReleaseAgeExclude: - "@openclaw/fs-safe@0.5.4" - "@openclaw/libterminal@0.3.2" - "@openclaw/proxyline@0.3.4" - - "@openclaw/uirouter@0.1.0" + - "@openclaw/uirouter@0.1.1" - "acpx" - "tokenjuice" - "@agentclientprotocol/sdk" diff --git a/src/media/store.retry.test.ts b/src/media/store.retry.test.ts index cbc2b3bc118a..9ef14a274a32 100644 --- a/src/media/store.retry.test.ts +++ b/src/media/store.retry.test.ts @@ -1,6 +1,5 @@ // Media store retry tests cover the exact directory-recreation recovery boundary. import fs from "node:fs/promises"; -import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; @@ -53,7 +52,7 @@ describe("media store directory recreation", () => { return { ...actualStore, write: async (...args: Parameters) => { - if (args[0].includes(`${segment}${path.sep}`) && writeAttempts++ === 0) { + if (args[0].includes(`${segment}/`) && writeAttempts++ === 0) { throw injectedError; } return await actualStore.write(...args); diff --git a/src/media/store.test.ts b/src/media/store.test.ts index e3639cee736b..905ee2db267c 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -80,7 +80,7 @@ describe("media store", () => { ...actualStore, write: async (...args: Parameters) => { const [relativePath] = args; - if (!injectedEnoent && relativePath.includes(`${params.segment}${path.sep}`)) { + if (!injectedEnoent && relativePath.includes(`${params.segment}/`)) { injectedEnoent = true; await fs.rm(path.dirname(actualStore.path(relativePath)), { recursive: true, @@ -126,7 +126,7 @@ describe("media store", () => { ...actualStore, write: async (...args: Parameters) => { const [relativePath] = args; - if (relativePath.includes(`failed-buffer${path.sep}`)) { + if (relativePath.includes("failed-buffer/")) { attemptedRelPaths.push(relativePath); const err = new Error("no space left on device") as NodeJS.ErrnoException; err.code = "ENOSPC"; diff --git a/test/vitest/vitest.ui-isolated-paths.mjs b/test/vitest/vitest.ui-isolated-paths.mjs index defad078e3b0..045f4e160d18 100644 --- a/test/vitest/vitest.ui-isolated-paths.mjs +++ b/test/vitest/vitest.ui-isolated-paths.mjs @@ -15,6 +15,7 @@ export const uiIsolatedTestFiles = [ "ui/src/pages/chat/chat-pane-identity.test.ts", "ui/src/pages/chat/chat-pane-lifecycle.test.ts", "ui/src/pages/chat/chat-pane-pull-requests.test.ts", + "ui/src/pages/chat/chat-pane-retained-presentation.test.ts", "ui/src/pages/chat/chat-pane.message-cut.test.ts", "ui/src/pages/chat/chat-pane.read-marker.test.ts", "ui/src/pages/chat/chat-pane.session-discussion.test.ts", diff --git a/ui/package.json b/ui/package.json index a9d1fe7b7532..257907bdec44 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,7 +30,7 @@ "@openclaw/net-policy": "workspace:*", "@openclaw/normalization-core": "workspace:*", "@openclaw/session-url-contract": "workspace:*", - "@openclaw/uirouter": "0.1.0", + "@openclaw/uirouter": "0.1.1", "@openclaw/workboard-contract": "workspace:*", "@tanstack/lit-virtual": "3.13.35", "@tanstack/virtual-core": "3.17.6", diff --git a/ui/src/app/chat-attachment-handoff.test.ts b/ui/src/app/chat-attachment-handoff.test.ts index a32196ba454c..72f2d5c24061 100644 --- a/ui/src/app/chat-attachment-handoff.test.ts +++ b/ui/src/app/chat-attachment-handoff.test.ts @@ -74,36 +74,61 @@ describe("chat attachment route handoff", () => { } }); - it("releases a reused pane on session or Gateway-owner mismatch", () => { - const cases = [ - { ownerMatches: false, scopeKey: "agent:main:one" }, - { ownerMatches: true, scopeKey: "agent:main:two" }, - ]; - for (const { ownerMatches, scopeKey } of cases) { - const handoff = createChatAttachmentHandoff(); - const expectedOwner = {} as GatewayBrowserClient; - const annotation = storedAttachment( - `mismatch-${ownerMatches}-${scopeKey}`, - "image/png", - true, - ); - handoff.prepare({ - owner: expectedOwner, - paneId: "p1", - scopeKey: "agent:main:one", - attachments: [annotation], - fallbacks: {}, - }); + it("isolates retained session scopes and releases an exact Gateway-owner mismatch", () => { + const handoff = createChatAttachmentHandoff(); + const expectedOwner = {} as GatewayBrowserClient; + const first = storedAttachment("first-scope", "image/png", true); + const second = storedAttachment("second-scope", "image/png", true); + handoff.prepare({ + owner: expectedOwner, + paneId: "p1", + scopeKey: "agent:main:one", + attachments: [first], + fallbacks: {}, + }); + handoff.prepare({ + owner: expectedOwner, + paneId: "p1", + scopeKey: "agent:main:two", + attachments: [second], + fallbacks: {}, + }); - expect( - handoff.consume({ - owner: ownerMatches ? expectedOwner : ({} as GatewayBrowserClient), - paneId: "p1", - scopeKey, - }), - ).toBeNull(); - expect(getChatAttachmentDataUrl(annotation)).toBeNull(); - } + expect( + handoff.consume({ + owner: {} as GatewayBrowserClient, + paneId: "p1", + scopeKey: "agent:main:two", + }), + ).toBeNull(); + expect(getChatAttachmentDataUrl(second)).toBeNull(); + expect( + handoff.consume({ owner: expectedOwner, paneId: "p1", scopeKey: "agent:main:one" }), + ).toEqual({ attachments: [first], fallbacks: {} }); + }); + + it("does not let an empty retained session teardown erase another scope", () => { + const handoff = createChatAttachmentHandoff(); + const owner = {} as GatewayBrowserClient; + const annotation = storedAttachment("overlapping-scope", "image/png", true); + handoff.prepare({ + owner, + paneId: "p1", + scopeKey: "agent:main:one", + attachments: [annotation], + fallbacks: {}, + }); + handoff.prepare({ + owner, + paneId: "p1", + scopeKey: "agent:main:two", + attachments: [], + fallbacks: {}, + }); + + expect( + handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" })?.attachments, + ).toEqual([annotation]); }); it("keeps payloads reused by a replacement prepare", () => { diff --git a/ui/src/app/chat-attachment-handoff.ts b/ui/src/app/chat-attachment-handoff.ts index 8c4a5e8bcf9e..fed9bd8010fb 100644 --- a/ui/src/app/chat-attachment-handoff.ts +++ b/ui/src/app/chat-attachment-handoff.ts @@ -8,6 +8,7 @@ const MAX_PENDING_CHAT_ATTACHMENT_ENTRIES = 32; type PendingChatAttachmentHandoff = { owner: NonNullable[0]["owner"]>; + paneId: string; scopeKey: string; attachments: ChatAttachment[]; fallbacks: Record; @@ -37,17 +38,19 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff } release(handoffAttachments(handoff).filter((attachment) => !retainedIds.has(attachment.id))); }; - const take = (paneId: string) => { - const handoff = pending.get(paneId); + const entryKey = (paneId: string, scopeKey: string) => JSON.stringify([paneId, scopeKey]); + const take = (key: string) => { + const handoff = pending.get(key); if (handoff) { - pending.delete(paneId); + pending.delete(key); } return handoff; }; return { prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => { - const previous = take(paneId); + const key = entryKey(paneId, scopeKey); + const previous = take(key); const fallbackEntries = Object.entries(fallbacks); if (attachments.length === 0 && fallbackEntries.length === 0) { releaseHandoff(previous); @@ -67,37 +70,44 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff } return; } - pending.set(paneId, { + pending.set(key, { owner, + paneId, scopeKey, attachments: [...attachments], fallbacks: Object.fromEntries( - fallbackEntries.map(([key, fallback]) => [ - key, + fallbackEntries.map(([fallbackKey, fallback]) => [ + fallbackKey, { ...fallback, attachments: [...fallback.attachments] }, ]), ), }); // Route handoffs normally consume immediately. Bounds make abandoned // split panes release their packages instead of leaking for the tab lifetime. - for (const oldestPaneId of pending.keys()) { + for (const oldestKey of pending.keys()) { if (pending.size <= MAX_PENDING_CHAT_ATTACHMENT_ENTRIES) { break; } - releaseHandoff(take(oldestPaneId)); + releaseHandoff(take(oldestKey)); } }, consume: ({ owner, paneId, scopeKey }) => { - const match = take(paneId); - // Reusing a pane id with another session or Gateway is terminal for the - // old owner; keeping it would allow a later remount to recover stale evidence. - if (match?.owner === owner && match.scopeKey === scopeKey) { + const match = take(entryKey(paneId, scopeKey)); + // A Gateway mismatch is terminal for this exact presentation. Other + // retained session scopes under the same logical pane remain independent. + if (match?.owner === owner) { return { attachments: match.attachments, fallbacks: match.fallbacks }; } releaseHandoff(match); return null; }, - clearPane: (paneId) => releaseHandoff(take(paneId)), + clearPane: (paneId) => { + for (const [key, handoff] of pending) { + if (handoff.paneId === paneId) { + releaseHandoff(take(key)); + } + } + }, dispose: () => { disposed = true; for (const handoff of pending.values()) { diff --git a/ui/src/app/router-outlet-chat.test.ts b/ui/src/app/router-outlet-chat.test.ts index 994da0042e65..33c570d7eae0 100644 --- a/ui/src/app/router-outlet-chat.test.ts +++ b/ui/src/app/router-outlet-chat.test.ts @@ -91,8 +91,9 @@ afterEach(() => { }); describe("openclaw-router-outlet chat ownership", () => { - it("retains the exact subtree while the same thread switches presentation face", async () => { + it("retains the exact subtree across session and presentation switches", async () => { const sessionKey = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef"; + const nextSessionKey = "agent:main:dashboard:abcdef12-3456-7890-abcd-ef1234567890"; const row = { key: sessionKey, displayName: "Retained board" }; const chatTarget = sessionNavigationTarget({ face: "chat", @@ -102,9 +103,9 @@ describe("openclaw-router-outlet chat ownership", () => { }); const dashboardTarget = sessionNavigationTarget({ face: "dashboard", - sessionKey, + sessionKey: nextSessionKey, fallbackAgentId: "main", - row, + row: { key: nextSessionKey, displayName: "Next retained board" }, }); const nextData = deferred(); const teardown = vi.fn(async () => undefined); @@ -143,7 +144,7 @@ describe("openclaw-router-outlet chat ownership", () => { expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chat"); expect(teardown).not.toHaveBeenCalled(); - nextData.resolve(sessionData(sessionKey, "dashboard")); + nextData.resolve(sessionData(nextSessionKey, "dashboard")); await navigation; await settleOutlet(outlet); expect(outlet.querySelector("mcp-app-view")).toBe(appView); @@ -200,7 +201,7 @@ describe("openclaw-router-outlet chat ownership", () => { router.stop(); }); - it("does not retain a colliding short path when its full-key hint changes", async () => { + it("retains the chat page when a colliding short path's full-key hint changes", async () => { const firstKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001"; const secondKey = "agent:main:dashboard:12345678-0bbb-4000-8000-000000000002"; const pathname = "/chat/main/deploy-monitor-12345678"; @@ -235,17 +236,19 @@ describe("openclaw-router-outlet chat ownership", () => { undefined, location(pathname, `?${SESSION_NAVIGATION_KEY_PARAM}=${encodeURIComponent(secondKey)}`), ); - await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull(); - expect(teardown).toHaveBeenCalledOnce(); + await settleOutlet(outlet); + expect(outlet.querySelector("mcp-app-view")).toBe(firstView); + expect(teardown).not.toHaveBeenCalled(); nextData.resolve(sessionData(secondKey, "chat")); await navigation; await settleOutlet(outlet); - expect(outlet.querySelector("mcp-app-view")).not.toBe(firstView); + expect(outlet.querySelector("mcp-app-view")).toBe(firstView); + expect(teardown).not.toHaveBeenCalled(); router.stop(); }); - it("replaces the old owner for a clean unresolved route and its ambiguous result", async () => { + it("retains an unresolved route until an ambiguous result replaces it", async () => { const sessionKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001"; const nextData = deferred(); let loadCount = 0; @@ -265,10 +268,12 @@ describe("openclaw-router-outlet chat ownership", () => { const outlet = createOutlet(router); await router.navigate("chat", {}, undefined, location("/chat/main/alpha-12345678")); await settleOutlet(outlet); + const firstView = outlet.querySelector("mcp-app-view"); const navigation = router.navigate("chat", {}, undefined, location("/chat/main/beta-12345678")); - await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull(); - expect(teardown).toHaveBeenCalledOnce(); + await settleOutlet(outlet); + expect(outlet.querySelector("mcp-app-view")).toBe(firstView); + expect(teardown).not.toHaveBeenCalled(); nextData.resolve({ kind: "ambiguous", @@ -280,7 +285,7 @@ describe("openclaw-router-outlet chat ownership", () => { await navigation; await settleOutlet(outlet); expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chooser"); - expect(outlet.querySelector("mcp-app-view")).not.toBeNull(); + expect(teardown).toHaveBeenCalledOnce(); router.stop(); }); }); diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 11252c149d59..007b7076dd9c 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -14,7 +14,7 @@ import type { ThemeMode } from "../app/theme.ts"; import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts"; import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts"; import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.ts"; -import { parseAgentSessionKey } from "../lib/sessions/session-key.ts"; +import { parseAgentSessionKey, resolveUiConfiguredMainKey } from "../lib/sessions/session-key.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import type { SidebarWorkboardBoard, SidebarWorkboardRenderers } from "./app-sidebar-workboard.ts"; @@ -107,6 +107,13 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { } } + protected sessionMainKey(): string { + return resolveUiConfiguredMainKey({ + agentsList: this.context?.agents.state.agentsList, + hello: this.context?.gateway.snapshot.hello, + }); + } + readNewSessionAccess(): SessionMethodAccess { return readSessionMethodAccess(this.connected ? this.context?.gateway.snapshot : null, { method: "sessions.create", diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 8228c0a8845d..218096a24d1a 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -9,6 +9,7 @@ import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; import { isCronSessionKey } from "../lib/session-display.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import { filterVisibleSessionRows, sessionMatchesArchivedFilter } from "../lib/sessions/index.ts"; +import { runSessionNavigationIntent } from "../lib/sessions/navigation-handoff.ts"; import { composerDraftSearch, resolveSessionPreferredFace, @@ -18,7 +19,6 @@ import { areUiSessionKeysEquivalent, normalizeAgentId, parseAgentSessionKey, - resolveUiConfiguredMainKey, resolveUiDefaultAgentId, resolveUiSessionNavigationParentKey, } from "../lib/sessions/session-key.ts"; @@ -127,7 +127,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { private readonly runtimeSampledAtByRow = new WeakMap(); private readonly attention = new SessionAttentionController(this); - // Controller order preserves the former inheritance-chain field initialization order. declare readonly sessionOrganizer: SessionOrganizerController; declare readonly sidebarMenus: SidebarMenusController; @@ -174,7 +173,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { ]; for (const row of liveRows) { if (adopted.has(row.key) && !byKey.has(row.key)) { - byKey.set(row.key, this.projectSidebarSession(row)); + byKey.set(row.key, this.getSessionNavigationState().toSidebarSession(row)); } } return [...byKey.values()]; @@ -222,7 +221,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { void this.sessionData.loadChildSessions(session.key); } } - // The hidden main row needs an eager child fetch or its threads never surface. const mainRow = this.mainSessionRow(); if ( mainRow && @@ -257,10 +255,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0; } - protected projectSidebarSession(row: GatewaySessionRow): SidebarRecentSession { - return this.getSessionNavigationState().toSidebarSession(row); - } - public getRouteSessionKey(): string { return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || ""; } @@ -320,9 +314,16 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { preferenceDerivedFace: true, navigationKey: sessionKey, }); - this.prepareSessionNavigation(sessionKey, target.options.pathname); - this.onNavigate?.(face, target.options); - this.bindLiteralSession(sessionKey, this.selectedAgentIdForSessions(), target.options); + runSessionNavigationIntent(this, { + commit: () => { + this.prepareSessionNavigation(sessionKey, target.options.pathname); + this.onNavigate?.(face, target.options); + this.bindLiteralSession(sessionKey, this.selectedAgentIdForSessions(), target.options); + return true; + }, + face, + sessionKey, + }); }; /** Collapsed zones keep full rows for true header counts and status dots. */ @@ -331,8 +332,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { rows, grouping: this.sessionsGrouping, knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [], - // Raw gateway-owned order: grouping normalizes it against the full - // discovered category set without dropping catalog-lagging categories. + // Normalize gateway order without dropping catalog-lagging categories. sectionOrder: this.knownSectionOrder(), catalogIds: this.sessionsStatusFilter === "archived" @@ -404,7 +404,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { if (session.isChild || event.defaultPrevented || event.button !== 0) { return; } - // Modified parent clicks build multi-select; middle-click keeps native new-tab behavior. if (event.metaKey || event.ctrlKey) { event.preventDefault(); this.toggleSessionSelected(session.key); @@ -524,13 +523,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { ); } - protected sessionMainKey(): string { - return resolveUiConfiguredMainKey({ - agentsList: this.context?.agents.state.agentsList, - hello: this.context?.gateway.snapshot.hello, - }); - } - /** Offline routes to Settings instead of a dead chat load. */ private openAgentConversation(agentId: string) { if (!this.connected) { @@ -626,10 +618,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { showCron: this.sessionsShowCron, archivedFilter: this.sessionsStatusFilter, }).toSorted(this.compareSidebarSessionRows); - // The identity card is the main session's entry point; its row leaves the - // list and its spawned children surface as top-level threads instead. - // Children index under the gateway row's literal key, which may be an - // equivalent alias (e.g. "main"), so promotion tracks every removed key. + // The identity card replaces the main row; promote children under all equivalent aliases. const mainSessionKey = this.selectedAgentMainSessionKey(selected); const lineageRoot = this.sessionData.activeSessionLineageRoot; const lineageAgentId = normalizeAgentId( diff --git a/ui/src/e2e/board-fixture.e2e.test.ts b/ui/src/e2e/board-fixture.e2e.test.ts index d03f1d94f556..247893f8750d 100644 --- a/ui/src/e2e/board-fixture.e2e.test.ts +++ b/ui/src/e2e/board-fixture.e2e.test.ts @@ -246,7 +246,7 @@ describeStandaloneMockServer("standalone Control UI mock server", () => { await expect .poll(() => page - .locator(".chat-thread .chat-bubble") + .locator(".chat-pane-cache__pane--active .chat-thread .chat-bubble") .allTextContents() .then((messages) => messages.map((message) => message.trim())), ) diff --git a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts index 5c9d2f7922f1..98c3313a7282 100644 --- a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts @@ -1,6 +1,10 @@ import type { Locator, Page } from "playwright"; import { expect, it } from "vitest"; -import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { + controlUiSessionUrl, + installMockGateway, + navigateToControlUiSession, +} from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ @@ -94,7 +98,7 @@ suite.define(() => { ); }); - it("aborts a session's pending image before the pane adopts another session", async () => { + it("keeps a session's pending image isolated while another session is active", async () => { const firstSession = "agent:main:attachment-session-a"; const secondSession = "agent:main:attachment-session-b"; await suite.withPage( @@ -122,16 +126,17 @@ suite.define(() => { }); await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstSession)); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.fill("Private session A attachment"); - await pastePng(composer); + const activeComposer = () => + page.locator( + 'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox textarea', + ); + await activeComposer().fill("Private session A attachment"); + await pastePng(activeComposer()); await expect .poll(() => page.getByRole("button", { name: "Send message" }).isDisabled()) .toBe(true); - await page.locator("openclaw-chat-pane").evaluate((pane, sessionKey) => { - (pane as HTMLElement & { sessionKey: string }).sessionKey = sessionKey; - }, secondSession); + await navigateToControlUiSession(page, secondSession); await expect .poll(() => @@ -141,17 +146,36 @@ suite.define(() => { .attachmentReadProof.aborts, ), ) - .toBe(1); - await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0); + .toBe(0); + await expect + .poll(() => + page.locator('openclaw-chat-pane[aria-hidden="false"] .chat-attachment-thumb').count(), + ) + .toBe(0); - await composer.fill("Safe session B message"); - await composer.press("Enter"); + await activeComposer().fill("Safe session B message"); + await activeComposer().press("Enter"); const request = await gateway.waitForRequest("chat.send"); expect(request.params).toMatchObject({ message: "Safe session B message", sessionKey: secondSession, }); expect((request.params as { attachments?: unknown }).attachments).toBeUndefined(); + + await navigateToControlUiSession(page, firstSession); + await page.evaluate(() => { + const proof = (globalThis as unknown as { attachmentReadProof: DeferredAttachmentProof }) + .attachmentReadProof; + if (!proof.finish) { + throw new Error("Pasted image read was not retained"); + } + proof.finish(); + }); + await page + .locator( + 'openclaw-chat-pane[aria-hidden="false"] .chat-attachment-thumb img[alt="Attachment preview"]', + ) + .waitFor(); }, ); }); diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index 9fc97ee42e18..3c7aedfb02de 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -1,6 +1,10 @@ // Control UI E2E tests cover the redesigned chat composer. import { expect, it } from "vitest"; -import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { + controlUiSessionUrl, + installMockGateway, + navigateToControlUiSession, +} from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ @@ -605,41 +609,48 @@ suite.define(() => { }); }); - it("refreshes agent-scoped models when the pane switches sessions", async () => { + it("loads agent-scoped startup models when the route switches sessions", async () => { await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { + const workModel = { + id: "work-model", + name: "Work Model", + provider: "openai", + available: true, + }; + const otherModel = { + id: "other-model", + name: "Other Model", + provider: "anthropic", + available: true, + }; + const startupResponse = (sessionId: string, model: typeof workModel) => ({ + agentsList: { + agents: [ + { id: "work", name: "Work" }, + { id: "other", name: "Other" }, + ], + defaultId: "work", + mainKey: "main", + scope: "agent", + }, + messages: [], + metadata: { commands: [], models: [model] }, + sessionId, + thinkingLevel: null, + }); const gateway = await installMockGateway(page, { defaultAgentId: "work", sessionKey: "agent:work:main", methodResponses: { - "chat.metadata": { + "chat.startup": { cases: [ { - match: { agentId: "work" }, - response: { - commands: [], - models: [ - { - id: "work-model", - name: "Work Model", - provider: "openai", - available: true, - }, - ], - }, + match: { sessionKey: "agent:work:main" }, + response: startupResponse("work-session", workModel), }, { - match: { agentId: "other" }, - response: { - commands: [], - models: [ - { - id: "other-model", - name: "Other Model", - provider: "anthropic", - available: true, - }, - ], - }, + match: { sessionKey: "agent:other:main" }, + response: startupResponse("other-session", otherModel), }, ], }, @@ -672,177 +683,41 @@ suite.define(() => { ts: Date.now(), }, }, - models: [{ id: "work-model", name: "Work Model", provider: "openai", available: true }], + models: [workModel], }); await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:work:main")); await gateway.waitForRequest("chat.startup"); - // The initial work-agent catalog is complete in chat.startup, so only the - // later switch to the other agent should require chat.metadata. expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); - const composer = page.locator(".agent-chat__input"); + const activeComposer = () => + page.locator('openclaw-chat-pane[aria-hidden="false"] .agent-chat__input'); await expect - .poll(() => composer.locator('[data-chat-model-option="openai/work-model"]').count()) - .toBe(1); - - await page.locator("openclaw-chat-pane").evaluate((pane) => { - (pane as HTMLElement & { sessionKey: string }).sessionKey = "agent:other:main"; - }); - - await expect - .poll(async () => { - const requests = await gateway.getRequests("chat.metadata"); - return requests.filter( - (request) => (request.params as { agentId?: string } | undefined)?.agentId === "other", - ).length; - }) - .toBe(1); - await expect - .poll(() => composer.locator('[data-chat-model-option="anthropic/other-model"]').count()) - .toBe(1); - await expect - .poll(() => composer.locator('[data-chat-model-option="openai/work-model"]').count()) - .toBe(0); - const metadataRequests = await gateway.getRequests("chat.metadata"); - expect(metadataRequests).toHaveLength(1); - expect((metadataRequests[0]?.params as { agentId?: string } | undefined)?.agentId).toBe( - "other", - ); - }); - }); - - it("keeps startup models when an explicit metadata refresh fails", async () => { - await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { - const gateway = await installMockGateway(page, { - deferredMethods: ["chat.metadata"], - models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", available: true }], - }); - - await page.goto(`${suite.server.baseUrl}chat`); - await gateway.waitForRequest("chat.startup"); - const composer = page.locator(".agent-chat__input"); - await expect - .poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent()) - .toContain("GPT-5.5"); - expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); - - // Startup metadata now owns the same-agent cache. A config change invalidates - // that cache, so the next pane refresh still exercises the failure fallback. - await gateway.emitGatewayEvent("config.changed", {}); - await page.locator("openclaw-chat-pane").evaluate((pane) => { - (pane as HTMLElement & { sessionKey: string }).sessionKey = "agent:main:refreshed"; - }); - await gateway.waitForRequest("chat.metadata"); - await gateway.rejectDeferred("chat.metadata", { - code: "UNAVAILABLE", - message: "metadata unavailable", - }); - await expect - .poll(() => composer.locator('[data-chat-model-provider-group="openai"]').textContent()) - .toContain("GPT-5.5"); - expect(await gateway.getRequests("models.list")).toHaveLength(0); - }); - }); - - it("does not substitute default-agent models when scoped metadata fails", async () => { - await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { - const gateway = await installMockGateway(page, { - deferredMethods: ["chat.startup"], - methodResponses: { - "chat.metadata": { - cases: [ - { - match: { agentId: "work" }, - response: { - __mockError: { code: "UNAVAILABLE", message: "metadata unavailable" }, - }, - }, - ], - }, - }, - models: [{ id: "gpt-default", name: "GPT Default", provider: "openai", available: true }], - }); - - await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:main:main")); - await gateway.waitForRequest("chat.startup"); - await page.locator("openclaw-chat-pane").evaluate((pane) => { - (pane as HTMLElement & { sessionKey: string }).sessionKey = "agent:work:main"; - }); - await expect - .poll(async () => { - const requests = await gateway.getRequests("chat.metadata"); - return requests.some( - (request) => (request.params as { agentId?: string } | undefined)?.agentId === "work", - ); - }) - .toBe(true); - await page.waitForFunction(() => { - const pane = document.querySelector("openclaw-chat-pane") as - | (HTMLElement & { - state?: { - sessionKey?: string; - chatMetadataRequestVersion?: number; - chatModelCatalog?: unknown[]; - chatModelsLoading?: boolean; - }; - }) - | null; - return ( - pane?.state?.sessionKey === "agent:work:main" && - (pane.state.chatMetadataRequestVersion ?? 0) >= 2 && - pane.state.chatModelsLoading === false && - pane.state.chatModelCatalog?.length === 0 - ); - }); - await gateway.resolveDeferred("chat.startup", { - agentsList: { - agents: [ - { id: "main", name: "Main" }, - { id: "work", name: "Work" }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - messages: [], - metadata: { - commands: [], - models: [{ id: "gpt-default", name: "GPT Default", provider: "openai", available: true }], - }, - sessionId: "control-ui-e2e-session", - thinkingLevel: null, - }); - await page.waitForFunction(() => { - const pane = document.querySelector("openclaw-chat-pane") as - | (HTMLElement & { - state?: { agentsList?: { defaultId?: string; agents?: Array<{ id?: string }> } }; - }) - | null; - return ( - pane?.state?.agentsList?.defaultId === "main" && - pane.state.agentsList.agents?.some((agent) => agent.id === "main") === true - ); - }); - const composer = page.locator(".agent-chat__input"); - await expect - .poll(async () => - (await composer.locator("[data-chat-model-option]").allTextContents()).join(" "), + .poll(() => + activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(), ) - .not.toContain("GPT Default"); - const metadataRequests = await gateway.getRequests("chat.metadata"); + .toBe(1); + + await navigateToControlUiSession(page, "agent:other:main"); + const startupRequests = await gateway.getRequests("chat.startup"); expect( - metadataRequests.filter( - (request) => (request.params as { agentId?: string } | undefined)?.agentId === "work", + startupRequests.filter( + (request) => + (request.params as { sessionKey?: string } | undefined)?.sessionKey === + "agent:other:main", ), ).toHaveLength(1); - expect( - metadataRequests.every( - (request) => - typeof (request.params as { agentId?: string } | undefined)?.agentId === "string", - ), - ).toBe(true); - expect(await gateway.getRequests("models.list")).toHaveLength(0); + expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); + await expect + .poll(() => + activeComposer().locator('[data-chat-model-option="anthropic/other-model"]').count(), + ) + .toBe(1); + await expect + .poll(() => + activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(), + ) + .toBe(0); }); }); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts index f8942e23a31c..27be11352184 100644 --- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -136,10 +136,8 @@ suite.define(() => { await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 }); const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length; await sessionLink(sessionB).click(); - await expect - .poll(async () => (await gateway.getRequests("chat.history")).length) - .toBeGreaterThan(historyRequestsBeforeReturn); await expectTrace(); + expect(await gateway.getRequests("chat.history")).toHaveLength(historyRequestsBeforeReturn); if (artifactDir) { await page.screenshot({ fullPage: true, @@ -252,10 +250,11 @@ suite.define(() => { expect(requireRecord(historyRequest.params)).toMatchObject({ sessionKey: "agent:main:session-b", }); - await page.locator(".chat-thread").getByText("User history question 68").waitFor({ + const activeThread = page.locator(".chat-pane-cache__pane--active .chat-thread"); + await activeThread.getByText("User history question 68").waitFor({ timeout: 10_000, }); - await page.locator(".chat-thread").getByText("Assistant history answer 69").waitFor({ + await activeThread.getByText("Assistant history answer 69").waitFor({ timeout: 10_000, }); await expect @@ -273,11 +272,11 @@ suite.define(() => { await waitForChatScrollIdle(page); await scrollChatThreadToTop(page); - await page.locator(".chat-thread").getByText("User history question 10").waitFor({ + await activeThread.getByText("User history question 10").waitFor({ timeout: 10_000, }); await scrollChatThreadToTop(page); - await page.locator(".chat-thread").getByText("User history question 0").waitFor({ + await activeThread.getByText("User history question 0").waitFor({ timeout: 10_000, }); await scrollChatThreadToTop(page); @@ -407,18 +406,17 @@ suite.define(() => { ); await sessionB.click(); await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); - const thread = page.locator(".chat-thread"); + const activePane = page.locator('openclaw-chat-pane[aria-hidden="false"]'); + const thread = activePane.locator(".chat-thread"); await thread.hover(); await page.mouse.wheel(0, -1_000_000); await expect .poll(() => - page - .locator("openclaw-chat-pane") - .evaluate( - (element) => - (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages - .length, - ), + activePane.evaluate( + (element) => + (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages + .length, + ), ) .toBe(140); // Prepending preserves the visible anchor. A renewed upward gesture @@ -445,15 +443,15 @@ suite.define(() => { ).chatSessionReturnSamples = samples; const deadline = performance.now() + 750; const sample = () => { - const pane = document.querySelector("openclaw-chat-pane") as + const pane = document.querySelector('openclaw-chat-pane[aria-hidden="false"]') as | (HTMLElement & { state?: { chatMessages?: unknown[]; sessionKey?: string }; }) | null; - const rows = Array.from(document.querySelectorAll("[data-chat-row-key]")); + const rows = Array.from(pane?.querySelectorAll("[data-chat-row-key]") ?? []); samples.push({ - hiddenNotice: document.body.textContent?.includes("Showing last") ?? false, - loading: document.querySelector(".chat-history-loading") !== null, + hiddenNotice: pane?.textContent?.includes("Showing last") ?? false, + loading: pane?.querySelector(".chat-history-loading") !== null, messageCount: pane?.state?.chatMessages?.length ?? 0, minOpacity: rows.reduce( (minimum, row) => Math.min(minimum, Number.parseFloat(getComputedStyle(row).opacity)), @@ -501,7 +499,7 @@ suite.define(() => { expect(returnedSamples.every((sample) => !sample.hiddenNotice)).toBe(true); expect(returnedSamples.every((sample) => !sample.loading)).toBe(true); expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0); - await expectRequestCountStable(gateway, "chat.history", historyRequestsBeforeReturn + 1); + await expectRequestCountStable(gateway, "chat.history", historyRequestsBeforeReturn); if (artifactDir) { await page.screenshot({ path: `${artifactDir}/retained-history-return.png`, diff --git a/ui/src/e2e/chat-flow.media-files.e2e.test.ts b/ui/src/e2e/chat-flow.media-files.e2e.test.ts index 9caf3d894513..7408238559ea 100644 --- a/ui/src/e2e/chat-flow.media-files.e2e.test.ts +++ b/ui/src/e2e/chat-flow.media-files.e2e.test.ts @@ -655,13 +655,11 @@ suite.define(() => { expect(evictedImageIndex).toBeGreaterThanOrEqual(0); expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl); - const evictedPath = new URL( - expectDefined(imageUrls[evictedImageIndex], "evicted managed image URL").replace( - /\/full$/u, - "/thumbnail", - ), + const evictedUrl = new URL( + expectDefined(imageUrls[evictedImageIndex], "evicted managed image URL"), suite.server.baseUrl, - ).pathname; + ); + const evictedPath = evictedUrl.pathname.replace(/\/full$/u, "/thumbnail"); const fetchesBeforeRevisit = fetchedMedia.filter( (request) => request.pathname === evictedPath, ).length; diff --git a/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts index 2a23afe643ce..119e0d17d61c 100644 --- a/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts +++ b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts @@ -106,13 +106,16 @@ suite.define(() => { const main = page.getByRole("main"); const openModelSelect = async () => { - const trigger = main.locator('[data-chat-model-select="true"]').first(); + const trigger = main.locator( + 'openclaw-chat-pane[aria-hidden="false"] [data-chat-model-select="true"]', + ); await trigger.waitFor({ state: "visible", timeout: 10_000 }); return trigger; }; const selectModel = async (value: string) => { - await main.locator('[data-chat-model-select="true"]').click(); - const option = main.locator(`[data-chat-model-option="${value}"]`); + const activePane = main.locator('openclaw-chat-pane[aria-hidden="false"]'); + await activePane.locator('[data-chat-model-select="true"]').click(); + const option = activePane.locator(`[data-chat-model-option="${value}"]`); await option.waitFor({ state: "visible", timeout: 10_000 }); await option.click(); }; @@ -345,9 +348,10 @@ suite.define(() => { try { await page.goto(`${suite.server.baseUrl}chat`); const main = page.getByRole("main"); - const modelSelect = main.locator('[data-chat-model-select="true"]').first(); - const effortSelect = main.locator('[data-chat-thinking-select="true"]').first(); - const thinkingSlider = main.locator('[data-chat-thinking-slider="true"]'); + const activePane = main.locator('openclaw-chat-pane[aria-hidden="false"]'); + const modelSelect = activePane.locator('[data-chat-model-select="true"]'); + const effortSelect = activePane.locator('[data-chat-thinking-select="true"]'); + const thinkingSlider = activePane.locator('[data-chat-thinking-slider="true"]'); const expectedThinkingValues = thinkingLevels.map((level) => level.id).join(","); await modelSelect.waitFor({ state: "visible", timeout: 10_000 }); @@ -355,7 +359,7 @@ suite.define(() => { expect(await modelSelect.textContent()).not.toContain("@openai:"); await modelSelect.click(); await expect - .poll(() => main.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count()) + .poll(() => activePane.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count()) .toBe(1); expect( (await main.locator("[data-chat-model-option]").allTextContents()).join(" "), @@ -379,7 +383,7 @@ suite.define(() => { }); await modelSelect.click(); await expect - .poll(() => main.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count()) + .poll(() => activePane.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').count()) .toBe(1); await expect .poll(() => thinkingSlider.getAttribute("data-chat-thinking-values")) diff --git a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts index ac26acc7fc44..77fa87798196 100644 --- a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts @@ -82,7 +82,7 @@ suite.define(() => { } }); - it("restores a scrolled session after switching away while new messages arrive", async () => { + it("retains scrolled and end-anchored sessions without history reloads", async () => { const context = await suite.newBrowserContext({ locale: "en-US", serviceWorkers: "block", @@ -123,7 +123,7 @@ suite.define(() => { try { await page.goto(`${suite.server.baseUrl}chat`); await waitForChatScrollIdle(page); - const thread = page.locator(".chat-thread"); + const thread = page.locator(".chat-pane-cache__pane--active .chat-thread"); await expect.poll(() => thread.count()).toBe(1); const initialDistance = await thread.evaluate((element) => { const transcript = element as HTMLElement; @@ -151,17 +151,11 @@ suite.define(() => { }); expect(firstVisitDistance).toBeLessThanOrEqual(8); - const messagesAWithNewTail = messages("A", 78); - const updatedResponses = responseCases(messagesAWithNewTail); - await gateway.setMethodResponse("chat.history", updatedResponses); - await gateway.setMethodResponse("chat.startup", updatedResponses); const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length; await sessionLink(sessionA).click(); await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionA)); - await expect - .poll(async () => (await gateway.getRequests("chat.history")).length) - .toBeGreaterThan(historyRequestsBeforeReturn); await waitForChatScrollIdle(page); + expect(await gateway.getRequests("chat.history")).toHaveLength(historyRequestsBeforeReturn); const restored = await thread.evaluate((element) => { const transcript = element as HTMLElement; @@ -177,17 +171,13 @@ suite.define(() => { ).toBeLessThanOrEqual(120); expect(restored.distanceFromBottom).toBeGreaterThan(8); - const messagesBWithNewTail = messages("B", 36); - const endAnchoredResponses = responseCases(messagesAWithNewTail, messagesBWithNewTail); - await gateway.setMethodResponse("chat.history", endAnchoredResponses); - await gateway.setMethodResponse("chat.startup", endAnchoredResponses); const historyRequestsBeforeEndReturn = (await gateway.getRequests("chat.history")).length; await sessionLink(sessionB).click(); await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionB)); - await expect - .poll(async () => (await gateway.getRequests("chat.history")).length) - .toBeGreaterThan(historyRequestsBeforeEndReturn); await waitForChatScrollIdle(page); + expect(await gateway.getRequests("chat.history")).toHaveLength( + historyRequestsBeforeEndReturn, + ); const endAnchoredDistance = await thread.evaluate((element) => { const transcript = element as HTMLElement; return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight; @@ -727,7 +717,10 @@ suite.define(() => { try { await page.goto(`${suite.server.baseUrl}chat`); await page.locator(`.sidebar-recent-session[data-session-key="${secondKey}"]`).waitFor(); - await page.locator(".chat-pane__session-title").getByText("Instant A").waitFor(); + await page + .locator(".chat-pane-cache__pane--visible .chat-pane__session-title") + .getByText("Instant A") + .waitFor(); await page.waitForTimeout(500); const initialListCount = (await gateway.getRequests("sessions.list")).length; const initialMetadataCount = (await gateway.getRequests("chat.metadata")).length; @@ -738,7 +731,10 @@ suite.define(() => { `.sidebar-recent-session[data-session-key="${secondKey}"] a.sidebar-recent-session__link`, ) .click(); - await page.locator(".chat-pane__session-title").getByText("Instant B").waitFor(); + await page + .locator(".chat-pane-cache__pane--visible .chat-pane__session-title") + .getByText("Instant B") + .waitFor(); const emptyOutboxListRequests = (await gateway.getRequests("sessions.list")).slice( initialListCount, ); @@ -746,7 +742,7 @@ suite.define(() => { expect(await gateway.getRequests("chat.metadata")).toHaveLength(initialMetadataCount); const emptyOutboxListCount = initialListCount + emptyOutboxListRequests.length; - await page.locator("openclaw-chat-pane").evaluate((pane, targetKey) => { + await page.locator('openclaw-chat-pane[aria-hidden="false"]').evaluate((pane, targetKey) => { const state = ( pane as HTMLElement & { state: { @@ -785,7 +781,10 @@ suite.define(() => { `.sidebar-recent-session[data-session-key="${firstKey}"] a.sidebar-recent-session__link`, ) .click(); - await page.locator(".chat-pane__session-title").getByText("Instant A").waitFor(); + await page + .locator(".chat-pane-cache__pane--visible .chat-pane__session-title") + .getByText("Instant A") + .waitFor(); await expect .poll(async () => (await gateway.getRequests("sessions.list")).length) .toBe(emptyOutboxListCount + 1); diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index b759e7f70dd7..10c01616c064 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -247,7 +247,9 @@ suite.define(() => { ] = marker; return marker; }); - const avatar = page.locator("img.agent-chat__welcome-avatar"); + const avatar = page.locator( + 'openclaw-chat-pane[aria-hidden="false"] img.agent-chat__welcome-avatar', + ); await avatar.waitFor({ state: "visible" }); await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); diff --git a/ui/src/e2e/chat-flow.test-support.ts b/ui/src/e2e/chat-flow.test-support.ts index acf44370d8a4..e3f675bf03f0 100644 --- a/ui/src/e2e/chat-flow.test-support.ts +++ b/ui/src/e2e/chat-flow.test-support.ts @@ -120,7 +120,7 @@ export async function copiedViaExec(page: Page): Promise { } export async function chatThreadDistanceFromBottom(page: Page): Promise { - return page.locator(".chat-thread").evaluate((element) => { + return page.locator(".chat-pane-cache__pane--active .chat-thread").evaluate((element) => { const thread = element as HTMLElement; return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight); }); @@ -130,7 +130,7 @@ export async function waitForChatScrollIdle(page: Page): Promise { await expect .poll( () => - page.locator(".chat-thread").evaluate(async (element) => { + page.locator(".chat-pane-cache__pane--active .chat-thread").evaluate(async (element) => { const thread = element as HTMLElement; const readGeometry = () => ({ clientHeight: thread.clientHeight, @@ -159,7 +159,7 @@ export async function waitForChatScrollIdle(page: Page): Promise { } export async function scrollChatThreadToTop(page: Page): Promise { - await page.locator(".chat-thread").evaluate((element) => { + await page.locator(".chat-pane-cache__pane--active .chat-thread").evaluate((element) => { const thread = element as HTMLElement; thread.scrollTop = 0; thread.dispatchEvent(new Event("scroll", { bubbles: true })); @@ -184,7 +184,7 @@ export async function captureSessionAccessibilityProof(page: Page, name: string) } export async function visibleChatBubbleTexts(page: Page): Promise { - return page.locator(".chat-thread").evaluate((element) => { + return page.locator(".chat-pane-cache__pane--active .chat-thread").evaluate((element) => { const thread = element as HTMLElement; const viewport = thread.getBoundingClientRect(); return Array.from(thread.querySelectorAll(".chat-bubble")) diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index 9aca1a189fb3..a8c99bd58a5e 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -629,7 +629,8 @@ suite.define(() => { await page.getByText("Older remote review", { exact: true }).waitFor(); await page.getByText("Remote architecture review", { exact: true }).click(); await expect.poll(() => page.getByText("newer answer", { exact: true }).count()).toBe(1); - const thread = page.locator(".chat-thread"); + const catalogPane = page.locator('openclaw-chat-pane[aria-hidden="false"]'); + const thread = catalogPane.locator(".chat-thread"); await expect .poll(() => thread.evaluate((element) => element.scrollHeight > element.clientHeight + 100)) .toBe(true); @@ -645,37 +646,40 @@ suite.define(() => { element.dispatchEvent(new Event("scroll")); }); await page.clock.runFor(100); - await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor(); + await catalogPane + .locator('.chat-virtual-row:not([data-virtual-row-key="history"])') + .first() + .waitFor(); await expect .poll(() => gateway.getRequests("sessions.catalog.read").then((requests) => requests.length)) .toBe(initialReadCount + 1); - await page.locator(".chat-history-loading").waitFor(); - expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0); + await catalogPane.locator(".chat-history-loading").waitFor(); + expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0); const anchor = await firstVisibleVirtualRow(thread); await startVirtualRowPrependProbe(thread, anchor); await gateway.resolveDeferred("sessions.catalog.read"); await expect .poll(() => - page - .locator("openclaw-chat-pane") - .evaluate( - (element) => - (element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length, - ), + catalogPane.evaluate( + (element) => + (element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length, + ), ) .toBe(41); await page.clock.runFor(100); expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread)); - expect(await page.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe(true); + expect( + await catalogPane.locator(".agent-chat__composer-combobox > textarea").isDisabled(), + ).toBe(true); await expect .poll(() => page.getByText("This session is on a paired device and is view-only.").count()) .toBe(1); const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); const expectCenteredLayout = async (screenshotName: string) => { const [workbenchBox, threadBox, composerBox] = await Promise.all([ - page.locator(".chat-workbench").boundingBox(), - page.locator(".chat-thread-inner").boundingBox(), - page.locator(".agent-chat__composer-shell").boundingBox(), + catalogPane.locator(".chat-workbench").boundingBox(), + catalogPane.locator(".chat-thread-inner").boundingBox(), + catalogPane.locator(".agent-chat__composer-shell").boundingBox(), ]); expect(workbenchBox).not.toBeNull(); expect(threadBox).not.toBeNull(); @@ -709,8 +713,8 @@ suite.define(() => { await expect.poll(() => thread.evaluate((element) => element.scrollTop)).toBe(0); await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1); await page.clock.runFor(500); - expect(await page.locator(".chat-history-loading").count()).toBe(0); - expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0); + expect(await catalogPane.locator(".chat-history-loading").count()).toBe(0); + expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0); expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(exhaustedReadCount); await page.close(); }); diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index 46b26c24bb42..934b4d14fb2b 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -958,8 +958,11 @@ suite.define(() => { await page.goto(`${suite.server.baseUrl}chat`); await expandCodingSection(page); await page.getByText("Release checklist", { exact: true }).click(); - await expect.poll(() => page.getByText("prepare release", { exact: true }).count()).toBe(1); - const composer = page.locator(".agent-chat__composer-combobox > textarea"); + const catalogPane = page + .locator("openclaw-chat-pane.chat-pane-cache__pane--visible") + .filter({ hasText: "prepare release" }); + await catalogPane.getByText("prepare release", { exact: true }).waitFor(); + const composer = catalogPane.locator(".agent-chat__composer-combobox > textarea"); await composer.fill("continue with the final checks"); await gateway.setMethodResponse("sessions.list", { count: 1, diff --git a/ui/src/e2e/external-session-catalogs.e2e.test.ts b/ui/src/e2e/external-session-catalogs.e2e.test.ts index c3edbf45981f..6322c35c30d3 100644 --- a/ui/src/e2e/external-session-catalogs.e2e.test.ts +++ b/ui/src/e2e/external-session-catalogs.e2e.test.ts @@ -115,8 +115,13 @@ suite.define(() => { await page.getByText("OpenCode release review", { exact: true }).click(); await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1); await page.getByText("Pi architecture notes", { exact: true }).click(); - await expect.poll(() => page.getByText("Pi transcript loaded").count()).toBe(1); - expect(await page.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe(true); + const piPane = page + .locator("openclaw-chat-pane.chat-pane-cache__pane--visible") + .filter({ hasText: "Pi transcript loaded" }); + await piPane.getByText("Pi transcript loaded").waitFor(); + expect(await piPane.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe( + true, + ); expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(2); const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); diff --git a/ui/src/e2e/managed-media-base-path.e2e.test.ts b/ui/src/e2e/managed-media-base-path.e2e.test.ts index 89642ccf7bca..5764e51c49b4 100644 --- a/ui/src/e2e/managed-media-base-path.e2e.test.ts +++ b/ui/src/e2e/managed-media-base-path.e2e.test.ts @@ -34,9 +34,9 @@ describe("Control UI managed media under a UI base path", () => { viewport: { width: 1280, height: 800 }, }); const page = await context.newPage(); - const mediaPath = + const sourcePath = "/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-000000000001/full"; - const previewPath = mediaPath.replace(/\/full$/u, "/thumbnail"); + const previewPath = sourcePath.replace(/\/full$/u, "/thumbnail"); const imageBytes = await readFile( path.join(process.cwd(), "docs/assets/openclaw-banner-dark.png"), ); @@ -64,7 +64,7 @@ describe("Control UI managed media under a UI base path", () => { role: "assistant", content: [ { type: "text", text: "Managed attachment proof" }, - { type: "image", url: mediaPath, alt: "Managed proof image" }, + { type: "image", url: sourcePath, alt: "Managed proof image" }, ], timestamp: 1, }, diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index 9fdb9b6190fd..edf2d7f8fcaa 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -230,16 +230,6 @@ suite.define(() => { }), ) .toBe("connected"); - await page.evaluate((selectedSessionKey) => { - const pane = document.querySelector("openclaw-chat-pane") as unknown as HTMLElement & { - state: { chatMessages: unknown[]; chatMessagesBySession: Map }; - switchPaneSession: (sessionKey: string) => void; - }; - pane.state.chatMessages = []; - pane.state.chatMessagesBySession.clear(); - pane.switchPaneSession("agent:main:temporary-session"); - pane.switchPaneSession(selectedSessionKey); - }, sessionKey); if (captureUiProofEnabled) { await mkdir(reconnectProofArtifactDir, { recursive: true }); await page.screenshot({ diff --git a/ui/src/e2e/session-management.archive.e2e.test.ts b/ui/src/e2e/session-management.archive.e2e.test.ts index 3f038324382a..9c7485368a6b 100644 --- a/ui/src/e2e/session-management.archive.e2e.test.ts +++ b/ui/src/e2e/session-management.archive.e2e.test.ts @@ -334,7 +334,9 @@ suite.define(() => { await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 }); await rowFor(selected.key).locator("a").first().click(); await assertSelectedRoute(); - await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); + await page + .locator('openclaw-chat-pane[aria-hidden="false"] .agent-chat__input textarea') + .waitFor({ state: "visible" }); for (const row of batchRows) { await rowFor(row.key).click({ modifiers: ["Meta"] }); @@ -382,7 +384,9 @@ suite.define(() => { const archivedNotice = page.locator(".agent-chat__disabled-banner"); await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived."); - await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0); + await expect + .poll(() => page.locator(".chat-pane-cache__pane--visible .agent-chat__input").count()) + .toBe(0); await archivedNotice.getByRole("button", { name: "Unarchive" }).click(); await waitForPatch( @@ -398,7 +402,9 @@ suite.define(() => { await assertSelectedRoute(); await archivedNotice.waitFor({ state: "detached", timeout: 10_000 }); - await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); + await page + .locator(".chat-pane-cache__pane--visible .agent-chat__input textarea") + .waitFor({ state: "visible" }); } finally { await context.close(); } diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 37f9a78fb2e3..4ca930e731ec 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -49,7 +49,9 @@ suite.define(() => { await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstKey)); const firstRow = page.locator(`[data-session-key="${firstKey}"]`); const secondRow = page.locator(`[data-session-key="${secondKey}"]`); - const composer = page.locator(".agent-chat__composer-combobox > textarea"); + const composer = page.locator( + 'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox > textarea', + ); await firstRow.waitFor({ state: "visible", timeout: 10_000 }); await secondRow.waitFor({ state: "visible" }); await composer.waitFor({ state: "visible" }); diff --git a/ui/src/lib/sessions/navigation-handoff.ts b/ui/src/lib/sessions/navigation-handoff.ts index e69a921df313..b0c5c990e797 100644 --- a/ui/src/lib/sessions/navigation-handoff.ts +++ b/ui/src/lib/sessions/navigation-handoff.ts @@ -1,3 +1,5 @@ +import type { BoardFace } from "../board/settings.ts"; + type SessionNavigationHandoff = { pathname: string; sessionKey: string; @@ -13,10 +15,59 @@ type SessionNavigationHandoffOwner = { }; const SESSION_NAVIGATION_HANDOFF_TTL_MS = 2_000; +export const SESSION_NAVIGATION_INTENT_EVENT = "openclaw:session-navigation-intent"; +export type SessionNavigationIntent = { + commit: () => boolean; + face: BoardFace; + sessionKey: string; +}; +type SessionNavigationIntentOwner = { + readonly isConnected: boolean; + readonly activeRouteId?: unknown; + readonly sessionKey?: unknown; +}; const sessionNavigationHandoffs = new WeakMap< SessionNavigationHandoffOwner, SessionNavigationHandoff >(); +const sessionNavigationIntents = new WeakMap(); + +function announceSessionNavigationIntent(intent: SessionNavigationIntent): boolean { + const event = new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: intent, + }); + globalThis.dispatchEvent(event); + return event.defaultPrevented; +} + +export function runSessionNavigationIntent( + owner: SessionNavigationIntentOwner, + intent: SessionNavigationIntent, +): void { + const token = {}; + const activeRouteId = owner.activeRouteId; + const sourceSessionKey = owner.sessionKey; + sessionNavigationIntents.set(owner, token); + const guarded = { + ...intent, + commit: () => { + if ( + !owner.isConnected || + owner.activeRouteId !== activeRouteId || + owner.sessionKey !== sourceSessionKey || + sessionNavigationIntents.get(owner) !== token + ) { + return false; + } + sessionNavigationIntents.delete(owner); + return intent.commit(); + }, + }; + if (!announceSessionNavigationIntent(guarded)) { + guarded.commit(); + } +} export function prepareSessionNavigationHandoff( owner: SessionNavigationHandoffOwner, diff --git a/ui/src/pages/chat/attachment-payload-store.ts b/ui/src/pages/chat/attachment-payload-store.ts index 7763ed911941..5f93d8af0e78 100644 --- a/ui/src/pages/chat/attachment-payload-store.ts +++ b/ui/src/pages/chat/attachment-payload-store.ts @@ -62,6 +62,17 @@ export function cloneChatAttachmentsMetadata( return attachments.map(cloneChatAttachmentMetadata); } +/** Gives another mounted composer payload ownership independent of the source. */ +export function cloneChatAttachmentsForIndependentOwner( + attachments: readonly ChatAttachment[], +): ChatAttachment[] { + return attachments.map((attachment) => { + const { id: _id, previewUrl: _previewUrl, ...metadata } = attachment; + const dataUrl = getChatAttachmentDataUrl(attachment); + return { ...metadata, id: generateAttachmentId(), ...(dataUrl ? { dataUrl } : {}) }; + }); +} + export function releaseChatAttachmentPayload(id: string): void { const payload = payloads.get(id); if (!payload) { diff --git a/ui/src/pages/chat/chat-composer-memory-fallback.ts b/ui/src/pages/chat/chat-composer-memory-fallback.ts index ae3fd4297c79..04676c3b7e02 100644 --- a/ui/src/pages/chat/chat-composer-memory-fallback.ts +++ b/ui/src/pages/chat/chat-composer-memory-fallback.ts @@ -22,7 +22,7 @@ export type ChatComposerMemoryFallbackOwnership = { sequence: number; }; -export function resolveChatComposerMemoryFallback( +function resolveChatComposerMemoryFallback( state: ChatPageHost, sessionKey: string, scopeOverride?: StoredChatOutboxScope, diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 50308466eb91..9207c0761ea6 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -374,6 +374,27 @@ describe("handleChatGatewayEvent", () => { ).toEqual([payload.message]); }); + it("caches one background final when three retained panes receive the same event", () => { + const cache = new Map(); + const states = ["one", "two", "three"].map((sessionKey) => + createState({ chatMessagesBySession: cache, sessionKey }), + ); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "background", + state: "final", + message: createTextChatMessage("assistant", "background final"), + }; + + for (const state of states) { + expect(handleChatGatewayEvent(state, payload)).toBeNull(); + } + + expect(readChatMessagesFromCache(cache, states[0]!, { sessionKey: "background" })).toEqual([ + payload.message, + ]); + }); + it.each([ { name: "canonical default-session finals under the main alias", diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index b9a86bce2471..2804ca394381 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -193,12 +193,19 @@ function appendCachedChatMessage( state: ChatState, sessionKey: string, message: unknown, + eventClaim: object, agentId?: string, ) { if (!state.chatMessagesBySession) { return; } - appendChatMessageToCache(state.chatMessagesBySession, state, { sessionKey, agentId }, message); + appendChatMessageToCache( + state.chatMessagesBySession, + state, + { sessionKey, agentId }, + message, + eventClaim, + ); } function handleChatEvent( @@ -229,7 +236,7 @@ function handleChatEvent( const cacheAgentId = isUiGlobalSessionKey(payload.sessionKey) ? (payload.agentId ?? resolveUiDefaultAgentId(state)) : payload.agentId; - appendCachedChatMessage(state, payload.sessionKey, finalMessage, cacheAgentId); + appendCachedChatMessage(state, payload.sessionKey, finalMessage, payload, cacheAgentId); } } return null; diff --git a/ui/src/pages/chat/chat-page-retained-sessions.test.ts b/ui/src/pages/chat/chat-page-retained-sessions.test.ts new file mode 100644 index 000000000000..aaeda1905756 --- /dev/null +++ b/ui/src/pages/chat/chat-page-retained-sessions.test.ts @@ -0,0 +1,428 @@ +/* @vitest-environment jsdom */ +/* @vitest-environment-options {"url":"http://chat-page-retained.test/"} */ + +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./chat-pane.ts", () => ({})); +vi.mock("../../app/native-gateways.runtime.ts", () => ({ + nativeGatewaysCapability: () => null, +})); + +import type { ApplicationContext } from "../../app/context.ts"; +import { SESSION_NAVIGATION_INTENT_EVENT } from "../../lib/sessions/navigation-handoff.ts"; +import { createStorageMock } from "../../test-helpers/storage.ts"; +import { ChatPage } from "./chat-page.ts"; + +type RenderedPane = HTMLElement & { + active: boolean; + focusComposer: boolean; + onFaceChange?: (paneId: string, sessionKey: string, face: "chat" | "dashboard") => void; + onPaneSessionChange?: ( + paneId: string, + nextSessionKey: string, + options?: { replace?: boolean }, + ) => boolean | void; + onSessionDeleted?: (paneId: string, sessionKey: string, replacementSessionKey: string) => void; + paneId: string; + presentationId: string; + presented: boolean; + sessionKey: string; +}; + +function setNavigationContext(page: ChatPage) { + const navigate = vi.fn(); + const replace = vi.fn(); + const patch = vi.fn(async () => null); + const agentSelectionState = { selectedId: "main" }; + const chatAttachmentHandoff = { + prepare: vi.fn(), + consume: vi.fn(() => null), + clearPane: vi.fn(), + dispose: vi.fn(), + }; + const context = { + basePath: "", + sessions: { state: { result: null }, subscribe: () => () => undefined, patch }, + agents: { state: { agentsList: { defaultId: "main", mainKey: "main" } } }, + gateway: { snapshot: { hello: null } }, + navigate, + replace, + agentSelection: { + state: agentSelectionState, + set: vi.fn((agentId: string) => { + agentSelectionState.selectedId = agentId; + }), + }, + chatAttachmentHandoff, + } as unknown as ApplicationContext; + (page as unknown as { context: ApplicationContext }).context = context; + return { chatAttachmentHandoff, navigate, patch, replace }; +} + +function stubMatchMedia() { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +describe("chat page retained sessions", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); + localStorage.clear(); + stubMatchMedia(); + }); + + afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("retains three session panes and reactivates them without remounting", async () => { + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + + const navigate = async (sessionKey: string) => { + page.data = { sessionKey }; + await page.updateComplete; + await page.updateComplete; + }; + const panes = () => [...page.querySelectorAll("openclaw-chat-pane")]; + const paneFor = (sessionKey: string) => + panes().find((candidate) => candidate.sessionKey === sessionKey); + const paneA = paneFor("agent:main:a"); + expect(paneA).toBeDefined(); + + await navigate("agent:main:b"); + const paneB = paneFor("agent:main:b"); + expect(paneB).toBeDefined(); + expect(paneB?.presentationId).not.toBe(paneA?.presentationId); + expect(paneA?.active).toBe(false); + expect(paneA?.presented).toBe(false); + expect(paneA?.hasAttribute("inert")).toBe(true); + expect(paneA?.getAttribute("aria-hidden")).toBe("true"); + expect(paneB?.active).toBe(true); + expect(paneB?.presented).toBe(true); + expect(paneB?.hasAttribute("inert")).toBe(false); + + await navigate("agent:main:a"); + expect(paneFor("agent:main:a")).toBe(paneA); + expect(paneFor("agent:main:b")).toBe(paneB); + + await navigate("agent:main:c"); + await navigate("agent:main:d"); + expect( + panes() + .map((pane) => pane.sessionKey) + .toSorted(), + ).toEqual(["agent:main:a", "agent:main:c", "agent:main:d"]); + expect(paneB?.isConnected).toBe(false); + }); + + it("hands route-owned focus to the final page across pane replacement", async () => { + const sourcePage = new ChatPage(); + setNavigationContext(sourcePage); + sourcePage.data = { + sessionKey: "main", + draft: "What can you do?", + focusComposer: true, + }; + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "main" }; + + vi.useFakeTimers(); + try { + document.body.append(sourcePage); + await sourcePage.updateComplete; + await Promise.resolve(); + + document.body.append(page); + await page.updateComplete; + const pane = expectDefined( + page.querySelector("openclaw-chat-pane"), + "retained chat pane", + ); + expect(pane.focusComposer).toBe(true); + + const combobox = document.createElement("div"); + combobox.className = "agent-chat__composer-combobox"; + const textarea = document.createElement("textarea"); + combobox.append(textarea); + pane.append(combobox); + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(textarea); + + const replacementPane = document.createElement("openclaw-chat-pane") as RenderedPane; + replacementPane.active = true; + replacementPane.sessionKey = "main"; + const replacementCombobox = document.createElement("div"); + replacementCombobox.className = "agent-chat__composer-combobox"; + const replacementTextarea = document.createElement("textarea"); + replacementCombobox.append(replacementTextarea); + replacementPane.append(replacementCombobox); + pane.replaceWith(replacementPane); + + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(replacementTextarea); + + const userTarget = document.createElement("button"); + document.body.append(userTarget); + userTarget.focus(); + vi.advanceTimersByTime(250); + expect(document.activeElement).toBe(userTarget); + } finally { + sourcePage.remove(); + page.remove(); + vi.useRealTimers(); + } + }); + + it("rejects navigation and face changes from a hidden retained session", async () => { + const page = new ChatPage(); + const navigation = setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + const paneA = page.querySelector("openclaw-chat-pane"); + + page.data = { sessionKey: "agent:main:b" }; + await page.updateComplete; + await page.updateComplete; + navigation.navigate.mockClear(); + navigation.patch.mockClear(); + + expect(paneA?.onPaneSessionChange?.("p1", "agent:main:stale-result")).toBe(false); + paneA?.onFaceChange?.("p1", "agent:main:a", "dashboard"); + + expect(navigation.navigate).not.toHaveBeenCalled(); + expect(navigation.patch).not.toHaveBeenCalled(); + expect(page.data.sessionKey).toBe("agent:main:b"); + + page.remove(); + expect(navigation.chatAttachmentHandoff.clearPane).not.toHaveBeenCalled(); + }); + + it("rejects a pane callback while a newer browser route is loading", async () => { + const page = new ChatPage(); + const navigation = setNavigationContext(page); + page.data = { sessionKey: "main" }; + document.body.append(page); + await page.updateComplete; + const pane = page.querySelector("openclaw-chat-pane"); + const previousHref = window.location.href; + + try { + history.pushState(null, "", "/chat/main?catalog=pi&host=node&thread=next"); + + expect(pane?.onPaneSessionChange?.("p1", "agent:main:main", { replace: true })).toBe(false); + expect(navigation.replace).not.toHaveBeenCalled(); + } finally { + history.replaceState(null, "", previousHref); + } + }); + + it("presents a retained sidebar destination before route data resolves", async () => { + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + + page.data = { sessionKey: "agent:main:b" }; + await page.updateComplete; + await page.updateComplete; + const panes = () => [...page.querySelectorAll("openclaw-chat-pane")]; + const paneA = panes().find((pane) => pane.sessionKey === "agent:main:a"); + const paneB = panes().find((pane) => pane.sessionKey === "agent:main:b"); + page.data = { sessionKey: "agent:main:a" }; + await page.updateComplete; + await page.updateComplete; + + const intent = new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: () => true, face: "chat", sessionKey: "agent:main:b" }, + }); + window.dispatchEvent(intent); + + expect(intent.defaultPrevented).toBe(true); + expect(page.data.sessionKey).toBe("agent:main:a"); + expect(paneA?.classList.contains("chat-pane-cache__pane--visible")).toBe(false); + expect(paneA?.presented).toBe(true); + expect(paneA?.hasAttribute("inert")).toBe(true); + expect(paneA?.getAttribute("aria-hidden")).toBe("false"); + expect(paneB?.classList.contains("chat-pane-cache__pane--visible")).toBe(true); + expect(paneB?.presented).toBe(false); + expect(paneB?.hasAttribute("inert")).toBe(true); + expect(paneB?.getAttribute("aria-hidden")).toBe("true"); + expect(paneA?.active).toBe(true); + expect(paneB?.active).toBe(false); + + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: () => true, face: "chat", sessionKey: "agent:main:a" }, + }), + ); + expect(paneA?.classList.contains("chat-pane-cache__pane--visible")).toBe(true); + expect(paneA?.presented).toBe(true); + expect(paneA?.hasAttribute("inert")).toBe(false); + expect(paneB?.classList.contains("chat-pane-cache__pane--visible")).toBe(false); + expect(paneB?.presented).toBe(false); + expect(paneB?.hasAttribute("inert")).toBe(true); + + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: () => true, face: "chat", sessionKey: "agent:main:b" }, + }), + ); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(paneA?.presented).toBe(true); + expect(paneB?.presented).toBe(false); + + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: () => true, face: "chat", sessionKey: "agent:main:b" }, + }), + ); + page.data = { sessionKey: "agent:main:b" }; + await page.updateComplete; + await page.updateComplete; + expect(panes().find((pane) => pane.sessionKey === "agent:main:b")).toBe(paneB); + expect(paneA?.active).toBe(false); + expect(paneA?.presented).toBe(false); + expect(paneA?.hasAttribute("inert")).toBe(true); + expect(paneB?.active).toBe(true); + expect(paneB?.presented).toBe(true); + expect(paneB?.hasAttribute("inert")).toBe(false); + }); + + it("evicts a deleted inactive retained session without redirecting the active pane", async () => { + const page = new ChatPage(); + const navigation = setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + page.data = { sessionKey: "agent:main:b" }; + await page.updateComplete; + await page.updateComplete; + const paneA = [...page.querySelectorAll("openclaw-chat-pane")].find( + (pane) => pane.sessionKey === "agent:main:a", + ); + navigation.navigate.mockClear(); + + paneA?.onSessionDeleted?.("p1", "agent:main:a", "agent:main:main"); + await page.updateComplete; + + expect( + [...page.querySelectorAll("openclaw-chat-pane")].some( + (pane) => pane.sessionKey === "agent:main:a", + ), + ).toBe(false); + expect(navigation.navigate).not.toHaveBeenCalled(); + expect(page.data.sessionKey).toBe("agent:main:b"); + }); + + it("rolls a retained preview back when authoritative navigation never commits", async () => { + vi.useFakeTimers(); + try { + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + page.data = { sessionKey: "agent:main:b" }; + await page.updateComplete; + await page.updateComplete; + page.data = { sessionKey: "agent:main:a" }; + await page.updateComplete; + await page.updateComplete; + const panes = [...page.querySelectorAll("openclaw-chat-pane")]; + const paneA = panes.find((pane) => pane.sessionKey === "agent:main:a"); + const paneB = panes.find((pane) => pane.sessionKey === "agent:main:b"); + + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: () => true, face: "chat", sessionKey: "agent:main:b" }, + }), + ); + expect(paneA?.presented).toBe(true); + expect(paneA?.hasAttribute("inert")).toBe(true); + expect(paneB?.presented).toBe(false); + expect(paneB?.hasAttribute("inert")).toBe(true); + vi.advanceTimersByTime(5_000); + + expect(paneA?.presented).toBe(true); + expect(paneA?.hasAttribute("inert")).toBe(false); + expect(paneB?.presented).toBe(false); + expect(paneB?.hasAttribute("inert")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("cannot commit a retained navigation after supersession or page disposal", async () => { + const frames = new Map(); + let nextFrame = 0; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frame) => { + frames.delete(frame); + }); + const page = new ChatPage(); + setNavigationContext(page); + page.data = { sessionKey: "agent:main:a" }; + document.body.append(page); + await page.updateComplete; + for (const sessionKey of ["agent:main:b", "agent:main:c", "agent:main:a"]) { + page.data = { sessionKey }; + await page.updateComplete; + await page.updateComplete; + } + const commitB = vi.fn(() => true); + const commitC = vi.fn(() => true); + + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: commitB, face: "chat", sessionKey: "agent:main:b" }, + }), + ); + frames.get(1)?.(0); + const staleSecondFrame = frames.get(2); + window.dispatchEvent( + new CustomEvent(SESSION_NAVIGATION_INTENT_EVENT, { + cancelable: true, + detail: { commit: commitC, face: "chat", sessionKey: "agent:main:c" }, + }), + ); + staleSecondFrame?.(16); + frames.get(3)?.(16); + const disposedSecondFrame = frames.get(4); + + expect(commitB).not.toHaveBeenCalled(); + page.remove(); + disposedSecondFrame?.(32); + expect(commitC).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/chat/chat-page-retained-sessions.ts b/ui/src/pages/chat/chat-page-retained-sessions.ts new file mode 100644 index 000000000000..4411e5804c02 --- /dev/null +++ b/ui/src/pages/chat/chat-page-retained-sessions.ts @@ -0,0 +1,345 @@ +import { html } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import type { ApplicationContext } from "../../app/context.ts"; +import { nativeGatewaysCapability } from "../../app/native-gateways.runtime.ts"; +import type { BoardFace } from "../../lib/board/settings.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; +import { resolveSessionKey } from "../../lib/sessions/index.ts"; +import { + SESSION_NAVIGATION_INTENT_EVENT, + type SessionNavigationIntent, +} from "../../lib/sessions/navigation-handoff.ts"; +import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; +import { persistSessionBoardFace } from "./chat-board-face-persistence.ts"; +import { clearPaneSessionHandoff, clearPaneSessionHandoffs } from "./chat-pane-shared.ts"; +import { RouteDraftComposerFocus, type ChatPaneElement } from "./route-draft-focus-handoff.ts"; +import { routeDraft } from "./route-draft.ts"; +import type { SessionChatRouteData } from "./route-loader.ts"; +import type { ChatMessageCache } from "./session-message-cache.ts"; +import { + findPane, + setActivePane, + type ChatSplitLayout, + type ChatSplitPane, +} from "./split-layout.ts"; + +const RETAINED_SESSIONS_PER_PANE = 3; +const SESSION_NAVIGATION_PREVIEW_TIMEOUT_MS = 5_000; + +type RetentionHost = HTMLElement & { requestUpdate(): unknown }; +type RetentionBindings = { + context: () => ApplicationContext | undefined; + face: () => BoardFace; + layout: () => ChatSplitLayout; + splitLayout: () => ChatSplitLayout | undefined; + persistLayout: (layout: ChatSplitLayout) => void; + selectReplacement: (paneId: string, sourceSessionKey: string, sessionKey: string) => void; + updateRoute: (sessionKey: string, replace: boolean, face: BoardFace) => void; +}; + +export class ChatPageRetainedSessions { + private readonly sessionsByPane = new Map(); + private preview: (SessionNavigationIntent & { href: string; paneId: string }) | null = null; + private previewFrame: number | undefined; + private previewTimer: number | undefined; + + constructor( + private readonly host: RetentionHost, + private readonly bindings: RetentionBindings, + ) {} + + connect(): void { + window.addEventListener("popstate", this.cancelPreview); + window.addEventListener(SESSION_NAVIGATION_INTENT_EVENT, this.handleNavigationIntent); + } + + disconnect(): void { + // Pane disconnects stage their scoped composer packages for a later chat + // remount. Only an explicit pane/session close is terminal. + this.sessionsByPane.clear(); + window.removeEventListener("popstate", this.cancelPreview); + window.removeEventListener(SESSION_NAVIGATION_INTENT_EVENT, this.handleNavigationIntent); + this.cancelPreview(); + } + + settleRoute(sessionKey: string): void { + if (!this.preview) { + return; + } + if (areUiSessionKeysEquivalent(this.preview.sessionKey, sessionKey)) { + this.preview = null; + this.clearPreviewWork(); + } else { + this.cancelPreview(); + } + } + + retain(pane: ChatSplitPane): string[] { + let retained = this.sessionsByPane.get(pane.id); + if (!retained) { + retained = []; + this.sessionsByPane.set(pane.id, retained); + } + const equivalentIndex = retained.findIndex( + (key) => key === pane.sessionKey || areUiSessionKeysEquivalent(key, pane.sessionKey), + ); + const retainedKey = + equivalentIndex < 0 ? pane.sessionKey : retained.splice(equivalentIndex, 1)[0]!; + retained.push(retainedKey); + if (retained.length > RETAINED_SESSIONS_PER_PANE) { + this.findPane(pane.id, retained.shift()!)?.prepareForEviction?.(); + } + return retained.toSorted((left, right) => left.localeCompare(right)); + } + + prune(validPaneIds: ReadonlySet): void { + for (const paneId of this.sessionsByPane.keys()) { + if (!validPaneIds.has(paneId)) { + this.sessionsByPane.delete(paneId); + } + } + } + + discardPane(paneId: string): void { + const context = this.bindings.context(); + if (context) { + clearPaneSessionHandoffs(context, paneId); + context.chatAttachmentHandoff.clearPane(paneId); + } + this.sessionsByPane.delete(paneId); + } + + readonly removeSession = ( + paneId: string, + sessionKey: string, + replacementSessionKey: string, + ): void => { + const deletedPane = this.findPane(paneId, sessionKey); + deletedPane?.discardStagedAttachments?.(); + const retained = this.sessionsByPane.get(paneId); + const retainedIndex = retained?.findIndex((key) => areUiSessionKeysEquivalent(key, sessionKey)); + if (retained && retainedIndex !== undefined && retainedIndex >= 0) { + retained.splice(retainedIndex, 1); + } + const context = this.bindings.context(); + if (context) { + clearPaneSessionHandoff(context, paneId, sessionKey); + } + if ( + this.preview?.paneId === paneId && + areUiSessionKeysEquivalent(this.preview.sessionKey, sessionKey) + ) { + this.cancelPreview(); + } + const selectedSessionKey = findPane(this.bindings.layout(), paneId)?.pane.sessionKey; + if (selectedSessionKey && areUiSessionKeysEquivalent(selectedSessionKey, sessionKey)) { + this.bindings.selectReplacement(paneId, sessionKey, replacementSessionKey); + } else { + this.host.requestUpdate(); + } + }; + + readonly changeFace = (paneId: string, sessionKey: string, face: BoardFace): void => { + const selectedSessionKey = findPane(this.bindings.layout(), paneId)?.pane.sessionKey; + if (!selectedSessionKey || !areUiSessionKeysEquivalent(selectedSessionKey, sessionKey)) { + return; + } + const layout = this.bindings.splitLayout(); + if (layout && layout.activePaneId !== paneId) { + this.bindings.persistLayout(setActivePane(layout, paneId)); + } + const context = this.bindings.context(); + if (context) { + persistSessionBoardFace(context, sessionKey, face); + this.bindings.updateRoute(sessionKey, false, face); + } + }; + + private findPane(paneId: string, sessionKey: string): ChatPaneElement | undefined { + return [...this.host.querySelectorAll("openclaw-chat-pane")].find( + (pane) => + pane.paneId === paneId && areUiSessionKeysEquivalent(pane.sessionKey ?? "", sessionKey), + ); + } + + private readonly handleNavigationIntent = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + this.cancelPreview(); + const intent = event.detail as SessionNavigationIntent; + if (intent.face !== this.bindings.face()) { + return; + } + const layout = this.bindings.layout(); + const activePane = findPane(layout, layout.activePaneId)?.pane; + const retainedKey = this.sessionsByPane + .get(activePane?.id ?? "") + ?.find((key) => areUiSessionKeysEquivalent(key, intent.sessionKey)); + if ( + !activePane || + !retainedKey || + areUiSessionKeysEquivalent(activePane.sessionKey, retainedKey) + ) { + return; + } + this.present(activePane.id, retainedKey, true); + // The route remains authoritative for semantic/global ownership. Both + // presentations stay inert until it settles; only visual ownership moves. + const preview = { + ...intent, + href: window.location.href, + paneId: activePane.id, + sessionKey: retainedKey, + }; + this.preview = preview; + this.previewFrame = requestAnimationFrame(() => { + if (this.preview !== preview) { + return; + } + this.previewFrame = requestAnimationFrame(() => { + this.previewFrame = undefined; + if ( + this.preview === preview && + (window.location.href !== preview.href || !preview.commit()) + ) { + this.cancelPreview(); + } + }); + }); + this.previewTimer = window.setTimeout( + this.cancelPreview, + SESSION_NAVIGATION_PREVIEW_TIMEOUT_MS, + ); + event.preventDefault(); + }; + + private present(paneId: string, sessionKey: string, preview = false): void { + for (const pane of this.host.querySelectorAll("openclaw-chat-pane")) { + if (pane.paneId !== paneId) { + continue; + } + const presented = areUiSessionKeysEquivalent(pane.sessionKey ?? "", sessionKey); + pane.classList.toggle("chat-pane-cache__pane--visible", presented); + if (preview) { + pane.toggleAttribute("inert", true); + continue; + } + pane.toggleAttribute("inert", !presented); + pane.setAttribute("aria-hidden", presented ? "false" : "true"); + pane.presented = presented; + } + } + + private clearPreviewWork(): void { + if (this.previewFrame !== undefined) { + cancelAnimationFrame(this.previewFrame); + this.previewFrame = undefined; + } + if (this.previewTimer !== undefined) { + window.clearTimeout(this.previewTimer); + this.previewTimer = undefined; + } + } + + private readonly cancelPreview = () => { + this.clearPreviewWork(); + this.preview = null; + const layout = this.bindings.layout(); + const activePane = findPane(layout, layout.activePaneId)?.pane; + if (activePane) { + this.present(activePane.id, activePane.sessionKey); + } + }; +} + +export function renderRetainedChatPanes(params: { + active: boolean; + chatMessagesBySession: ChatMessageCache; + consumedDraftData: SessionChatRouteData | null; + data: SessionChatRouteData; + draftFocus: RouteDraftComposerFocus; + mergedChrome: boolean; + narrow: boolean; + navDrawerOpen: boolean; + onboarding: boolean; + onClosePane?: (paneId: string) => void; + onFaceChange: (paneId: string, sessionKey: string, face: BoardFace) => void; + onFocusPane: (paneId: string) => void; + onOpenSplitView?: () => void; + onPaneSessionChange: ( + paneId: string, + sourceSessionKey: string, + sessionKey: string, + options?: { replace?: boolean }, + ) => boolean; + onSessionDeleted: (paneId: string, sessionKey: string, replacementSessionKey: string) => void; + onSplitDown?: (paneId: string) => void; + onSplitRight?: (paneId: string) => void; + ownerKey: string; + pane: ChatSplitPane; + sessionKeys: readonly string[]; + showGatewayPicker: boolean; + splitMode: boolean; + context?: ApplicationContext; +}) { + const nativeGateways = nativeGatewaysCapability(); + const sessions = params.context?.sessions?.state.result?.sessions ?? []; + return repeat( + params.sessionKeys, + (sessionKey) => sessionKey, + (sessionKey) => { + const visible = + sessionKey === params.pane.sessionKey || + areUiSessionKeysEquivalent(sessionKey, params.pane.sessionKey); + const presented = visible && (!params.narrow || params.active); + const active = params.active && visible; + const draft = active + ? routeDraft(params.data, params.consumedDraftData, sessionKey) + : undefined; + const focus = params.draftFocus.shouldFocusPane(active, draft, sessionKey, params.data); + const resolvedKey = + resolveSessionKey(sessionKey, params.context?.gateway?.snapshot?.hello) || sessionKey; + const title = resolveSessionDisplayName( + resolvedKey, + sessions.find((row) => areUiSessionKeysEquivalent(row.key, resolvedKey)), + ); + return html` params.onPaneSessionChange(paneId, sessionKey, nextSessionKey, options)} + .onSessionDeleted=${params.onSessionDeleted} + .onFaceChange=${params.onFaceChange} + >`; + }, + ); +} diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index 37b34bc984ae..0a8dbdf10171 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -7,8 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const nativeGateways = vi.hoisted(() => ({ current: null as NativeGatewaysCapability | null })); -// The dedicated unit-mock-registry project keeps this complete, side-effect-only -// module mock from sharing a worker's mock registry with component tests. +// Keep this complete mock in the dedicated unit-mock-registry project. vi.mock("./chat-pane.ts", () => ({})); vi.mock("../../app/native-gateways.runtime.ts", () => ({ nativeGatewaysCapability: () => nativeGateways.current, @@ -54,6 +53,7 @@ type RenderedPane = HTMLElement & { focusComposer: boolean; chatMessagesBySession: ChatMessageCache; sessionKey: string; + presented: boolean; active: boolean; paneTitle: string; narrow: boolean; @@ -62,7 +62,7 @@ type RenderedPane = HTMLElement & { gatewaysSnapshot: NativeGatewaysSnapshot | null; onOpenSplitView?: () => void; onClosePane?: (paneId: string) => void; - onFaceChange?: (face: "chat" | "dashboard") => void; + onFaceChange?: (paneId: string, sessionKey: string, face: "chat" | "dashboard") => void; }; type RenderedDivider = HTMLElement & { orientation: "horizontal" | "vertical" }; @@ -262,66 +262,9 @@ describe("chat page split layout host", () => { false, ); expect(page.querySelector("resizable-divider")).toBeNull(); - // The always-on pane header owns the classic split-view opener. expect(typeof itemAt(panes, 0, "rendered pane").onOpenSplitView).toBe("function"); }); - it("hands route-owned focus to the final page across pane replacement", async () => { - const sourcePage = new ChatPage(); - setNavigationContext(sourcePage); - sourcePage.data = { - sessionKey: "main", - draft: "What can you do?", - focusComposer: true, - }; - const page = new ChatPage(); - setNavigationContext(page); - page.data = { sessionKey: "main" }; - - vi.useFakeTimers(); - try { - document.body.append(sourcePage); - await sourcePage.updateComplete; - await Promise.resolve(); - - document.body.append(page); - await page.updateComplete; - const pane = itemAt(page.querySelectorAll("openclaw-chat-pane"), 0, "pane"); - expect(pane.focusComposer).toBe(true); - - const combobox = document.createElement("div"); - combobox.className = "agent-chat__composer-combobox"; - const textarea = document.createElement("textarea"); - combobox.append(textarea); - pane.append(combobox); - vi.advanceTimersByTime(250); - expect(document.activeElement).toBe(textarea); - - const replacementPane = document.createElement("openclaw-chat-pane") as RenderedPane; - replacementPane.active = true; - replacementPane.sessionKey = "main"; - const replacementCombobox = document.createElement("div"); - replacementCombobox.className = "agent-chat__composer-combobox"; - const replacementTextarea = document.createElement("textarea"); - replacementCombobox.append(replacementTextarea); - replacementPane.append(replacementCombobox); - pane.replaceWith(replacementPane); - - vi.advanceTimersByTime(250); - expect(document.activeElement).toBe(replacementTextarea); - - const userTarget = document.createElement("button"); - document.body.append(userTarget); - userTarget.focus(); - vi.advanceTimersByTime(250); - expect(document.activeElement).toBe(userTarget); - } finally { - sourcePage.remove(); - page.remove(); - vi.useRealTimers(); - } - }); - it("passes the chat-owned gateway capability only to the rightmost pane", async () => { const gatewaySnapshot: NativeGatewaysSnapshot = { gateways: [], @@ -644,7 +587,7 @@ describe("chat page split layout host", () => { await page.updateComplete; const pane = page.querySelector("openclaw-chat-pane"); - pane?.onFaceChange?.("dashboard"); + pane?.onFaceChange?.(pane.paneId, pane.sessionKey, "dashboard"); const expectedSearch = catalogSessionSearch(CATALOG_KEY); expect(navigation.navigate).toHaveBeenCalledWith("dashboard", { pathname: "/dashboard/research", @@ -679,7 +622,7 @@ describe("chat page split layout host", () => { }); navigation.navigate.mockClear(); const pane = page.querySelector("openclaw-chat-pane"); - pane?.onFaceChange?.("dashboard"); + pane?.onFaceChange?.(pane.paneId, pane.sessionKey, "dashboard"); expect(navigation.navigate).toHaveBeenCalledWith("dashboard", { pathname: "/dashboard/main/1234567890", }); @@ -783,7 +726,7 @@ describe("chat page split layout host", () => { expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { sessionKeys: [] }); }); - it("renders only the active pane from a preserved split on narrow viewports", async () => { + it("keeps split panes mounted but presents only the active pane on narrow viewports", async () => { stubMatchMedia(true); const page = new ChatPage(); page.data = { sessionKey: "main" }; @@ -792,9 +735,12 @@ describe("chat page split layout host", () => { await page.updateComplete; const panes = [...page.querySelectorAll("openclaw-chat-pane")]; - expect(panes.map((pane) => pane.paneId)).toEqual(["p2"]); - expect(itemAt(panes, 0, "rendered pane").active).toBe(true); - expect(itemAt(panes, 0, "rendered pane").narrow).toBe(true); + expect(panes.map((pane) => pane.paneId)).toEqual(["p1", "p2"]); + expect(panes.filter((pane) => pane.active).map((pane) => pane.paneId)).toEqual(["p2"]); + expect(panes.every((pane) => pane.narrow)).toBe(true); + expect(panes.filter((pane) => pane.presented).map((pane) => pane.paneId)).toEqual(["p2"]); + expect(panes[0]?.hasAttribute("inert")).toBe(true); + expect(panes[0]?.closest(".chat-split-view__cell--narrow-hidden")).not.toBeNull(); expect(page.querySelector("resizable-divider")).toBeNull(); }); @@ -815,7 +761,7 @@ describe("chat page split layout host", () => { const narrowPane = itemAt( page.querySelectorAll("openclaw-chat-pane"), - 0, + 1, "active narrow pane", ); expect(narrowPane).toBe(activePane); diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 8a4204442105..c7d58e65855d 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -10,22 +10,22 @@ import "../../components/resizable-divider.ts"; import { McpAppUnmountGate } from "../../components/mcp-app-unmount.ts"; import { UI_COMMAND_EVENT, type UiCommandDetail } from "../../components/panel-toggle-contract.ts"; import { t } from "../../i18n/index.ts"; -import type { BoardFace } from "../../lib/board/settings.ts"; -import { resolveSessionDisplayName } from "../../lib/session-display.ts"; import { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts"; -import { resolveSessionKey } from "../../lib/sessions/index.ts"; import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts"; import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { persistSessionBoardFace } from "./chat-board-face-persistence.ts"; import { stillOwnsCanonicalLocation } from "./chat-canonical-location.ts"; +import { + ChatPageRetainedSessions, + renderRetainedChatPanes, +} from "./chat-page-retained-sessions.ts"; import { closeStagedPane, resumeStagedPanes } from "./chat-pane-attachment-handoff.ts"; import { ChatViewerPresenceController } from "./chat-viewer-presence.ts"; import "../../styles/chat.css"; import "./chat-pane.ts"; import { RouteDraftComposerFocus, type ChatPaneElement } from "./route-draft-focus-handoff.ts"; -import { locationWithoutDraft, routeDraft } from "./route-draft.ts"; +import { locationWithoutDraft } from "./route-draft.ts"; import type { SessionChatRouteData } from "./route-loader.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; import { @@ -47,12 +47,12 @@ import { singlePaneLayout, splitRatio, splitWeight, - visiblePanesOf, type ChatSplitLayout, type ChatSplitPane, } from "./split-layout.ts"; type DropIndicator = { paneId: string; zone: SplitDropZone; rect: SplitDropRect }; + export class ChatPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -73,7 +73,6 @@ export class ChatPage extends OpenClawLightDomElement { ); private mediaQuery: MediaQueryList | null = null; private mobileNavMediaQuery: MediaQueryList | null = null; - // Clear the shared preview only after balanced Light-DOM drag events leave the page. private dragDepth = 0; private dragFrame = 0; private pendingDragOver: { pane: ChatPaneElement; x: number; y: number } | null = null; @@ -82,11 +81,24 @@ export class ChatPage extends OpenClawLightDomElement { private readonly chatMessagesBySession: ChatMessageCache = new Map(); private classicColumnId = "c1"; private classicPaneId = "p1"; + private routeHref = ""; private readonly mcpAppUnmountGate = new McpAppUnmountGate(this); private readonly viewerPresence = new ChatViewerPresenceController(this); + private readonly retainedSessions = new ChatPageRetainedSessions(this, { + context: () => this.context, + face: () => this.data?.face ?? "chat", + layout: () => this.layout ?? this.classicLayout(), + splitLayout: () => this.layout, + persistLayout: (layout) => this.persistLayout(layout), + selectReplacement: (paneId, sourceSessionKey, sessionKey) => { + this.handlePaneSessionChange(paneId, sourceSessionKey, sessionKey); + }, + updateRoute: (sessionKey, replace, face) => this.updateRoute(sessionKey, replace, face), + }); override connectedCallback() { super.connectedCallback(); + this.routeHref = window.location.href; this.layout = loadSettings().chatSplitLayout; this.mediaQuery = window.matchMedia("(max-width: 1099px)"); this.narrow = this.mediaQuery.matches; @@ -100,6 +112,7 @@ export class ChatPage extends OpenClawLightDomElement { this.addEventListener("drop", this.handleDrop); window.addEventListener("dragend", this.handleWindowDragEnd); window.addEventListener(UI_COMMAND_EVENT, this.handleUiCommand); + this.retainedSessions.connect(); this.syncRouteAgent(); this.syncRouteToActivePane(); const layout = this.layout ?? this.classicLayout(); @@ -107,6 +120,7 @@ export class ChatPage extends OpenClawLightDomElement { } override disconnectedCallback() { + this.retainedSessions.disconnect(); this.viewerPresence.dispose(); this.subscriptions.clear(); this.mediaQuery?.removeEventListener("change", this.handleViewportChange); @@ -134,12 +148,12 @@ export class ChatPage extends OpenClawLightDomElement { const activeSessionKey = this.layout ? (activePane?.sessionKey ?? null) : undefined; const draftRendered = this.draftFocus.rendered(data, activeSessionKey, this.consumedDraftData); if (changedProperties.has("data")) { + this.routeHref = window.location.href; if ( data?.canonicalLocation && stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data) ) { - // data.face is the loader's resolved face, which may differ from the namespace - // this route was matched under; replacing under it moves the URL to that board. + // Move a route matched under the wrong namespace to its resolved board face. this.context.replace(data.face ?? "chat", data.canonicalLocation); return; } @@ -150,8 +164,7 @@ export class ChatPage extends OpenClawLightDomElement { this.data === data && stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data) ) { - // A lazy chat canonicalization can resolve while the old page remains - // mounted under a cold navigation. Never replace that newer route. + // A lazy canonicalization must never replace a newer route. this.context.replace( data.face ?? "chat", this.consumedDraftData === data ? locationWithoutDraft(location) : location, @@ -160,14 +173,13 @@ export class ChatPage extends OpenClawLightDomElement { }); this.syncRouteAgent(); this.syncRouteToActivePane(); + this.retainedSessions.settleRoute(data.sessionKey); } if (data && draftRendered) { - // Process the route draft once so later focus changes cannot hand it to another pane. queueMicrotask(() => { if (this.isConnected && this.data === data && this.consumedDraftData !== data) { this.draftFocus.beforeDraftCleanup(data); this.consumedDraftData = data; - // Remove the one-shot draft from history once the matching pane owns it. this.updateRoute(data.sessionKey, true, data.face ?? "chat"); this.requestUpdate(); } @@ -203,7 +215,6 @@ export class ChatPage extends OpenClawLightDomElement { if (command.kind !== "split" && command.kind !== "close-pane" && command.kind !== "focus") { return; } - // Narrow viewports leave programmatic splits to the host's navigation fallback. if (command.kind === "split" && this.narrow) { return; } @@ -225,6 +236,9 @@ export class ChatPage extends OpenClawLightDomElement { command.kind === "close-pane" && targetPane ? closeStagedPane(this.context, this, layout, targetPane.id) : undefined; + if (targetPane) { + this.retainedSessions.discardPane(targetPane.id); + } const next = applyUiCommandToSplitLayout(layout, command, sourceSessionKey); if (next === layout) { return; @@ -262,7 +276,6 @@ export class ChatPage extends OpenClawLightDomElement { const target = event.target instanceof Element ? event.target : null; const pane = target?.closest("openclaw-chat-pane"); if (!pane || !this.contains(pane)) { - // Keep the last preview while the pointer crosses dividers and pane gaps. return; } this.pendingDragOver = { pane, x: event.clientX, y: event.clientY }; @@ -311,7 +324,6 @@ export class ChatPage extends OpenClawLightDomElement { const sessionKey = readSessionDragData(event.dataTransfer); const target = event.target instanceof Element ? event.target : null; const pane = target?.closest("openclaw-chat-pane"); - // A divider or gap uses the retained preview so the drop matches its indicator. const indicator = (pane && this.contains(pane) ? this.resolveDropIndicator(pane, event.clientX, event.clientY) @@ -406,15 +418,6 @@ export class ChatPage extends OpenClawLightDomElement { } } - private readonly handlePaneFaceChange = (paneId: string, sessionKey: string, face: BoardFace) => { - const layout = this.layout; - if (layout && layout.activePaneId !== paneId) { - this.persistLayout(setActivePane(layout, paneId)); - } - persistSessionBoardFace(this.context, sessionKey, face); - this.updateRoute(sessionKey, false, face); - }; - private applySessionDrop(sessionKey: string, paneId: string, zone: SplitDropZone): void { const trimmed = sessionKey.trim(); if (!trimmed) { @@ -472,26 +475,31 @@ export class ChatPage extends OpenClawLightDomElement { private readonly handlePaneSessionChange = ( paneId: string, + sourceSessionKey: string, sessionKey: string, options?: { replace?: boolean }, - ) => { + ): boolean => { const trimmed = sessionKey.trim(); - if (!trimmed) { - return; + if (!trimmed || window.location.href !== this.routeHref) { + return false; } - const layout = this.layout; - if (!layout) { + const resolvedLayout = this.layout ?? this.classicLayout(); + const pane = findPane(resolvedLayout, paneId)?.pane; + if (!pane || !areUiSessionKeysEquivalent(pane.sessionKey, sourceSessionKey)) { + return false; + } + if (!this.layout) { this.updateRoute(trimmed, options?.replace); - return; + return true; } - const pane = findPane(layout, paneId)?.pane; - if (!pane || pane.sessionKey === trimmed) { - return; + if (pane.sessionKey === trimmed) { + return true; } - this.persistLayout(setPaneSession(layout, paneId, trimmed)); - if (layout.activePaneId === paneId) { + this.persistLayout(setPaneSession(resolvedLayout, paneId, trimmed)); + if (resolvedLayout.activePaneId === paneId) { this.updateRoute(trimmed, options?.replace); } + return true; }; private readonly openSplitView = () => { @@ -521,6 +529,7 @@ export class ChatPage extends OpenClawLightDomElement { return; } const survivingPane = closeStagedPane(this.context, this, layout, paneId); + this.retainedSessions.discardPane(paneId); const next = closePane(layout, paneId); if (!next && survivingPane) { const survivingLocation = findPane(layout, survivingPane.id); @@ -550,52 +559,42 @@ export class ChatPage extends OpenClawLightDomElement { ownerKey: string, showGatewayPicker: boolean, ) { - const sessions = this.context?.sessions?.state.result?.sessions ?? []; - const nativeGateways = nativeGatewaysCapability(); - const draft = active - ? routeDraft(this.data, this.consumedDraftData, pane.sessionKey) - : undefined; - const focus = this.draftFocus.shouldFocusPane(active, draft, pane.sessionKey, this.data); - // Resolve aliases like the pane does so renamed sessions keep their display title. - const resolvedKey = - resolveSessionKey(pane.sessionKey, this.context?.gateway?.snapshot?.hello) || pane.sessionKey; - const title = resolveSessionDisplayName( - resolvedKey, - sessions.find((row) => areUiSessionKeysEquivalent(row.key, resolvedKey)), - ); return html`
this.handleFocusPane(pane.id)} @focusin=${() => this.handleFocusPane(pane.id)} > - - this.handlePaneFaceChange(pane.id, pane.sessionKey, face)} - > +
+ ${renderRetainedChatPanes({ + active, + chatMessagesBySession: this.chatMessagesBySession, + consumedDraftData: this.consumedDraftData, + context: this.context, + data: this.data, + draftFocus: this.draftFocus, + mergedChrome: this.mergedChrome, + narrow: this.narrow, + navDrawerOpen: this.navDrawerOpen, + onboarding: this.closest(".shell--onboarding") !== null, + onClosePane: splitMode ? this.handleClosePane : undefined, + onFaceChange: this.retainedSessions.changeFace, + onFocusPane: this.handleFocusPane, + onOpenSplitView: splitMode || this.narrow ? undefined : this.openSplitView, + onPaneSessionChange: this.handlePaneSessionChange, + onSessionDeleted: this.retainedSessions.removeSession, + onSplitDown: splitMode ? this.handleSplitDown : undefined, + onSplitRight: splitMode ? this.handleSplitRight : undefined, + ownerKey, + pane, + sessionKeys: this.retainedSessions.retain(pane), + showGatewayPicker, + splitMode, + })} +
`; } @@ -606,30 +605,20 @@ export class ChatPage extends OpenClawLightDomElement { private renderSplitLayout(layout: ChatSplitLayout, splitMode: boolean) { const activeLocation = findPane(layout, layout.activePaneId); - const renderedColumns = - this.narrow && activeLocation - ? [ - { - ...activeLocation.column, - panes: [activeLocation.pane], - paneWeights: [1], - }, - ] - : this.narrow - ? [] - : layout.columns; - const renderedColumnWeights = this.narrow ? [1] : layout.columnWeights; - const rightmostPane = renderedColumns.at(-1)?.panes.at(-1); + const rightmostPane = this.narrow ? activeLocation?.pane : layout.columns.at(-1)?.panes.at(-1); return html`
${repeat( - renderedColumns, + layout.columns, (column) => column.id, (column, columnIndex) => html`
- ${columnIndex < renderedColumns.length - 1 + ${!this.narrow && columnIndex < layout.columns.length - 1 ? html` pane.id)); + const renderedPaneIds = new Set(panesOf(layout).map((pane) => pane.id)); + this.retainedSessions.prune(renderedPaneIds); const renderedPaneOwners = layout.columns.flatMap((column) => - column.panes - .filter((pane) => renderedPaneIds.has(pane.id)) - .map((pane) => ({ columnId: column.id, pane })), + column.panes.map((pane) => ({ columnId: column.id, pane })), ); const nextPaneKeys = new Set( - renderedPaneOwners.map(({ columnId, pane }) => - JSON.stringify([columnId, pane.id, pane.sessionKey]), - ), + renderedPaneOwners.flatMap(({ columnId, pane }) => { + const ownerKey = JSON.stringify([columnId, pane.id]); + return this.retainedSessions + .retain(pane) + .map((sessionKey) => JSON.stringify([ownerKey, sessionKey])); + }), ); const rendered = html`
diff --git a/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts b/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts index 71778d1c1734..4a5672e44567 100644 --- a/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts +++ b/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts @@ -45,11 +45,13 @@ function state(attachments: ChatAttachment[], sessionKey = "agent:main:one") { describe("staged chat attachment pane handoff", () => { it("discards a mounted package before clearing a closed pane handoff", () => { const calls: string[] = []; - const pane = { - paneId: "p1", - discardStagedAttachments: () => calls.push("discard"), - }; - const root = { querySelectorAll: () => [pane] } as unknown as ParentNode; + const root = { + querySelectorAll: () => [ + { paneId: "p1", discardStagedAttachments: () => calls.push("discard-one") }, + { paneId: "p1", discardStagedAttachments: () => calls.push("discard-two") }, + { paneId: "p2", discardStagedAttachments: () => calls.push("wrong-pane") }, + ], + } as unknown as ParentNode; const context = { chatAttachmentHandoff: { clearPane: () => calls.push("clear") }, } as unknown as ApplicationContext; @@ -69,7 +71,7 @@ describe("staged chat attachment pane handoff", () => { } satisfies ChatSplitLayout; expect(closeStagedPane(context, root, layout, "p1")?.id).toBe("p2"); - expect(calls).toEqual(["discard", "clear"]); + expect(calls).toEqual(["discard-one", "discard-two", "clear"]); }); it("does not restage a closed pane when its id is reused after disconnect", () => { @@ -211,10 +213,11 @@ describe("staged chat attachment pane handoff", () => { const context = { chatAttachmentHandoff: handoff } as unknown as ApplicationContext; const displaced = storedAttachment("displaced"); const mounted = storedAttachment("mounted"); + const remount = state([]); handoff.prepare({ owner, paneId: "p1", - scopeKey: "active", + scopeKey: storedChatOutboxScopeKey(resolveStoredChatOutboxScope(remount, remount.sessionKey)), attachments: [], fallbacks: { collision: { @@ -225,7 +228,6 @@ describe("staged chat attachment pane handoff", () => { }, }, }); - const remount = state([]); remount.chatComposerFallbackByScope = { collision: { attachments: [mounted], diff --git a/ui/src/pages/chat/chat-pane-attachment-handoff.ts b/ui/src/pages/chat/chat-pane-attachment-handoff.ts index d05d5ed6df94..698e878967f7 100644 --- a/ui/src/pages/chat/chat-pane-attachment-handoff.ts +++ b/ui/src/pages/chat/chat-pane-attachment-handoff.ts @@ -116,11 +116,9 @@ export function resumeStagedPanes( layout: ChatSplitLayout, narrow: boolean, ): void { - const visibleSessions = new Map( - visiblePanesOf(layout, narrow).map((pane) => [pane.id, pane.sessionKey]), - ); + const visiblePaneIds = new Set(visiblePanesOf(layout, narrow).map((pane) => pane.id)); for (const pane of root.querySelectorAll("openclaw-chat-pane")) { - if (visibleSessions.get(pane.paneId) === pane.sessionKey) { + if (visiblePaneIds.has(pane.paneId)) { pane.resumeStagedAttachments?.(); } } @@ -133,11 +131,14 @@ export function closeStagedPane( paneId: string, ) { const survivingPane = panesOf(layout).find((candidate) => candidate.id !== paneId); - const pane = [...root.querySelectorAll("openclaw-chat-pane")].find( + const mounted = [...root.querySelectorAll("openclaw-chat-pane")].filter( (candidate) => candidate.paneId === paneId, ); - // Clear a mounted pane first so its disconnect cannot restage the closed package. - pane?.discardStagedAttachments?.(); + // Clear every retained presentation first so their disconnects cannot + // restage a package under a later reused logical pane id. + for (const pane of mounted) { + pane.discardStagedAttachments?.(); + } context.chatAttachmentHandoff.clearPane(paneId); return survivingPane; } diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 836097cbe824..54c42adccfc9 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -59,7 +59,6 @@ import type { SessionRailMode } from "./components/chat-session-rail.ts"; import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; import { ChatTranscriptController } from "./components/chat-thread.ts"; import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts"; -import type { ChatSessionScrollPosition } from "./scroll.ts"; import type { ChatMessageCache } from "./session-message-cache.ts"; export abstract class ChatPaneBase extends OpenClawLightDomElement { @@ -70,22 +69,59 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) protected context!: ChatPageContext; @property({ attribute: false }) paneId = "single"; + @property({ attribute: false }) presentationId = "single"; @property({ attribute: false }) chatMessagesBySession?: ChatMessageCache; // Empty means "no route/layout opinion yet": the pane boots on the page // state's default session and must not canonicalize or write global session // bindings until the container supplies a real key (classic mode renders // before route data resolves). @property({ attribute: false }) sessionKey = ""; - @property({ attribute: false }) active = false; + private activeValue = false; + private presentedValue = true; + get presented(): boolean { + return this.presentedValue; + } + set presented(value: boolean) { + const previous = this.presentedValue; + if (value === previous) { + return; + } + this.presentedValue = value; + this.requestUpdate("presented", previous); + this.presentedChanged(value); + } + protected presentedChanged(_presented: boolean): void {} + get active(): boolean { + return this.activeValue; + } + set active(value: boolean) { + const previous = this.activeValue; + if (value === previous) { + return; + } + this.activeValue = value; + this.requestUpdate("active", previous); + this.activeChanged(value); + } + protected activeChanged(_active: boolean): void {} @property({ attribute: false }) draft?: string; @property({ attribute: false }) focusComposer = false; @property({ attribute: false }) routeFace: BoardFace = "chat"; - @property({ attribute: false }) onFaceChange?: (face: BoardFace) => void; + @property({ attribute: false }) onFaceChange?: ( + paneId: string, + sessionKey: string, + face: BoardFace, + ) => void; @property({ attribute: false }) onFocusPane?: (paneId: string) => void; @property({ attribute: false }) onPaneSessionChange?: ( paneId: string, nextSessionKey: string, options?: PaneSessionChangeOptions, + ) => boolean | void; + @property({ attribute: false }) onSessionDeleted?: ( + paneId: string, + sessionKey: string, + replacementSessionKey: string, ) => void; @property({ attribute: false }) paneTitle = ""; @property({ attribute: false }) narrow = false; @@ -378,9 +414,6 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { protected abstract applyApplicationConfig(config: ChatPageContext["config"]["current"]): void; protected abstract applySessionsState(state: ChatPageContext["sessions"]["state"]): void; protected abstract cancelHeaderRename(): void; - protected abstract resetOlderMessagesViewport( - nextSessionKey?: string, - ): ChatSessionScrollPosition | null; - protected abstract restoreOlderMessagesViewport(sessionKey: string, scrollTop: number): void; + protected abstract resetOlderMessagesViewport(): void; protected abstract sendPendingSkillWorkshopRevision(expectedSessionKey: string): void; } diff --git a/ui/src/pages/chat/chat-pane-board.test.ts b/ui/src/pages/chat/chat-pane-board.test.ts index a8edb881778f..2befc051cd91 100644 --- a/ui/src/pages/chat/chat-pane-board.test.ts +++ b/ui/src/pages/chat/chat-pane-board.test.ts @@ -30,9 +30,11 @@ type TestChatPane = HTMLElement & { context: ApplicationContext; state: ChatPageHost; createSession: () => Promise; + paneId: string; + sessionKey: string; resetConfirmationOpen: boolean; routeFace: "chat" | "dashboard"; - onFaceChange?: (face: "chat" | "dashboard") => void; + onFaceChange?: (paneId: string, sessionKey: string, face: "chat" | "dashboard") => void; confirmConversationReset: () => Promise; settleResetConfirmation: (confirmed: boolean) => void; updated: () => void; @@ -414,6 +416,18 @@ describe("chat pane board shell", () => { }); }); + it("routes face changes through the owning retained presentation", () => { + const pane = createTestPane(); + pane.paneId = "pane-1"; + pane.sessionKey = "agent:main:retained"; + const onFaceChange = vi.fn(); + pane.onFaceChange = onFaceChange; + + pane.persistBoardSessionView({ face: "dashboard" }); + + expect(onFaceChange).toHaveBeenCalledWith("pane-1", "agent:main:retained", "dashboard"); + }); + it("uses in-memory tab preferences while the route owns the face", () => { const pane = createTestPane(); pane.routeFace = "dashboard"; diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index 69968633d728..946d026a505d 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -351,7 +351,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { patch: Partial & { face?: "chat" | "dashboard" }, ): void { if (patch.face) { - this.onFaceChange?.(patch.face); + this.onFaceChange?.(this.paneId, this.sessionKey, patch.face); } const persistedPatch = { ...patch }; delete persistedPatch.face; diff --git a/ui/src/pages/chat/chat-pane-browser-annotation-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-browser-annotation-lifecycle.test.ts index 541e48455396..5b04d7d18c51 100644 --- a/ui/src/pages/chat/chat-pane-browser-annotation-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-browser-annotation-lifecycle.test.ts @@ -7,6 +7,7 @@ import type { ApplicationContext } from "../../app/context.ts"; import type { BrowserAnnotationDraft } from "../../components/browser/browser-annotation.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { + cloneChatAttachmentsForIndependentOwner, getChatAttachmentDataUrl, registerChatAttachmentPayload, releaseChatAttachmentPayload, @@ -63,6 +64,17 @@ describe("staged attachment composer adoption", () => { }); } + it("clones payload ownership for another retained composer", () => { + const source = storedAttachment("independent-source", false); + const [destination] = cloneChatAttachmentsForIndependentOwner([source]); + + expect(destination?.id).not.toBe(source.id); + expect(getChatAttachmentDataUrl(destination!)).toBe(getChatAttachmentDataUrl(source)); + releaseChatAttachmentPayload(source.id); + expect(getChatAttachmentDataUrl(source)).toBeNull(); + expect(getChatAttachmentDataUrl(destination!)).not.toBeNull(); + }); + function connectPaneThroughAttachmentRestore( context: ApplicationContext, paneId: string, @@ -184,6 +196,29 @@ describe("staged attachment composer adoption", () => { remount.disconnectedCallback(); }); + it("restores each retained session package under the same logical pane", () => { + const owner = {} as GatewayBrowserClient; + const context = createSessionContext(owner, {} as SessionCapability); + const first = connectPaneThroughAttachmentRestore(context, "p1", "agent:main:first"); + const second = connectPaneThroughAttachmentRestore(context, "p1", "agent:main:second"); + const firstAttachment = storedAttachment("retained-first", false); + const secondAttachment = storedAttachment("retained-second", false); + first.state.chatAttachments = [firstAttachment]; + second.state.chatAttachments = [secondAttachment]; + + first.disconnectedCallback(); + second.disconnectedCallback(); + + const secondRemount = connectPaneThroughAttachmentRestore(context, "p1", "agent:main:second"); + const firstRemount = connectPaneThroughAttachmentRestore(context, "p1", "agent:main:first"); + expect(secondRemount.state.chatAttachments).toEqual([secondAttachment]); + expect(firstRemount.state.chatAttachments).toEqual([firstAttachment]); + secondRemount.discardStagedAttachments?.(); + firstRemount.discardStagedAttachments?.(); + secondRemount.disconnectedCallback(); + firstRemount.disconnectedCallback(); + }); + it("keeps generated context on the attachment and leaves the user's draft unchanged", () => { const { pane, state } = createTestChatPane({ client: {} as GatewayBrowserClient, diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index a3effa943b9f..ad685eeb8c89 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -90,8 +90,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { parseAgentSessionKey(state.sessionKey)?.agentId ?? this.context.agentSelection.state.selectedId ?? "main"; - this.onPaneSessionChange?.( + this.onSessionDeleted?.( this.paneId, + state.sessionKey, buildAgentMainSessionKey({ agentId, mainKey: resolveUiConfiguredMainKey({ @@ -281,7 +282,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { if ( routeSessionKey && canonicalRouteSessionKey && - canonicalRouteSessionKey !== routeSessionKey + canonicalRouteSessionKey !== routeSessionKey && + this.active && + this.presented ) { this.onPaneSessionChange?.(this.paneId, canonicalRouteSessionKey, { replace: true }); state.requestUpdate?.(); diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index a653b966e356..254dec4425e2 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -1,6 +1,5 @@ /* @vitest-environment jsdom */ -import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; import type { SessionCatalogSession, @@ -13,9 +12,8 @@ import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessio import type { SessionCapability } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; import { loadChatHistory } from "./chat-history.ts"; +import { consumePaneSessionHandoff } from "./chat-pane-shared.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; -import type { AfterCommitEffect } from "./render-lifecycle.ts"; -import type { ChatSessionScrollPosition } from "./scroll.ts"; type TestChatPane = HTMLElement & { catalogMessages: unknown[]; @@ -26,8 +24,8 @@ type TestChatPane = HTMLElement & { continueCatalogSession: (key: CatalogSessionKey) => Promise; catalogLoadGeneration: number; catalogSession: SessionCatalogSession | null; + paneId: string; sessionKey: string; - switchPaneSession: (nextSessionKey: string) => void; onPaneSessionChange?: (paneId: string, sessionKey: string) => void; catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record | null; handleTranscriptScroll: (event: Event) => void; @@ -40,8 +38,7 @@ type TestChatPane = HTMLElement & { hasOlderMessages: () => boolean; loadingOlder: boolean; olderOffsetsSeen: Set; - resetOlderMessagesViewport: (nextSessionKey?: string) => ChatSessionScrollPosition | null; - restoreOlderMessagesViewport: (sessionKey: string, scrollTop: number) => void; + resetOlderMessagesViewport: () => void; transcriptScrollTop: number | null; transcript: { activeSessionKey: string | null; @@ -144,11 +141,6 @@ function createCatalogContinuationPane(request: ReturnType) { canArchive: true, }; pane.onPaneSessionChange = vi.fn(); - pane.switchPaneSession = vi.fn((nextSessionKey: string) => { - state.sessionKey = nextSessionKey; - pane.sessionKey = nextSessionKey; - pane.catalogLoadGeneration += 1; - }); return { client, key, pane, requestUpdate, sessions, sourceSessionKey, state }; } @@ -168,83 +160,6 @@ function nativeHistorySeq(message: unknown): number | undefined { } describe("chat pane native history pagination", () => { - it("restores a saved per-session viewport while first visits keep the end anchor", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const thread = document.createElement("div"); - thread.className = "chat-thread"; - Object.defineProperty(thread, "scrollHeight", { configurable: true, value: 2_600 }); - Object.defineProperty(thread, "clientHeight", { configurable: true, value: 500 }); - thread.scrollTop = 420; - pane.append(thread); - pane.transcript.activeSessionKey = state.sessionKey; - - expect(pane.resetOlderMessagesViewport("agent:main:session-b")).toBeNull(); - state.sessionKey = "agent:main:session-b"; - pane.transcript.activeSessionKey = state.sessionKey; - thread.scrollTop = 80; - expect(pane.resetOlderMessagesViewport("agent:main:current")).toEqual({ - scrollTop: 420, - anchorToEnd: false, - }); - - state.sessionKey = "agent:main:current"; - pane.transcript.activeSessionKey = state.sessionKey; - let commitEffect: AfterCommitEffect | undefined; - state.renderLifecycle.afterCommit = vi.fn((effect: AfterCommitEffect) => { - commitEffect = effect; - return vi.fn(); - }); - pane.restoreOlderMessagesViewport(state.sessionKey, 420); - commitEffect?.(vi.fn()); - - expect(thread.scrollTop).toBe(420); - expect(pane.transcriptScrollTop).toBe(420); - expect(state.chatHasAutoScrolled).toBe(true); - expect(state.chatFollowLocked).toBe(true); - expect(state.chatNewMessagesBelow).toBe(true); - - // A rapid second switch sees a transient DOM top of zero while the - // logical restore is still pending; it must retain the logical 420px. - thread.scrollTop = 0; - const pendingScrollOffset = vi - .spyOn(pane.transcript, "pendingScrollOffsetFor") - .mockReturnValue(420); - expect(pane.resetOlderMessagesViewport("agent:main:session-b")).toEqual({ - scrollTop: 80, - anchorToEnd: false, - }); - pendingScrollOffset.mockRestore(); - state.sessionKey = "agent:main:session-b"; - pane.transcript.activeSessionKey = state.sessionKey; - expect(pane.resetOlderMessagesViewport("agent:main:current")).toEqual({ - scrollTop: 420, - anchorToEnd: false, - }); - }); - - it("restores through equivalent default-main session keys", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.sessionKey = "agent:main:main"; - const thread = document.createElement("div"); - thread.className = "chat-thread"; - Object.defineProperty(thread, "scrollHeight", { configurable: true, value: 2_600 }); - Object.defineProperty(thread, "clientHeight", { configurable: true, value: 500 }); - pane.append(thread); - let commitEffect: AfterCommitEffect | undefined; - state.renderLifecycle.afterCommit = vi.fn((effect: AfterCommitEffect) => { - commitEffect = effect; - return vi.fn(); - }); - - pane.restoreOlderMessagesViewport("main", 420); - commitEffect?.(vi.fn()); - - expect(thread.scrollTop).toBe(420); - expect(pane.transcriptScrollTop).toBe(420); - }); - it("does not request older rows from a complete imported snapshot", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); @@ -671,34 +586,35 @@ describe("chat pane native history pagination", () => { }); describe("chat pane catalog continuation lifecycle", () => { - it("continues and sends a catalog draft while its original connection remains current", async () => { + it("hands a continued catalog draft to the retained destination pane", async () => { const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" }); const { key, pane, state } = createCatalogContinuationPane(request); await pane.continueCatalogSession(key); expect(request).toHaveBeenCalledWith("sessions.catalog.continue", key); - const onPaneSessionChange = expectDefined( - pane.onPaneSessionChange, - "catalog continuation navigation callback", - ); - expect(onPaneSessionChange).toHaveBeenCalledWith("single", "agent:main:continued"); - expect(pane.switchPaneSession).toHaveBeenCalledWith("agent:main:continued"); + expect(pane.onPaneSessionChange).toHaveBeenCalledWith("single", "agent:main:continued"); + expect(consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:continued")).toEqual({ + attachments: [], + draft: "Continue the original catalog conversation", + send: true, + }); + expect(state.sessionKey).not.toBe("agent:main:continued"); + expect(state.handleSendChat).not.toHaveBeenCalled(); + }); + + it("does not stage or send a continuation rejected by its logical pane", async () => { + const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:rejected-continuation" }); + const { key, pane, state } = createCatalogContinuationPane(request); + pane.onPaneSessionChange = vi.fn(() => false); + + await pane.continueCatalogSession(key); + expect( - expectDefined( - vi.mocked(pane.switchPaneSession).mock.invocationCallOrder[0], - "catalog continuation session switch order", - ), - ).toBeLessThan( - expectDefined( - vi.mocked(onPaneSessionChange).mock.invocationCallOrder[0], - "catalog continuation navigation order", - ), - ); - expect(state.handleChatDraftChange).toHaveBeenCalledWith( - "Continue the original catalog conversation", - ); - expect(state.handleSendChat).toHaveBeenCalledOnce(); + consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:rejected-continuation"), + ).toBeNull(); + expect(state.handleSendChat).not.toHaveBeenCalled(); + expect(state.chatSending).toBe(false); }); it("does not send a stale catalog draft after the user switches conversations", async () => { @@ -715,7 +631,6 @@ describe("chat pane catalog continuation lifecycle", () => { await pending; expect(pane.onPaneSessionChange).not.toHaveBeenCalled(); - expect(pane.switchPaneSession).not.toHaveBeenCalled(); expect(state.handleChatDraftChange).not.toHaveBeenCalled(); expect(state.handleSendChat).not.toHaveBeenCalled(); expect(state.sessionKey).toBe("agent:main:different-conversation"); @@ -736,7 +651,6 @@ describe("chat pane catalog continuation lifecycle", () => { await pending; expect(pane.onPaneSessionChange).not.toHaveBeenCalled(); - expect(pane.switchPaneSession).not.toHaveBeenCalled(); expect(state.handleChatDraftChange).not.toHaveBeenCalled(); expect(state.handleSendChat).not.toHaveBeenCalled(); expect(state.chatMessage).toBe("Draft from the reconnected conversation"); @@ -760,7 +674,6 @@ describe("chat pane catalog continuation lifecycle", () => { await pending; expect(pane.onPaneSessionChange).not.toHaveBeenCalled(); - expect(pane.switchPaneSession).not.toHaveBeenCalled(); expect(state.handleChatDraftChange).not.toHaveBeenCalled(); expect(state.handleSendChat).not.toHaveBeenCalled(); expect(state.client).toBe(replacementClient); @@ -782,7 +695,6 @@ describe("chat pane catalog continuation lifecycle", () => { continued.resolve({ sessionKey: "agent:main:stale-continuation" }); await pending; - expect(pane.switchPaneSession).not.toHaveBeenCalled(); expect(state.handleSendChat).not.toHaveBeenCalled(); expect(state.chatSendingScopeKey).toBe("newer-conversation-send"); expect(state.chatSending).toBe(true); @@ -803,17 +715,21 @@ describe("chat pane catalog continuation lifecycle", () => { first.resolve({ sessionKey: "agent:main:stale-continuation" }); await staleContinuation; - expect(pane.switchPaneSession).not.toHaveBeenCalled(); expect(state.handleSendChat).not.toHaveBeenCalled(); expect(state.chatSending).toBe(true); second.resolve({ sessionKey: "agent:main:latest-continuation" }); await currentContinuation; - expect(pane.switchPaneSession).toHaveBeenCalledOnce(); - expect(pane.switchPaneSession).toHaveBeenCalledWith("agent:main:latest-continuation"); - expect(state.handleChatDraftChange).toHaveBeenCalledWith("Only send the latest catalog draft"); - expect(state.handleSendChat).toHaveBeenCalledOnce(); + expect(pane.onPaneSessionChange).toHaveBeenCalledOnce(); + expect( + consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:latest-continuation"), + ).toEqual({ + attachments: [], + draft: "Only send the latest catalog draft", + send: true, + }); + expect(state.handleSendChat).not.toHaveBeenCalled(); }); it("does not display a rejected catalog continuation in a different conversation", async () => { @@ -847,71 +763,4 @@ describe("chat pane catalog continuation lifecycle", () => { expect(state.chatSending).toBe(false); expect(state.handleSendChat).not.toHaveBeenCalled(); }); - - it("reports a send failure in the newly adopted catalog conversation", async () => { - const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" }); - const { key, pane, state } = createCatalogContinuationPane(request); - state.handleSendChat = vi.fn(async () => { - throw new Error("Could not send the continued draft"); - }); - - await pane.continueCatalogSession(key); - - expect(state.sessionKey).toBe("agent:main:continued"); - expect(state.lastError).toBe("Could not send the continued draft"); - expect(state.chatSending).toBe(false); - }); - - it("does not display an adopted send failure after returning to the source conversation", async () => { - const sent = createDeferred(); - const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" }); - const { key, pane, sourceSessionKey, state } = createCatalogContinuationPane(request); - state.handleSendChat = vi.fn(() => sent.promise); - - const pending = pane.continueCatalogSession(key); - await vi.waitFor(() => expect(state.handleSendChat).toHaveBeenCalledOnce()); - state.sessionKey = sourceSessionKey; - pane.sessionKey = sourceSessionKey; - pane.catalogLoadGeneration += 1; - state.lastError = "Current catalog conversation error"; - state.chatSending = false; - sent.reject(new Error("Stale adopted conversation send failed")); - await pending; - - expect(state.sessionKey).toBe(sourceSessionKey); - expect(state.lastError).toBe("Current catalog conversation error"); - expect(state.chatSending).toBe(false); - }); - - it("reports an error when adopting the current catalog conversation fails", async () => { - const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" }); - const { key, pane, state } = createCatalogContinuationPane(request); - pane.switchPaneSession = vi.fn(() => { - throw new Error("Could not open the adopted conversation"); - }); - - await pane.continueCatalogSession(key); - - expect(state.lastError).toBe("Could not open the adopted conversation"); - expect(state.chatSending).toBe(false); - expect(state.handleSendChat).not.toHaveBeenCalled(); - }); - - it("reports an adoption failure after the session transition has already started", async () => { - const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" }); - const { key, pane, state } = createCatalogContinuationPane(request); - pane.switchPaneSession = vi.fn((nextSessionKey: string) => { - state.sessionKey = nextSessionKey; - pane.sessionKey = nextSessionKey; - pane.catalogLoadGeneration += 1; - throw new Error("Could not finish opening the adopted conversation"); - }); - - await pane.continueCatalogSession(key); - - expect(state.sessionKey).toBe("agent:main:continued"); - expect(state.lastError).toBe("Could not finish opening the adopted conversation"); - expect(state.chatSending).toBe(false); - expect(state.handleSendChat).not.toHaveBeenCalled(); - }); }); diff --git a/ui/src/pages/chat/chat-pane-history.ts b/ui/src/pages/chat/chat-pane-history.ts index 2e34a039af80..64333d58c0b2 100644 --- a/ui/src/pages/chat/chat-pane-history.ts +++ b/ui/src/pages/chat/chat-pane-history.ts @@ -29,15 +29,14 @@ import { CHAT_HISTORY_INTENT_IDLE_MS, CHAT_HISTORY_TOUCH_INTENT_PX, CHAT_HISTORY_UPWARD_KEYS, + clearPaneSessionHandoff, + preparePaneSessionHandoff, } from "./chat-pane-shared.ts"; import { persistChatComposerState } from "./composer-persistence.ts"; import { captureChatSessionScrollPosition, - getChatSessionScrollPosition, - restoreChatScroll, saveChatSessionScrollPosition, scheduleChatScroll, - type ChatSessionScrollPosition, } from "./scroll.ts"; export abstract class ChatPaneHistory extends ChatPaneSession { @@ -59,30 +58,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { return pagination.hasMore && !state.chatLoading; } - protected resetOlderMessagesViewport(nextSessionKey?: string): ChatSessionScrollPosition | null { - let restoredPosition: ChatSessionScrollPosition | null = null; - const state = this.state; - if (nextSessionKey && state) { - const root = this.querySelector(".chat-thread"); - const outgoingSessionKey = root ? this.transcript.renderedSessionKey : state.sessionKey; - const pendingScrollTop = outgoingSessionKey - ? this.transcript.pendingScrollOffsetFor(outgoingSessionKey) - : null; - const outgoingPosition = - pendingScrollTop !== null - ? { scrollTop: pendingScrollTop, anchorToEnd: false } - : root - ? captureChatSessionScrollPosition(root) - : this.transcriptScrollTop !== null - ? { scrollTop: this.transcriptScrollTop, anchorToEnd: false } - : null; - if (outgoingSessionKey && outgoingPosition) { - saveChatSessionScrollPosition(this.paneId, outgoingSessionKey, outgoingPosition); - } - } - if (nextSessionKey) { - restoredPosition = getChatSessionScrollPosition(this.paneId, nextSessionKey) ?? null; - } + protected resetOlderMessagesViewport(): void { this.olderLoadGeneration += 1; this.loadingOlder = false; this.historyObserverArmed = false; @@ -94,62 +70,11 @@ export abstract class ChatPaneHistory extends ChatPaneSession { window.clearTimeout(this.historyIntentTimer); this.historyIntentTimer = null; } - this.transcriptScrollTop = restoredPosition?.scrollTop ?? null; + this.transcriptScrollTop = null; this.olderCursorsSeen.clear(); this.olderOffsetsSeen.clear(); this.nativePaginationSnapshot = null; this.clearHistoryObserver(); - return restoredPosition; - } - - protected restoreOlderMessagesViewport(sessionKey: string, scrollTop: number): void { - const state = this.state; - if (!state || !areUiSessionKeysEquivalent(state.sessionKey, sessionKey)) { - return; - } - const generation = this.olderLoadGeneration; - state.renderLifecycle.afterCommit((complete) => { - try { - if ( - this.state !== state || - !areUiSessionKeysEquivalent(state.sessionKey, sessionKey) || - this.olderLoadGeneration !== generation - ) { - return; - } - const root = this.querySelector(".chat-thread"); - if (root) { - restoreChatScroll(state, root, scrollTop); - // The outer scroller can still be zero-height on this commit. Let - // the virtualizer reconcile the logical target as rows are measured. - this.transcript.scrollToOffset(scrollTop, (settledPosition) => { - if ( - this.state !== state || - !areUiSessionKeysEquivalent(state.sessionKey, sessionKey) || - this.olderLoadGeneration !== generation - ) { - return; - } - const settledRoot = this.querySelector(".chat-thread"); - if (!settledRoot) { - return; - } - this.transcriptScrollTop = restoreChatScroll( - state, - settledRoot, - settledPosition.scrollTop, - ); - saveChatSessionScrollPosition(this.paneId, sessionKey, { - ...settledPosition, - scrollTop: this.transcriptScrollTop, - }); - }); - this.transcriptScrollTop = scrollTop; - } - } finally { - complete(); - } - }); } protected clearHistoryObserver(): void { @@ -172,6 +97,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { } if ( typeof IntersectionObserver !== "function" || + !this.presented || !this.state?.connected || this.loadingOlder || !this.hasOlderMessages() @@ -248,7 +174,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { areUiSessionKeysEquivalent(renderedSessionKey, stateSessionKey) ) { saveChatSessionScrollPosition( - this.paneId, + this.presentationId, renderedSessionKey, captureChatSessionScrollPosition(root), ); @@ -423,8 +349,6 @@ export abstract class ChatPaneHistory extends ChatPaneSession { const sourceSessionKey = state.sessionKey; const sourceCatalogGeneration = this.catalogLoadGeneration; const continuation = Symbol("catalog-continuation"); - let adoptedSessionKey: string | null = null; - let adoptedCatalogGeneration: number | null = null; this.activeCatalogContinuation = continuation; state.chatSending = true; state.requestUpdate(); @@ -455,29 +379,26 @@ export abstract class ChatPaneHistory extends ChatPaneSession { releaseStaleContinuation(); return; } - adoptedSessionKey = result.sessionKey; - announceCatalogSessionContinued({ ...key, sessionKey: result.sessionKey }); - // Make the adopted session authoritative before routing; otherwise the - // outgoing catalog pane can immediately restore the previous chat URL. - this.switchPaneSession(result.sessionKey); - adoptedCatalogGeneration = this.catalogLoadGeneration; - this.onPaneSessionChange?.(this.paneId, result.sessionKey); - state.handleChatDraftChange(draft); - await state.handleSendChat(); - if (this.activeCatalogContinuation === continuation) { - this.activeCatalogContinuation = null; + preparePaneSessionHandoff(this.context, this.paneId, result.sessionKey, { + attachments: [], + draft, + send: true, + }); + if (this.onPaneSessionChange?.(this.paneId, result.sessionKey) === false) { + clearPaneSessionHandoff(this.context, this.paneId, result.sessionKey); + releaseStaleContinuation(); + return; } + announceCatalogSessionContinued({ ...key, sessionKey: result.sessionKey }); + this.activeCatalogContinuation = null; + state.chatSending = false; + state.requestUpdate(); } catch (error) { if ( this.activeCatalogContinuation !== continuation || !this.isConnectionScopeCurrent(scope) || - (adoptedSessionKey === null - ? this.catalogLoadGeneration !== sourceCatalogGeneration || - state.sessionKey !== sourceSessionKey - : adoptedCatalogGeneration === null - ? state.sessionKey !== sourceSessionKey && state.sessionKey !== adoptedSessionKey - : this.catalogLoadGeneration !== adoptedCatalogGeneration || - state.sessionKey !== adoptedSessionKey) + this.catalogLoadGeneration !== sourceCatalogGeneration || + state.sessionKey !== sourceSessionKey ) { releaseStaleContinuation(); return; @@ -513,24 +434,20 @@ export abstract class ChatPaneHistory extends ChatPaneSession { try { const result = await state.sessions.forkAtMessage(sourceKey, entryId, agentParams); const editorText = result.editorText ?? ""; - const draftPersisted = persistChatComposerState(state, result.sessionKey, { - agentId: parseAgentSessionKey(result.sessionKey)?.agentId, - draft: editorText, - }); if (this.state !== state || !visibleSessionMatches(state, sourceKey, agentParams.agentId)) { return; } - this.onPaneSessionChange?.(this.paneId, result.sessionKey); - this.switchPaneSession(result.sessionKey); - // Restored images intentionally stay in this tab's memory; persisted composer drafts remain - // text-only so large payloads do not enter local storage. - state.chatAttachments = replaceChatAttachmentsFromEditor( - state.chatAttachments, - result.editorAttachments, - ); - if (!draftPersisted) { - state.handleChatDraftChange(editorText); + if (this.onPaneSessionChange?.(this.paneId, result.sessionKey) === false) { + return; } + persistChatComposerState(state, result.sessionKey, { + agentId: parseAgentSessionKey(result.sessionKey)?.agentId, + draft: editorText, + }); + preparePaneSessionHandoff(this.context, this.paneId, result.sessionKey, { + attachments: replaceChatAttachmentsFromEditor([], result.editorAttachments), + draft: editorText, + }); } catch (error) { state.lastError = error instanceof Error ? error.message : String(error); state.chatError = state.lastError; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index fcb02c2d24d5..2e9aec9414a5 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -768,55 +768,6 @@ describe("chat pane presentation teardown", () => { true, ); }); - - it("dismisses the previous session confirmation before switching in place", () => { - const frameCallbacks: FrameRequestCallback[] = []; - vi.stubGlobal( - "requestAnimationFrame", - vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback); - return frameCallbacks.length; - }), - ); - const addDocumentListener = vi.spyOn(document, "addEventListener"); - const removeDocumentListener = vi.spyOn(document, "removeEventListener"); - const addWindowListener = vi.spyOn(window, "addEventListener"); - const removeWindowListener = vi.spyOn(window, "removeEventListener"); - const { pane } = createTestChatPane({ - client: {} as GatewayBrowserClient, - sessions: {} as SessionCapability, - }); - window.localStorage.removeItem(SKIP_REWIND_CONFIRM_PREFERENCE); - const confirmation = createConfirmationOwner(); - - try { - for (const callback of frameCallbacks.splice(0)) { - callback(0); - } - const captureClickListener = addDocumentListener.mock.calls.find( - ([type, listener, options]) => type === "click" && options === true && listener, - )?.[1]; - const captureKeydownListener = addWindowListener.mock.calls.find( - ([type, listener, options]) => type === "keydown" && options === true && listener, - )?.[1]; - expect(captureClickListener).toBeDefined(); - expect(captureKeydownListener).toBeDefined(); - pane.appendChild(confirmation.owner); - - const stopAfterReset = new Error("stop after thread presentation reset"); - vi.spyOn(pane, "cancelHeaderRename").mockImplementation(() => { - throw stopAfterReset; - }); - - expect(() => pane.switchPaneSession("agent:main:next")).toThrow(stopAfterReset); - expect(confirmation.popover.isConnected).toBe(false); - expect(removeDocumentListener).toHaveBeenCalledWith("click", captureClickListener, true); - expect(removeWindowListener).toHaveBeenCalledWith("keydown", captureKeydownListener, true); - } finally { - dismissConfirmedActionPopovers(confirmation.owner); - confirmation.owner.remove(); - } - }); }); describe("chat pane connection lifecycle", () => { diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 3f59a004ade5..1a3a6a28ca1a 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -15,17 +15,11 @@ import { BROWSER_ANNOTATION_EVENT } from "../../components/browser/browser-annot import { t } from "../../i18n/index.ts"; import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts"; import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.ts"; -import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts"; import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; -import { resolveSessionCreateParams } from "../../lib/sessions/create.ts"; -import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; -import { - areUiSessionKeysEquivalent, - resolveAgentIdFromSessionKey, -} from "../../lib/sessions/session-key.ts"; +import { resolveSessionKey } from "../../lib/sessions/index.ts"; +import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; import { invalidateChatAvatarCache, refreshChatAvatar } from "./chat-avatar.ts"; -import { clearChatHistory } from "./chat-history.ts"; import { type ChatAttachmentGatewayOwner, discardStateStagedAttachments, @@ -33,11 +27,11 @@ import { replacePaneStagedAttachmentGatewayOwner, restorePaneStagedAttachments, } from "./chat-pane-attachment-handoff.ts"; -import { ChatPaneBoard } from "./chat-pane-board.ts"; import { focusBrowserAnnotationComposerAfterUpdate, receiveBrowserAnnotation as admitBrowserAnnotation, } from "./chat-pane-browser-annotation.ts"; +import { ChatPaneSessionCreation } from "./chat-pane-session-creation.ts"; import { CHAT_COMPOSER_TEXTAREA_SELECTOR, CHAT_MODAL_SELECTOR, @@ -45,9 +39,6 @@ import { CHAT_SPACE_ACTIVATION_SELECTOR, CHAT_TEXT_ENTRY_SELECTOR, keyboardEventPathMatches, - NEW_SESSION_ACTIVE_RUN_MESSAGE, - NEW_SESSION_CREATE_FAILED_MESSAGE, - NEW_SESSION_LIST_LOADING_MESSAGE, } from "./chat-pane-shared.ts"; import { subscribeChatPaneStartup } from "./chat-pane-startup-subscriptions.ts"; import { setChatError } from "./chat-send-queue-state.ts"; @@ -55,7 +46,7 @@ import { applySelectedChatAgent } from "./chat-session.ts"; import { handlePageGatewayEvent } from "./chat-state-events.ts"; import { createPageState } from "./chat-state-page.ts"; import { invalidateChatMetadataCache, refreshPageChat } from "./chat-state-refresh.ts"; -import { selectedChatSessionRow, canCreateChatSession } from "./chat-state-route.ts"; +import { selectedChatSessionRow } from "./chat-state-route.ts"; import { resetChatViewState } from "./chat-view-state.ts"; import { dismissConfirmedActionPopovers } from "./components/chat-message.ts"; import { clearChatModelSearchOnEscape } from "./components/chat-model-picker.ts"; @@ -70,7 +61,7 @@ import { readChatSessionSnapshot } from "./session-message-cache.ts"; const COMPOSER_PREFILL_ATTENTION_DURATION_MS = 1_200; const COMPOSER_PREFILL_ATTENTION_CLASS = "agent-chat__input--prefill-attention"; -export abstract class ChatPaneLifecycle extends ChatPaneBoard { +export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation { private stagedAttachmentGatewayOwner: ChatAttachmentGatewayOwner = null; private suppressStagedAttachmentHandoffOnDisconnect = false; @@ -100,7 +91,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { ); } - private clearComposerPrefillAttention(): void { + protected clearComposerPrefillAttention(): void { if (this.composerPrefillAttentionTimer !== null) { window.clearTimeout(this.composerPrefillAttentionTimer); this.composerPrefillAttentionTimer = null; @@ -203,117 +194,10 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { `; } - protected readonly createSession = async (): Promise => { - const state = this.state; - if (!state || !state.client || !state.connected) { - return false; - } - const context = this.context; - const sessions = context.sessions; - const client = state.client; - const previousSessionKey = state.sessionKey; - const preservesBoard = this.resolveBoardView().hasBoard; - const createParams = { - currentSessionKey: previousSessionKey, - agentId: - scopedAgentParamsForSession(state, previousSessionKey).agentId ?? - resolveAgentIdFromSessionKey(previousSessionKey), - }; - const createRequestParams = { - ...resolveSessionCreateParams(createParams.currentSessionKey, createParams.agentId), - }; - const readCreateAccess = () => - readSessionMethodAccess(context.gateway.snapshot, { - method: preservesBoard ? "sessions.reset" : "sessions.create", - ...(preservesBoard - ? { requiredScope: "operator.admin" as const } - : { params: createRequestParams }), - }); - const publishCreateAccessError = (reason: string) => { - state.lastError = reason; - state.chatError = reason; - state.requestUpdate?.(); - }; - const connectionGeneration = this.connectionGeneration; - const isCurrent = () => - this.isConnected && - this.state === state && - this.context === context && - this.context.sessions === sessions && - state.client === client && - state.connected && - this.connectedClient === client && - context.gateway.snapshot.client === client && - context.gateway.snapshot.phase === "connected" && - this.connectionGeneration === connectionGeneration; - if (!canCreateChatSession(state)) { - setChatError(state, NEW_SESSION_ACTIVE_RUN_MESSAGE); - state.requestUpdate?.(); - return false; - } - if (state.sessionsLoading) { - setChatError(state, NEW_SESSION_LIST_LOADING_MESSAGE); - state.requestUpdate?.(); - return false; - } - const initialAccess = readCreateAccess(); - if (!initialAccess.allowed) { - publishCreateAccessError(initialAccess.reason); - return false; - } - if ( - !(await this.confirmConversationReset()) || - !isCurrent() || - !areUiSessionKeysEquivalent(state.sessionKey, previousSessionKey) - ) { - return false; - } - if (!canCreateChatSession(state)) { - setChatError(state, NEW_SESSION_ACTIVE_RUN_MESSAGE); - state.requestUpdate?.(); - return false; - } - const currentAccess = readCreateAccess(); - if (!currentAccess.allowed) { - publishCreateAccessError(currentAccess.reason); - return false; - } - - setChatError(state, null); - if (preservesBoard) { - const resetResult = await clearChatHistory(state); - return resetResult !== "failed"; - } - const nextSessionKey = await sessions.create(createParams); - if (!isCurrent()) { - return false; - } - if ( - !nextSessionKey || - state.sessionKey !== previousSessionKey || - !canCreateChatSession(state) - ) { - if (!nextSessionKey) { - setChatError( - state, - state.sessionsError ?? - (state.sessionsLoading - ? NEW_SESSION_LIST_LOADING_MESSAGE - : NEW_SESSION_CREATE_FAILED_MESSAGE), - ); - state.requestUpdate?.(); - } - return false; - } - this.chatState.captureCreatedSessionComposer(nextSessionKey); - this.onPaneSessionChange?.(this.paneId, nextSessionKey); - return true; - }; - protected syncActiveBindings() { this.nativeDraftCleanup?.(); this.nativeDraftCleanup = null; - if (!this.active) { + if (!this.active || !this.presented) { this.announceCommandPaletteTarget(null); return; } @@ -321,7 +205,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { this.applyActiveSessionBindings(); this.nativeDraftCleanup = this.context.nativeChatDrafts.subscribe((draft) => { const state = this.state; - if (!state || !this.active) { + if (!state || !this.active || !this.presented) { return; } state.handleChatDraftChange(draft); @@ -336,7 +220,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { /** Receives one complete browser annotation without mixing generated context into the user's draft. */ protected receiveBrowserAnnotation(event: Event): void { - const accepted = admitBrowserAnnotation(this.state, this.active, event); + const accepted = admitBrowserAnnotation(this.state, this.active && this.presented, event); if (!accepted) { return; } @@ -347,7 +231,13 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { protected sendPendingSkillWorkshopRevision(expectedSessionKey: string) { const state = this.state; - if (!this.active || !state || !state.connected || state.sessionKey !== expectedSessionKey) { + if ( + !this.active || + !this.presented || + !state || + !state.connected || + state.sessionKey !== expectedSessionKey + ) { return; } const revision = this.context.skillWorkshopRevision.consume( @@ -374,6 +264,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { protected readonly handleDocumentKeydown = (event: KeyboardEvent) => { if ( this.active && + this.presented && !event.defaultPrevented && !event.altKey && event.shiftKey && @@ -392,6 +283,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { if ( this.active && + this.presented && !event.defaultPrevented && !event.isComposing && !event.metaKey && @@ -446,9 +338,12 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { }; override connectedCallback() { - this.boardProviderLifecycleConnected = true; + this.boardProviderLifecycleConnected = this.presented; this.resumeStagedAttachments(); super.connectedCallback(); + if (!this.presented) { + this.minutePoll.stop(); + } const mountGatewayOwner = this.context.gateway.snapshot.client; this.stagedAttachmentGatewayOwner = mountGatewayOwner; this.requestUpdate(); @@ -519,8 +414,15 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { } chatState.attach(pageState); chatState.restoreComposer({ preserveCurrent: true }); + const sessionHandoff = this.takeSessionHandoff(pageState.sessionKey); + if (sessionHandoff?.restore) { + this.applySessionHandoff(pageState.sessionKey, sessionHandoff, false); + } restorePaneStagedAttachments(this.context, this.paneId, pageState, mountGatewayOwner); chatState.startComposerPersistence(); + if (sessionHandoff && !sessionHandoff.restore) { + this.applySessionHandoff(pageState.sessionKey, sessionHandoff, true); + } if (this.draft !== undefined) { this.state.handleChatDraftChange(this.draft); } @@ -611,14 +513,12 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { // after its transcript commit in deferSessionHydrationUntilTranscript. this.sessionDiscussionStates.delete(nextSessionKey); } - if (nextSessionKey && !areUiSessionKeysEquivalent(nextSessionKey, this.state.sessionKey)) { - this.switchPaneSession(nextSessionKey); - } else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) { + if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) { this.catalogLoadGeneration += 1; this.openCatalogSession(catalogKey, this.state); } else if (nextSessionKey) { - // Route aliases name the same conversation. Adopt the canonical spelling - // without clearing the active stream and tool state as a session switch. + // A retained pane owns one conversation for its lifetime. Only its + // canonical spelling can change after Gateway defaults resolve. this.state.sessionKey = nextSessionKey; // A pane routed straight onto the created session never runs the switch // path, so its one-shot handoffs would expire unclaimed: the rejected turn @@ -634,9 +534,14 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { this.requestUpdate(); } } - this.chatState.restoreCreatedSessionComposer(nextSessionKey); + if (nextSessionKey) { + const handoff = this.takeSessionHandoff(nextSessionKey); + if (handoff) { + this.applySessionHandoff(nextSessionKey, handoff, true); + } + } } - if (changedProperties.has("active") || changedProperties.has("sessionKey")) { + if (changedProperties.has("sessionKey")) { this.syncActiveBindings(); } if ( @@ -723,7 +628,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { this.presencePayload = undefined; this.announceCommandPaletteTarget(null); dismissConfirmedActionPopovers(this); - resetChatViewState(this.paneId); + resetChatViewState(this.presentationId); this.state = undefined; this.connectedClient = null; disposeQuestionPromptState(this.questionPromptState); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 506ba87f1c5e..b64dc05c3329 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -139,9 +139,10 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { const { catalogKey, fullMessageLoader, chatProps } = resolveChatMessageAccess(state); const overlays = this.context?.overlays; const approvalSnapshot = overlays?.snapshot; - const inlineApproval = this.active - ? findInlineApproval(approvalSnapshot?.approvalQueue ?? [], state.sessionKey) - : null; + const inlineApproval = findInlineApproval( + approvalSnapshot?.approvalQueue ?? [], + state.sessionKey, + ); // Tool rows consult the global title store while rendering; point its // fetcher at this pane's connection. Requests capture session + agent at // schedule time, so later renders of other panes cannot re-route them. @@ -212,7 +213,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { ? this.paneWidth : (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth)); const sessionWorkspace = createSessionWorkspaceProps(state, { - draftScope: this.paneId, + draftScope: this.presentationId, narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, }); const railSideDocked = @@ -262,9 +263,9 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { const props: ChatProps = { transcript: this.transcript, backgroundTaskTranscript: this.backgroundTaskTranscript, - paneId: this.paneId, + paneId: this.presentationId, sessionKey: state.sessionKey, - announceTranscript: this.active, + announceTranscript: this.active && this.presented, onSessionKeyChange: (next) => { this.onPaneSessionChange?.(this.paneId, next); }, diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.test.ts b/ui/src/pages/chat/chat-pane-retained-presentation.test.ts new file mode 100644 index 000000000000..4f710b22bf65 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-retained-presentation.test.ts @@ -0,0 +1,231 @@ +/* @vitest-environment jsdom */ +/* @vitest-environment-options {"url":"http://chat-pane-retained.test/"} */ + +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { + getChatAttachmentDataUrl, + registerChatAttachmentPayload, + releaseChatAttachmentPayload, +} from "./attachment-payload-store.ts"; +import { + preparePaneStagedAttachments, + restorePaneStagedAttachments, +} from "./chat-pane-attachment-handoff.ts"; +import { + clearPaneSessionHandoffs, + consumePaneSessionHandoff, + preparePaneSessionHandoff, +} from "./chat-pane-shared.ts"; +import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; + +describe("chat pane retained presentation lifecycle", () => { + it("expires abandoned eviction payload ownership", () => { + vi.useFakeTimers(); + const id = "expired-retained-attachment"; + try { + const { pane } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + const attachment = registerChatAttachmentPayload({ + attachment: { id, mimeType: "image/png" }, + dataUrl: "data:image/png;base64,ZXhwaXJlZA==", + file: new File(["expired"], "expired.png", { type: "image/png" }), + }); + preparePaneSessionHandoff(pane.context, "p1", "agent:main:expired", { + attachments: [attachment], + draft: "", + restore: true, + }); + + vi.advanceTimersByTime(30_000); + + expect(consumePaneSessionHandoff(pane.context, "p1", "agent:main:expired")).toBeNull(); + expect(getChatAttachmentDataUrl(attachment)).toBeNull(); + } finally { + releaseChatAttachmentPayload(id); + vi.useRealTimers(); + } + }); + + it("clears every unmounted eviction handoff for a permanently discarded pane", () => { + const { pane } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + const attachment = registerChatAttachmentPayload({ + attachment: { id: "permanently-discarded-attachment", mimeType: "image/png" }, + dataUrl: "data:image/png;base64,ZGlzY2FyZGVk", + file: new File(["discarded"], "discarded.png", { type: "image/png" }), + }); + preparePaneSessionHandoff(pane.context, "p1", "agent:main:evicted-a", { + attachments: [attachment], + draft: "evicted a", + restore: true, + }); + preparePaneSessionHandoff(pane.context, "p1", "agent:main:evicted-b", { + attachments: [], + draft: "evicted b", + restore: true, + }); + + clearPaneSessionHandoffs(pane.context, "p1"); + + expect(consumePaneSessionHandoff(pane.context, "p1", "agent:main:evicted-a")).toBeNull(); + expect(consumePaneSessionHandoff(pane.context, "p1", "agent:main:evicted-b")).toBeNull(); + expect(getChatAttachmentDataUrl(attachment)).toBeNull(); + }); + + it("restores draft attachments and memory fallbacks after LRU eviction", () => { + const source = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + source.pane.paneId = "p1"; + source.pane.presentationId = "p1:first"; + source.pane.sessionKey = "agent:main:first"; + source.state.sessionKey = "agent:main:first"; + source.state.chatMessage = "draft kept across eviction"; + source.state.chatAttachments = [ + { id: "attachment", mimeType: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ]; + source.state.chatComposerFallbackByScope = { + fallback: { + attachments: [{ id: "fallback-attachment", mimeType: "text/plain" }], + message: "memory-only fallback", + sequence: 1, + storageFailed: true, + }, + }; + + source.pane.prepareForEviction(); + const owner = source.pane.context.gateway.snapshot.client; + preparePaneStagedAttachments(source.pane.context, source.pane.paneId, source.state, owner); + + const destination = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + destination.pane.context = source.pane.context; + destination.pane.paneId = "p1"; + destination.pane.presentationId = "p1:first-remount"; + destination.pane.sessionKey = "agent:main:first"; + destination.state.sessionKey = "agent:main:first"; + restorePaneStagedAttachments( + destination.pane.context, + destination.pane.paneId, + destination.state, + owner, + ); + destination.pane.presented = false; + destination.pane.presented = true; + + expect(destination.state.chatMessage).toBe("draft kept across eviction"); + expect(destination.state.chatAttachments).toEqual(source.state.chatAttachments); + expect(destination.state.chatComposerFallbackByScope).toEqual( + source.state.chatComposerFallbackByScope, + ); + }); + + it("delivers a one-shot continuation to the mounted destination and sends it", async () => { + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.paneId = "p1"; + pane.sessionKey = "agent:main:continued"; + state.sessionKey = pane.sessionKey; + state.handleChatDraftChange = vi.fn((draft) => { + state.chatMessage = draft; + }); + state.handleSendChat = vi.fn().mockResolvedValue(undefined); + preparePaneSessionHandoff(pane.context, pane.paneId, pane.sessionKey, { + attachments: [], + draft: "continue from the catalog", + send: true, + }); + + pane.presented = false; + pane.presented = true; + Object.defineProperty(pane, "active", { configurable: true, value: true }); + await Promise.resolve(); + + expect(state.handleChatDraftChange).toHaveBeenCalledWith("continue from the catalog"); + expect(state.handleSendChat).toHaveBeenCalledOnce(); + }); + + it("schedules renders when an actual retained pane is hidden and reactivated", () => { + const { pane } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.active = true; + const requestUpdate = vi.spyOn( + pane as unknown as { requestUpdate(name: PropertyKey, previous: unknown): void }, + "requestUpdate", + ); + + pane.presented = false; + pane.active = false; + pane.presented = true; + pane.active = true; + + expect( + requestUpdate.mock.calls.filter(([name]) => name === "presented" || name === "active"), + ).toEqual([ + ["presented", true], + ["active", true], + ["presented", false], + ["active", false], + ]); + }); + + it("retires foreground-only state when a retained pane is hidden", () => { + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + const stop = vi.fn(); + const release = vi.fn(); + state.realtimeTalkSession = { stop } as unknown as ChatPageHost["realtimeTalkSession"]; + state.realtimeTalkActive = true; + state.sidebarContent = { kind: "markdown", content: "transient details" }; + state.imageLightbox = { release, src: "blob:test", title: "preview" }; + pane.presentationId = "p1:visible"; + const announcement = document.createElement("span"); + announcement.className = "chat-transcript-announcement"; + announcement.setAttribute("aria-live", "polite"); + pane.append(announcement); + pane.presented = false; + + expect(stop).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); + expect(state.sidebarContent).toBeNull(); + expect(announcement.getAttribute("aria-live")).toBe("off"); + }); + + it("does not stage a created-session handoff when its logical pane rejects navigation", async () => { + const sessions = { + create: vi.fn().mockResolvedValue("agent:main:rejected-created-session"), + } as unknown as SessionCapability; + const { pane } = createTestChatPane({ client: {} as GatewayBrowserClient, sessions }); + advertiseSessionCreate(pane); + pane.onPaneSessionChange = vi.fn(() => false); + + await expect(pane.createSession()).resolves.toBe(false); + + expect( + consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:rejected-created-session"), + ).toBeNull(); + }); +}); + +function advertiseSessionCreate(pane: TestChatPane) { + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + features: { methods: ["sessions.create"] }, + } as typeof pane.context.gateway.snapshot.hello; +} diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.ts b/ui/src/pages/chat/chat-pane-retained-presentation.ts new file mode 100644 index 000000000000..097139100e74 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-retained-presentation.ts @@ -0,0 +1,156 @@ +import { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts"; +import { storeChatComposerMemoryFallback } from "./chat-composer-memory-fallback.ts"; +import { ChatPaneBoard } from "./chat-pane-board.ts"; +import { + consumePaneSessionHandoff, + type PaneSessionHandoff, + preparePaneSessionHandoff, +} from "./chat-pane-shared.ts"; +import { stopChatRealtimeTalk } from "./chat-realtime.ts"; +import { retryReconnectableQueuedChatSends } from "./chat-send-actions.ts"; +import { setChatError } from "./chat-send-queue-state.ts"; +import { refreshCurrentChatSessionList } from "./chat-session.ts"; +import { invalidateImageLightbox } from "./chat-state-page.ts"; +import { dismissConfirmedActionPopovers } from "./components/chat-message.ts"; +import { resetChatThreadSessionPresentationState } from "./components/chat-thread.ts"; +import { CHAT_COMPOSER_DRAFT_STORAGE_ERROR } from "./composer-persistence.ts"; + +/** Owns the resources and composer state that follow one retained presentation. */ +export abstract class ChatPaneRetainedPresentation extends ChatPaneBoard { + protected abstract clearComposerPrefillAttention(): void; + protected abstract settleResetConfirmation(confirmed: boolean): void; + protected abstract syncActiveBindings(): void; + + protected override activeChanged(active: boolean): void { + if (!this.isConnected) { + return; + } + this.syncActiveBindings(); + if (active && this.presented && this.state?.chatQueue.length) { + void refreshCurrentChatSessionList(this.state).catch(() => undefined); + void retryReconnectableQueuedChatSends(this.state); + } + this.querySelector(".chat-transcript-announcement")?.setAttribute( + "aria-live", + active ? "polite" : "off", + ); + } + + protected override presentedChanged(presented: boolean): void { + if (!this.isConnected) { + return; + } + if (presented) { + this.boardProviderLifecycleConnected = true; + this.minutePoll.start(); + this.consumeSessionHandoff(this.sessionKey); + this.syncActiveBindings(); + void this.refreshSessionPullRequests(); + return; + } + this.boardProviderLifecycleConnected = false; + this.releaseBoardProviderLease(); + this.minutePoll.stop(); + this.clearHistoryObserver(); + sessionPullRequestsForGateway(this.context.gateway).unwatch(this); + this.syncActiveBindings(); + this.clearComposerPrefillAttention(); + this.settleResetConfirmation(false); + this.cancelHeaderRename(); + dismissConfirmedActionPopovers(this); + resetChatThreadSessionPresentationState(this.presentationId, this); + const state = this.state; + if (state) { + stopChatRealtimeTalk(state); + invalidateImageLightbox(state); + state.sidebarContent = null; + state.requestUpdate?.(); + } + this.querySelector(".chat-transcript-announcement")?.setAttribute("aria-live", "off"); + } + + public prepareForEviction(): void { + const state = this.state; + if (!state?.sessionKey) { + return; + } + const persistResult = this.chatState.persistComposerForEviction(); + if (persistResult.status === "storage-failed") { + const scope = this.chatState.composerScopeForEviction(); + if (scope) { + storeChatComposerMemoryFallback(state, scope, { + message: state.chatMessage, + attachments: state.chatAttachments, + draftRetry: persistResult, + }); + } + } + preparePaneSessionHandoff(this.context, this.paneId, state.sessionKey, { + // The gateway-scoped disconnect handoff owns attachments and memory + // fallbacks. This transfer carries only composer metadata and the draft. + attachments: [], + draft: state.chatMessage, + restore: true, + storageFailed: persistResult.status === "storage-failed", + }); + } + + protected takeSessionHandoff(sessionKey: string): PaneSessionHandoff | null { + return consumePaneSessionHandoff(this.context, this.paneId, sessionKey); + } + + protected consumeSessionHandoff(sessionKey: string): void { + if (!this.state || !sessionKey) { + return; + } + const handoff = this.takeSessionHandoff(sessionKey); + if (handoff) { + this.applySessionHandoff(sessionKey, handoff, !handoff.restore); + } + } + + protected applySessionHandoff( + sessionKey: string, + handoff: PaneSessionHandoff, + notifyDraftChange: boolean, + ): void { + const state = this.state; + if (!state) { + return; + } + if (!handoff.restore) { + if (handoff.composerFallbacks) { + state.chatComposerFallbackByScope = handoff.composerFallbacks; + } + state.chatAttachments = [...handoff.attachments]; + } + if (notifyDraftChange) { + state.handleChatDraftChange(handoff.draft); + } else { + state.chatMessage = handoff.draft; + } + if (handoff.storageFailed) { + state.lastError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; + state.chatError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; + } + state.requestUpdate?.(); + if (handoff.send) { + queueMicrotask(() => { + if ( + this.state !== state || + state.sessionKey !== sessionKey || + !this.active || + !this.presented + ) { + return; + } + void state.handleSendChat().catch((error: unknown) => { + if (this.state === state && state.sessionKey === sessionKey) { + setChatError(state, error instanceof Error ? error.message : String(error)); + state.requestUpdate?.(); + } + }); + }); + } + } +} diff --git a/ui/src/pages/chat/chat-pane-session-creation.ts b/ui/src/pages/chat/chat-pane-session-creation.ts new file mode 100644 index 000000000000..7c58258b0d4c --- /dev/null +++ b/ui/src/pages/chat/chat-pane-session-creation.ts @@ -0,0 +1,135 @@ +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; +import { resolveSessionCreateParams } from "../../lib/sessions/create.ts"; +import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + resolveAgentIdFromSessionKey, +} from "../../lib/sessions/session-key.ts"; +import { cloneChatAttachmentsForIndependentOwner } from "./attachment-payload-store.ts"; +import { clearChatHistory } from "./chat-history.ts"; +import { ChatPaneRetainedPresentation } from "./chat-pane-retained-presentation.ts"; +import { + NEW_SESSION_ACTIVE_RUN_MESSAGE, + NEW_SESSION_CREATE_FAILED_MESSAGE, + NEW_SESSION_LIST_LOADING_MESSAGE, + preparePaneSessionHandoff, +} from "./chat-pane-shared.ts"; +import { setChatError } from "./chat-send-queue-state.ts"; +import { canCreateChatSession } from "./chat-state-route.ts"; + +/** Creates or resets a conversation while guarding its asynchronous ownership. */ +export abstract class ChatPaneSessionCreation extends ChatPaneRetainedPresentation { + protected abstract confirmConversationReset(): Promise; + + protected readonly createSession = async (): Promise => { + const state = this.state; + if (!state || !state.client || !state.connected) { + return false; + } + const context = this.context; + const sessions = context.sessions; + const client = state.client; + const previousSessionKey = state.sessionKey; + const preservesBoard = this.resolveBoardView().hasBoard; + const createParams = { + currentSessionKey: previousSessionKey, + agentId: + scopedAgentParamsForSession(state, previousSessionKey).agentId ?? + resolveAgentIdFromSessionKey(previousSessionKey), + }; + const createRequestParams = { + ...resolveSessionCreateParams(createParams.currentSessionKey, createParams.agentId), + }; + const readCreateAccess = () => + readSessionMethodAccess(context.gateway.snapshot, { + method: preservesBoard ? "sessions.reset" : "sessions.create", + ...(preservesBoard + ? { requiredScope: "operator.admin" as const } + : { params: createRequestParams }), + }); + const publishCreateAccessError = (reason: string) => { + state.lastError = reason; + state.chatError = reason; + state.requestUpdate?.(); + }; + const connectionGeneration = this.connectionGeneration; + const isCurrent = () => + this.isConnected && + this.state === state && + this.context === context && + this.context.sessions === sessions && + state.client === client && + state.connected && + this.connectedClient === client && + context.gateway.snapshot.client === client && + context.gateway.snapshot.phase === "connected" && + this.connectionGeneration === connectionGeneration; + if (!canCreateChatSession(state)) { + setChatError(state, NEW_SESSION_ACTIVE_RUN_MESSAGE); + state.requestUpdate?.(); + return false; + } + if (state.sessionsLoading) { + setChatError(state, NEW_SESSION_LIST_LOADING_MESSAGE); + state.requestUpdate?.(); + return false; + } + const initialAccess = readCreateAccess(); + if (!initialAccess.allowed) { + publishCreateAccessError(initialAccess.reason); + return false; + } + if ( + !(await this.confirmConversationReset()) || + !isCurrent() || + !areUiSessionKeysEquivalent(state.sessionKey, previousSessionKey) + ) { + return false; + } + if (!canCreateChatSession(state)) { + setChatError(state, NEW_SESSION_ACTIVE_RUN_MESSAGE); + state.requestUpdate?.(); + return false; + } + const currentAccess = readCreateAccess(); + if (!currentAccess.allowed) { + publishCreateAccessError(currentAccess.reason); + return false; + } + + setChatError(state, null); + if (preservesBoard) { + const resetResult = await clearChatHistory(state); + return resetResult !== "failed"; + } + const nextSessionKey = await sessions.create(createParams); + if (!isCurrent()) { + return false; + } + if ( + !nextSessionKey || + state.sessionKey !== previousSessionKey || + !canCreateChatSession(state) + ) { + if (!nextSessionKey) { + setChatError( + state, + state.sessionsError ?? + (state.sessionsLoading + ? NEW_SESSION_LIST_LOADING_MESSAGE + : NEW_SESSION_CREATE_FAILED_MESSAGE), + ); + state.requestUpdate?.(); + } + return false; + } + if (this.onPaneSessionChange?.(this.paneId, nextSessionKey) === false) { + return false; + } + preparePaneSessionHandoff(this.context, this.paneId, nextSessionKey, { + attachments: cloneChatAttachmentsForIndependentOwner(state.chatAttachments), + draft: state.chatMessage, + }); + return true; + }; +} diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index 06d4510928f3..ae38621c532f 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -7,7 +7,6 @@ import type { GatewaySessionRow } from "../../api/types.ts"; import { selectApplicationSession } from "../../app/agent-selection.ts"; import { clampText } from "../../lib/format.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; -import { resolveSessionDisplayName } from "../../lib/session-display.ts"; import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; import { scopedSessionPullRequestKey, @@ -23,12 +22,7 @@ import { import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; import { catalogMessageId } from "./catalog-message-id.ts"; -import { refreshChatAvatar } from "./chat-avatar.ts"; -import { - loadChatBranches, - loadChatHistory, - syncSelectedSessionMessageSubscription, -} from "./chat-history.ts"; +import { loadChatBranches } from "./chat-history.ts"; import { CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS, catalogRawResult, @@ -36,35 +30,21 @@ import { nativeHistoryMessageIdentity, summarizeSessionPullRequests, } from "./chat-pane-shared.ts"; -import { applySelectedSessionProjection } from "./chat-pane-state.ts"; import { ChatPaneTaskSuggestions } from "./chat-pane-task-suggestions.ts"; -import { flushChatQueueForEvent } from "./chat-send-actions.ts"; -import { flushChatQueueAfterIdleSessionReconciliation } from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; -import { refreshChatMetadata } from "./chat-state-refresh.ts"; -import { - refreshRouteSessionOptions, - resetChatStateForRouteSession, - retryChatComposerMemoryFallback, - resolveChatAgentId, - saveRouteSessionSettings, -} from "./chat-state-route.ts"; -import { dismissConfirmedActionPopovers } from "./components/chat-message.ts"; +import { resolveChatAgentId, saveRouteSessionSettings } from "./chat-state-route.ts"; import { dismissChatPullRequest, listDismissedChatPullRequests, } from "./components/chat-pull-requests.ts"; -import { resetChatThreadSessionPresentationState } from "./components/chat-thread.ts"; -import { - CHAT_COMPOSER_DRAFT_STORAGE_ERROR, - loadChatComposerSnapshot, - resolveStoredChatOutboxScope, - storedChatOutboxScopeKey, -} from "./composer-persistence.ts"; import { scheduleChatScroll } from "./scroll.ts"; export abstract class ChatPaneSession extends ChatPaneTaskSuggestions { protected async refreshSessionPullRequests(options: { refresh?: boolean } = {}): Promise { + if (!this.presented) { + sessionPullRequestsForGateway(this.context.gateway).unwatch(this); + return; + } const scope = this.captureConnectionScope(); if ( !scope || @@ -271,6 +251,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions { if ( !state || !this.active || + !this.presented || !this.sessionKey.trim() || parseCatalogSessionKey(state.sessionKey) ) { @@ -285,136 +266,6 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions { }); } - protected switchPaneSession(nextSessionKey: string) { - const state = this.state; - if (!state) { - return; - } - // Close old-session listener owners before the next render detaches their - // DOM; thread-global portals and caches are reset separately. - dismissConfirmedActionPopovers(this); - resetChatThreadSessionPresentationState(this.paneId); - this.sessionDiscussionOpenUrls.clear(); - const previousSessionKey = state.sessionKey; - // An in-progress title edit belongs to the previous session; committing - // it against the newly routed row would rename the wrong session. - this.cancelHeaderRename(); - const restoredPosition = this.resetOlderMessagesViewport(nextSessionKey); - const catalogKey = parseCatalogSessionKey(nextSessionKey); - const previousAgentId = resolveChatAgentId(state); - const previousSessionsResult = state.sessionsResult; - const nextSessionRow = state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey); - const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow); - const previousComposerScope = - this.chatState.composerScopeForRouteSwitch() ?? - resolveStoredChatOutboxScope(state, previousSessionKey); - const previousComposerScopeKey = storedChatOutboxScopeKey(previousComposerScope); - const existingFallback = state.chatComposerFallbackByScope[previousComposerScopeKey]; - const draftPersistResult = this.chatState.persistComposerForRouteSwitch(); - const draftPersisted = draftPersistResult.status === "persisted"; - const previousStoredSnapshot = loadChatComposerSnapshot( - state, - previousSessionKey, - previousComposerScope.agentId, - ); - const previousStoredDraft = previousStoredSnapshot ? previousStoredSnapshot.draft : null; - const storedDraftMatches = previousStoredDraft === state.chatMessage; - const hasStagedAttachments = state.chatAttachments.length > 0; - const retainExistingFallback = existingFallback !== undefined && !storedDraftMatches; - const previousDraftRetry = - draftPersistResult.status === "storage-failed" - ? { - expectedDraftRevision: draftPersistResult.expectedDraftRevision, - draftRevision: draftPersistResult.draftRevision, - } - : existingFallback?.storageFailed && !storedDraftMatches - ? existingFallback.draftRetry - : undefined; - resetChatStateForRouteSession(state, nextSessionKey, { - retainPreviousComposerInMemory: - !draftPersisted || hasStagedAttachments || retainExistingFallback, - previousDraftRetry, - previousComposerScope, - }); - // The sidebar row is already authoritative enough for first paint: it supplies - // the header and run controls while the reset restores any cached transcript. - applySelectedSessionProjection(state, nextSessionRow); - this.reconcileWaitingApprovalSnapshot(); - retryChatComposerMemoryFallback(state, nextSessionKey); - // Route restoration is the new persistence baseline. An untouched pane - // must not later erase a draft written by another split pane. Memory-only - // fallbacks stay pane-local until a later edit persists successfully. - this.chatState.adoptComposerRoute(); - this.taskSuggestionsRequestVersion += 1; - this.catalogLoadGeneration += 1; - this.taskSuggestions = []; - this.taskSuggestionBusyIds.clear(); - this.taskSuggestionOperations.clear(); - this.resetSessionSuggestions(); - this.clearTypingActors(); - this.resetSessionPullRequests(); - if (catalogKey) { - this.openCatalogSession(catalogKey, state); - return; - } - this.catalogRequestedSessionKey = null; - this.markSessionRead(nextSessionRow); - if (previousSessionKey !== nextSessionKey) { - state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel); - } - void state.loadAssistantIdentity(); - void refreshChatAvatar(state).finally(() => this.requestUpdate()); - const nextAgentId = resolveChatAgentId(state); - // Agent-scoped catalogs remain valid across same-agent sessions. Cross-agent - // failures must clear instead of retaining models owned by the previous agent. - void refreshChatMetadata(state, { - preserveModelCatalogOnFallback: Boolean(previousAgentId && previousAgentId === nextAgentId), - }).finally(() => state.requestUpdate?.()); - const subscriptionSync = syncSelectedSessionMessageSubscription(state); - const composerStorageError = state.chatError === CHAT_COMPOSER_DRAFT_STORAGE_ERROR; - const historyLoad = loadChatHistory(state, { deferBranches: true }); - if (composerStorageError) { - // History loading clears the shared error slot synchronously. Restore the - // pane-local storage warning unless the retry above made the draft durable. - state.lastError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; - state.chatError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; - } - state.requestUpdate(); - void this.refreshTaskSuggestions(); - void this.refreshSessionSuggestions(); - this.deferSessionHydrationUntilTranscript(nextSessionKey, historyLoad); - const scheduleHistoryScroll = () => { - if (state.sessionKey !== nextSessionKey) { - return; - } - state.requestUpdate(); - if (restoredPosition === null || restoredPosition.anchorToEnd) { - scheduleChatScroll(state, true); - } else { - this.restoreOlderMessagesViewport(nextSessionKey, restoredPosition.scrollTop); - } - }; - void historyLoad.then(scheduleHistoryScroll, scheduleHistoryScroll); - void historyLoad.then( - () => this.sendPendingSkillWorkshopRevision(nextSessionKey), - () => this.sendPendingSkillWorkshopRevision(nextSessionKey), - ); - if (state.chatQueue.length > 0) { - const sessionsRefresh = refreshRouteSessionOptions(state); - flushChatQueueAfterIdleSessionReconciliation( - state, - nextSessionKey, - historyLoad, - sessionsRefresh, - previousSessionsResult, - () => void flushChatQueueForEvent(state), - ); - void sessionsRefresh; - } - void subscriptionSync; - void historyLoad; - } - protected openCatalogSession(key: CatalogSessionKey, state: ChatPageHost) { this.catalogRequestedSessionKey = buildCatalogSessionKey(key); this.catalogMessages = []; diff --git a/ui/src/pages/chat/chat-pane-shared.ts b/ui/src/pages/chat/chat-pane-shared.ts index 7215c943c747..b0f5985f8c59 100644 --- a/ui/src/pages/chat/chat-pane-shared.ts +++ b/ui/src/pages/chat/chat-pane-shared.ts @@ -7,11 +7,146 @@ import { createDockPanelLayout } from "../../components/dock-panel-layout.ts"; import type { BoardProvider } from "../../lib/board/provider.ts"; import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts"; import type { BoardSnapshot, BoardTab } from "../../lib/board/types.ts"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; import { clampText } from "../../lib/format.ts"; +import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; +import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; export type ChatPageContext = ApplicationContext; export type PaneSessionChangeOptions = { replace?: boolean }; +export type PaneSessionHandoff = { + attachments: ChatAttachment[]; + composerFallbacks?: ChatPageHost["chatComposerFallbackByScope"]; + draft: string; + restore?: boolean; + send?: boolean; + storageFailed?: boolean; +}; +type PendingPaneSessionHandoff = PaneSessionHandoff & { expiresAt: number; sessionKey: string }; +// A retained pane owns one session for life, so creation/fork adoption crosses +// component instances. The application context scopes that one-shot transfer. +const PANE_SESSION_HANDOFF_TTL_MS = 30_000; +const PANE_SESSION_HANDOFF_LIMIT = 4; +const paneSessionHandoffs = new WeakMap< + ApplicationContext, + Map +>(); + +function discardPaneSessionHandoff(handoff: PendingPaneSessionHandoff): void { + if (!handoff.restore) { + return; + } + releaseChatAttachmentPayloads([ + ...handoff.attachments, + ...Object.values(handoff.composerFallbacks ?? {}).flatMap((fallback) => fallback.attachments), + ]); +} + +function paneHandoffs( + context: ApplicationContext, + paneId: string, + create: boolean, +): PendingPaneSessionHandoff[] | undefined { + let byPane = paneSessionHandoffs.get(context); + if (!byPane && create) { + byPane = new Map(); + paneSessionHandoffs.set(context, byPane); + } + let pending = byPane?.get(paneId); + if (!pending && create) { + pending = []; + byPane?.set(paneId, pending); + } + if (pending) { + const now = Date.now(); + for (let index = pending.length - 1; index >= 0; index -= 1) { + if (pending[index]!.expiresAt <= now) { + discardPaneSessionHandoff(pending[index]!); + pending.splice(index, 1); + } + } + } + return pending; +} + +export function preparePaneSessionHandoff( + context: ApplicationContext, + paneId: string, + sessionKey: string, + handoff: PaneSessionHandoff, +): void { + const pending = paneHandoffs(context, paneId, true)!; + const existing = pending.findIndex((candidate) => + areUiSessionKeysEquivalent(candidate.sessionKey, sessionKey), + ); + if (existing >= 0) { + discardPaneSessionHandoff(pending[existing]!); + pending.splice(existing, 1); + } + const stored = { + sessionKey, + ...handoff, + expiresAt: Date.now() + PANE_SESSION_HANDOFF_TTL_MS, + }; + pending.push(stored); + globalThis.setTimeout(() => { + paneHandoffs(context, paneId, false); + }, PANE_SESSION_HANDOFF_TTL_MS); + while (pending.length > PANE_SESSION_HANDOFF_LIMIT) { + discardPaneSessionHandoff(pending.shift()!); + } +} + +export function consumePaneSessionHandoff( + context: ApplicationContext, + paneId: string, + sessionKey: string, +): PaneSessionHandoff | null { + const pending = paneHandoffs(context, paneId, false); + const index = pending?.findIndex((candidate) => + areUiSessionKeysEquivalent(candidate.sessionKey, sessionKey), + ); + if (!pending || index === undefined || index < 0) { + return null; + } + const handoff = pending.splice(index, 1)[0]!; + const { expiresAt: _expiresAt, sessionKey: _sessionKey, ...value } = handoff; + return value; +} + +export function clearPaneSessionHandoff( + context: ApplicationContext, + paneId: string, + sessionKey: string, +): void { + const pending = paneHandoffs(context, paneId, false); + for (let index = (pending?.length ?? 0) - 1; index >= 0; index -= 1) { + if (areUiSessionKeysEquivalent(pending![index]!.sessionKey, sessionKey)) { + discardPaneSessionHandoff(pending![index]!); + pending?.splice(index, 1); + } + } +} + +export function clearPaneSessionHandoffs(context: ApplicationContext, paneId: string): void { + const byPane = paneSessionHandoffs.get(context); + if (!byPane) { + return; + } + const pending = byPane.get(paneId); + if (!pending) { + return; + } + for (const handoff of pending) { + discardPaneSessionHandoff(handoff); + } + byPane.delete(paneId); + if (byPane.size === 0) { + paneSessionHandoffs.delete(context); + } +} + export type ResolvedBoardView = { provider: BoardProvider; snapshot: BoardSnapshot; diff --git a/ui/src/pages/chat/chat-pane.message-cut.test.ts b/ui/src/pages/chat/chat-pane.message-cut.test.ts index 580b66642996..271586a8994f 100644 --- a/ui/src/pages/chat/chat-pane.message-cut.test.ts +++ b/ui/src/pages/chat/chat-pane.message-cut.test.ts @@ -4,6 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ApplicationContext } from "../../app/context.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; +import { consumePaneSessionHandoff } from "./chat-pane-shared.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; type TestChatPane = HTMLElement & { @@ -12,8 +13,8 @@ type TestChatPane = HTMLElement & { context: ApplicationContext; forkFromMessage: (entryId: string) => Promise; onPaneSessionChange?: (paneId: string, sessionKey: string) => void; + paneId: string; state: ChatPageHost; - switchPaneSession: (sessionKey: string) => void; }; function createSessionContext( @@ -91,21 +92,23 @@ describe("chat pane message cuts", () => { const client = {} as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions }); state.chatAttachments = [{ id: "old", mimeType: "image/jpeg", dataUrl: "data:old" }]; - pane.switchPaneSession = vi.fn((sessionKey: string) => { - state.sessionKey = sessionKey; - state.chatAttachments = []; - }); await pane.forkFromMessage("user-entry"); - expect(state.sessionKey).toBe("agent:main:forked"); + expect(state.sessionKey).toBe("agent:main:current"); expect(state.chatAttachments).toEqual([ - { - id: expect.stringMatching(/^att-/), - mimeType: "image/png", - dataUrl: "data:image/png;base64,aW1hZ2U=", - }, + { id: "old", mimeType: "image/jpeg", dataUrl: "data:old" }, ]); + expect(consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:forked")).toEqual({ + attachments: [ + { + id: expect.stringMatching(/^att-/), + mimeType: "image/png", + dataUrl: "data:image/png;base64,aW1hZ2U=", + }, + ], + draft: "edit me", + }); }); it("keeps a newer global agent selection when a message fork finishes late", async () => { diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 0dc32859690f..b898b0e4033b 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -30,6 +30,8 @@ import type { ChatMessageCache } from "./session-message-cache.ts"; export type TestChatPane = HTMLElement & { catalogMessages: unknown[]; active: boolean; + presented: boolean; + presentationId: string; chatMessagesBySession?: ChatMessageCache; chatState: { attach: (state: ChatPageHost) => void }; context: ApplicationContext; @@ -39,6 +41,7 @@ export type TestChatPane = HTMLElement & { connectedCallback: () => void; connectionGeneration: number; createSession: () => Promise; + prepareForEviction: () => void; restoreArchivedSession: (sessionKey: string) => Promise; disconnectedCallback: () => void; discardStagedAttachments?: () => void; @@ -76,7 +79,7 @@ export type TestChatPane = HTMLElement & { onPaneSessionChange?: (paneId: string, sessionKey: string) => void; paneId: string; sessionKey: string; - switchPaneSession: (nextSessionKey: string) => void; + updateComplete: Promise; deferSessionHydrationUntilTranscript: ( sessionKey: string, transcriptLoad: Promise, diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 1ceb4bace3c9..34c65c1abc04 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -511,10 +511,16 @@ describe("chat pane initialization", () => { } as unknown as ApplicationContext; pane.sessionKey = "main"; state.sessionKey = canonicalSessionKey; + state.settings = { + sessionKey: canonicalSessionKey, + lastActiveSessionKey: canonicalSessionKey, + } as ChatPageHost["settings"]; state.hello = hello; state.loadAssistantIdentity = vi.fn(async () => {}); pane.connectedClient = null; pane.onPaneSessionChange = navigate; + pane.active = true; + pane.presented = true; pane.applyGatewaySnapshot(snapshot); @@ -555,11 +561,6 @@ describe("chat pane initialization", () => { state.chatRunId = "run-reconnected"; state.chatStream = "The response survived navigation."; pane.sessionKey = canonicalSessionKey; - const switchPaneSession = vi.spyOn(pane, "switchPaneSession").mockImplementation((next) => { - state.sessionKey = next; - state.chatRunId = null; - state.chatStream = null; - }); ( pane as TestChatPane & { @@ -568,7 +569,6 @@ describe("chat pane initialization", () => { ).willUpdate(new Map([["sessionKey", "main"]])); expect(state.sessionKey).toBe(canonicalSessionKey); - expect(switchPaneSession).not.toHaveBeenCalled(); expect(state.chatRunId).toBe("run-reconnected"); expect(state.chatStream).toBe("The response survived navigation."); }); diff --git a/ui/src/pages/chat/chat-state-controller.ts b/ui/src/pages/chat/chat-state-controller.ts index 86e54384116b..fbb2e91d3d24 100644 --- a/ui/src/pages/chat/chat-state-controller.ts +++ b/ui/src/pages/chat/chat-state-controller.ts @@ -1,5 +1,4 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; -import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; import { disposeSelectedSessionMessageSubscription } from "./chat-history.ts"; import { subscribeChatOutboxProjection } from "./chat-queue.ts"; import { stopChatRealtimeTalk } from "./chat-realtime.ts"; @@ -17,12 +16,6 @@ import { import type { AfterCommitEffect, RenderLifecycle } from "./render-lifecycle.ts"; import { cancelChatScroll, scheduleCommittedChatScroll } from "./scroll.ts"; -type PendingCreatedSessionComposer = { - sessionKey: string; - chatMessage: string; - chatAttachments: ChatAttachment[]; -}; - type ChatRenderLifecycleScope = { connectionEpoch: number; cancellations: Set<() => void>; @@ -42,7 +35,6 @@ export class ChatStateController implements Reactiv private forceScrollAfterUpdate = false; private chatThreadResizeObserver: ResizeObserver | null = null; private chatThreadResizeTarget: Element | null = null; - private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null; private readonly cleanups: Array<() => void> = []; private renderLifecycleConnected = false; private renderLifecycleConnectionEpoch = 0; @@ -314,47 +306,14 @@ export class ChatStateController implements Reactiv this.composerPersistence.start(); } - persistComposerForRouteSwitch(): ChatComposerPersistResult { + persistComposerForEviction(): ChatComposerPersistResult { return this.composerPersistence.persistForRouteSwitchResult(); } - composerScopeForRouteSwitch(): StoredChatOutboxScope | null { + composerScopeForEviction(): StoredChatOutboxScope | null { return this.composerPersistence.scopeForRouteSwitch(); } - adoptComposerRoute() { - // File reads belong to their original session; abort before a late load can - // attach its payload to the pane's newly adopted route. - releaseChatMediaResourceSubscriber(this.stateValue?.requestUpdate); - this.attachmentReads.abortReads(); - this.composerPersistence.adoptCurrentRoute(); - } - - captureCreatedSessionComposer(sessionKey: string) { - const state = this.stateValue; - if (!state) { - return; - } - this.pendingCreatedSessionComposer = { - sessionKey, - chatMessage: state.chatMessage, - chatAttachments: state.chatAttachments, - }; - } - - restoreCreatedSessionComposer(sessionKey: string | null | undefined): boolean { - const state = this.stateValue; - const pending = this.pendingCreatedSessionComposer; - if (!state || !pending || pending.sessionKey !== sessionKey) { - return false; - } - this.pendingCreatedSessionComposer = null; - state.chatMessage = pending.chatMessage; - state.chatAttachments = pending.chatAttachments; - this.composerPersistence.persistNow(); - return true; - } - private stopChatEffects() { this.chatThreadResizeObserver?.disconnect(); this.chatThreadResizeObserver = null; @@ -387,6 +346,5 @@ export class ChatStateController implements Reactiv this.scrollAfterUpdate = false; this.scrollContentChangedAfterUpdate = false; this.forceScrollAfterUpdate = false; - this.pendingCreatedSessionComposer = null; } } diff --git a/ui/src/pages/chat/chat-state-route.ts b/ui/src/pages/chat/chat-state-route.ts index a5bc87fa2d79..0eb666c6c9f8 100644 --- a/ui/src/pages/chat/chat-state-route.ts +++ b/ui/src/pages/chat/chat-state-route.ts @@ -1,11 +1,9 @@ import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts"; -import { loadSettings, patchSettings } from "../../app/settings.ts"; +import { patchSettings } from "../../app/settings.ts"; import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; -import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts"; import { areUiSessionKeysEquivalent, - canonicalUiSessionKeyForPersistence, isUiGlobalSessionKey, isUiGlobalScopeConfigured, normalizeAgentId, @@ -13,44 +11,7 @@ import { resolveUiSelectedGlobalAgentId, uiSessionRowMatchesSelectedChat, } from "../../lib/sessions/session-key.ts"; -import { - resolveChatComposerMemoryFallback, - storeChatComposerMemoryFallback, -} from "./chat-composer-memory-fallback.ts"; -import { - readChatQueueForScope, - syncVisibleChatQueueProjection, - writeChatQueueForScope, -} from "./chat-queue.ts"; -import { stopChatRealtimeTalk } from "./chat-realtime.ts"; -import { refreshCurrentChatSessionList } from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; -import { invalidateImageLightbox } from "./chat-state-page.ts"; -import { cancelChatStreamRenderFrame } from "./chat-state-render.ts"; -import { - CHAT_COMPOSER_DRAFT_STORAGE_ERROR, - persistChatComposerState, - resolveStoredChatOutboxScope, - restoreChatComposerState, - storedChatOutboxScopeKey, - type ChatComposerDraftRetry, - type StoredChatOutboxScope, -} from "./composer-persistence.ts"; -import { admitInitialUserMessageHandoff } from "./history-merge.ts"; -import { admitInitialTurnHandoff } from "./initial-turn-handoff.ts"; -import { reconcileChatRunLifecycle } from "./run-lifecycle.ts"; -import { - cacheChatSessionSnapshot, - readChatSessionSnapshot, - type ChatSessionSnapshot, -} from "./session-message-cache.ts"; -import { normalizeSidebarLayout } from "./sidebar-layout.ts"; -import { clearAuthoritativeTerminal } from "./terminal-message-identity.ts"; - -type ChatComposerRouteResetResult = { - restoredFallback: boolean; - restoredStorageFailure: boolean; -}; export function canCreateChatSession(state: ChatPageHost) { return ( @@ -93,47 +54,6 @@ export function selectedChatSessionRow(state: ChatPageHost) { return row; } -function saveChatQueueForSession(state: ChatPageHost, sessionKey: string) { - const scope = resolveStoredChatOutboxScope(state, sessionKey); - writeChatQueueForScope(state, sessionKey, state.chatQueue, scope.agentId, { - requestUpdate: false, - }); -} - -function restoreChatQueueForSession(state: ChatPageHost, sessionKey: string): ChatQueueItem[] { - const scope = resolveStoredChatOutboxScope(state, sessionKey); - return readChatQueueForScope(state, sessionKey, scope.agentId); -} - -function saveChatMessagesForSession(state: ChatPageHost, sessionKey: string) { - cacheChatSessionSnapshot( - state.chatMessagesBySession, - state, - { sessionKey }, - { - ...(state.chatDisplayedLeafEntryId !== undefined - ? { displayedLeafEntryId: state.chatDisplayedLeafEntryId } - : {}), - messages: state.chatMessages, - pagination: state.chatHistoryPagination ?? { hasMore: false }, - sessionId: state.currentSessionId ?? null, - }, - ); -} - -function restoreChatMessagesForSession( - state: ChatPageHost, - sessionKey: string, -): ChatSessionSnapshot { - return ( - readChatSessionSnapshot(state.chatMessagesBySession, state, { sessionKey }) ?? { - messages: [], - pagination: { hasMore: false }, - sessionId: null, - } - ); -} - export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string) { if ( state.settings.sessionKey === sessionKey && @@ -144,156 +64,6 @@ export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string state.settings = patchSettings({ sessionKey, lastActiveSessionKey: sessionKey }); } -export function resetChatStateForRouteSession( - state: ChatPageHost, - sessionKey: string, - options: { - retainPreviousComposerInMemory?: boolean; - previousDraftRetry?: ChatComposerDraftRetry; - previousComposerScope?: StoredChatOutboxScope; - } = {}, -): ChatComposerRouteResetResult { - cancelChatStreamRenderFrame(state); - stopChatRealtimeTalk(state); - const previousSessionKey = state.sessionKey; - const previousComposerScope = - options.previousComposerScope ?? resolveStoredChatOutboxScope(state, previousSessionKey); - const previousComposerScopeKey = storedChatOutboxScopeKey(previousComposerScope); - if (options.retainPreviousComposerInMemory) { - storeChatComposerMemoryFallback(state, previousComposerScope, { - message: state.chatMessage, - attachments: state.chatAttachments, - ...(options.previousDraftRetry ? { draftRetry: options.previousDraftRetry } : {}), - }); - } else if (Object.hasOwn(state.chatComposerFallbackByScope, previousComposerScopeKey)) { - const nextFallbacks = { ...state.chatComposerFallbackByScope }; - delete nextFallbacks[previousComposerScopeKey]; - state.chatComposerFallbackByScope = nextFallbacks; - } - saveChatQueueForSession(state, previousSessionKey); - saveChatMessagesForSession(state, previousSessionKey); - const snapshot = restoreChatMessagesForSession(state, sessionKey); - state.sessionKey = sessionKey; - state.sidebarContent = null; - const sidebarSessionKey = canonicalUiSessionKeyForPersistence(state, sessionKey); - const sidebarSettings = loadSettings(); - state.sidebarLayout = normalizeSidebarLayout( - sidebarSettings.sidebarSessionLayouts?.[sidebarSessionKey], - ); - state.sidebarFocusPanelId = sidebarSettings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? ""; - state.sidebarFocusVersion += 1; - invalidateImageLightbox(state); - state.selectedChatSessionArchived = - state.sessionsResult?.sessions.some( - (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey), - ) === true; - state.currentSessionId = snapshot.sessionId; - state.chatDisplayedLeafEntryId = snapshot.displayedLeafEntryId; - state.reconnectResumeSessionId = null; - state.chatHistoryPagination = snapshot.pagination; - state.chatMessage = ""; - state.chatAttachments = []; - state.chatReplyTarget = null; - state.chatMessages = snapshot.messages; - state.chatBranches = []; - state.chatBranchesSessionKey = null; - state.chatBranchesConnectionEpoch = null; - state.chatBranchesLoading = false; - state.chatToolMessages = []; - state.chatStreamSegments = []; - state.chatThinkingLevel = null; - state.chatVerboseLevel = null; - state.chatQueueModeOverride = undefined; - state.chatEffectiveQueueMode = undefined; - state.chatStream = null; - state.observerDigest = null; - state.chatRunUsageById = new Map(); - state.chatSending = false; - state.chatSendingScopeKey = null; - state.lastError = null; - state.chatError = null; - state.chatRunError = null; - state.chatAvatarUrl = null; - state.chatAvatarSource = null; - state.chatAvatarStatus = null; - state.chatAvatarReason = null; - clearAuthoritativeTerminal(state); - state.chatQueue = restoreChatQueueForSession(state, sessionKey); - restoreChatComposerState(state); - // Composer hydration reads crash-safe queue states. Reapply the process-live - // projection without rendering through the old route's persistence owner. - // switchPaneSession requests an update only after adopting the new baseline. - syncVisibleChatQueueProjection(state, { requestUpdate: false }); - const initialTurn = admitInitialTurnHandoff(state, sessionKey); - admitInitialUserMessageHandoff(state, sessionKey); - const { fallback } = resolveChatComposerMemoryFallback(state, sessionKey); - if (fallback) { - state.chatMessage = fallback.message; - state.chatAttachments = [...fallback.attachments]; - } - const restoredStorageFailure = fallback?.storageFailed === true || initialTurn; - if (options.previousDraftRetry || restoredStorageFailure) { - state.lastError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; - state.chatError = CHAT_COMPOSER_DRAFT_STORAGE_ERROR; - } - state.resetChatInputHistoryNavigation(); - state.chatStreamStartedAt = null; - reconcileChatRunLifecycle(state, { - clearLocalRun: true, - clearChatStream: true, - clearToolStream: true, - clearRunStatus: true, - // chat-pane adopts the new composer owner before it renders. Rendering - // here would persist the hydrated target through the previous owner. - requestUpdate: false, - }); - state.resetChatScroll(); - // Deliberately no saveRouteSessionSettings here: this runs for every split - // pane, and only the active pane may write the global sessionKey / - // lastActiveSessionKey settings (chat-pane applyActiveSessionBindings). - return { - restoredFallback: Boolean(fallback), - restoredStorageFailure, - }; -} - -export function retryChatComposerMemoryFallback(state: ChatPageHost, sessionKey: string): boolean { - const { fallback, scopeKey } = resolveChatComposerMemoryFallback(state, sessionKey); - const draftRetry = fallback?.draftRetry; - if (!fallback?.storageFailed || !draftRetry) { - return false; - } - if ( - !persistChatComposerState(state, sessionKey, { - draft: fallback.message, - draftRevision: draftRetry.draftRevision, - expectedDraftRevision: draftRetry.expectedDraftRevision, - }) - ) { - return false; - } - const nextFallbacks = { ...state.chatComposerFallbackByScope }; - if (state.chatAttachments.length > 0) { - nextFallbacks[scopeKey] = { - ...fallback, - storageFailed: false, - draftRetry: undefined, - }; - } else { - delete nextFallbacks[scopeKey]; - } - state.chatComposerFallbackByScope = nextFallbacks; - if (state.chatError === CHAT_COMPOSER_DRAFT_STORAGE_ERROR) { - state.lastError = null; - state.chatError = null; - } - return true; -} - -export async function refreshRouteSessionOptions(state: ChatPageHost) { - await refreshCurrentChatSessionList(state); -} - export function resolveChatAgentId(state: ChatPageHost) { return normalizeAgentId( parseAgentSessionKey(state.sessionKey)?.agentId ?? diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index b6e92eea06ea..a12dd3d94511 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -9,20 +9,8 @@ import { replaceSlashCommands, SLASH_COMMANDS, } from "../../lib/chat/commands.ts"; -import { createStorageMock } from "../../test-helpers/storage.ts"; import { applyRemoteSlashCommandsResult } from "./chat-commands.ts"; -import { - clearChatComposerMemoryFallback, - retainChatComposerMemoryFallback, -} from "./chat-composer-memory-fallback.ts"; import { makeChatHost } from "./chat-host.test-support.ts"; -import { - admitQueuedMessageForSession, - removeQueuedMessage, - subscribeChatOutboxProjection, - updateQueuedMessageForSession, -} from "./chat-queue.ts"; -import { createInitialChatRealtimeState } from "./chat-realtime.ts"; import { ChatStateController } from "./chat-state-controller.ts"; import { handlePageGatewayEvent } from "./chat-state-events.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; @@ -32,25 +20,8 @@ import { refreshChatMetadata, refreshChatModelAuthStatus, } from "./chat-state-refresh.ts"; -import { - resetChatStateForRouteSession, - retryChatComposerMemoryFallback, - resolveChatAvatarUrl, - selectedChatSessionRow, -} from "./chat-state-route.ts"; -import { - admitStoredChatComposerQueueItem, - ChatComposerPersistence, - loadChatComposerCommittedDraftRevision, - loadChatComposerDraftRevision, - loadChatComposerSnapshot, - persistChatComposerState, - removeStoredChatComposerQueueItem, - resolveStoredChatOutboxScope, - storedChatOutboxScopeKey, -} from "./composer-persistence.ts"; +import { resolveChatAvatarUrl, selectedChatSessionRow } from "./chat-state-route.ts"; import { scheduleControlUiAfterPaint } from "./performance.ts"; -import { openSlot } from "./sidebar-layout.ts"; beforeEach(() => { vi.spyOn(assistantIdentity, "loadLocalAssistantIdentity").mockReturnValue({ @@ -1006,23 +977,6 @@ describe("ChatStateController render lifecycle", () => { expect(state.realtimeTalkCameraError).toBe(false); }); - it("aborts attachment reads when a pane adopts a different session", () => { - const host = createControllerHost(); - const controller = new ChatStateController(host); - const previousSignal = controller.attachmentReads.readSignal; - - controller.attachmentReads.updatePending(previousSignal, 1); - expect(controller.attachmentReads.pendingReads).toBe(1); - - controller.adoptComposerRoute(); - - expect(previousSignal.aborted).toBe(true); - expect(controller.attachmentReads.pendingReads).toBe(0); - expect(controller.attachmentReads.readSignal).not.toBe(previousSignal); - controller.attachmentReads.updatePending(previousSignal, 1); - expect(controller.attachmentReads.pendingReads).toBe(0); - }); - it("aborts attachment reads when a chat pane disconnects", () => { const host = createControllerHost(); const controller = new ChatStateController(host); @@ -1301,960 +1255,6 @@ describe("image lightbox lifecycle", () => { }); }); -describe("route composer fallback", () => { - function createRouteState(chatMessage: string) { - const resetChatInputHistoryNavigation = vi.fn(); - const resetChatScroll = vi.fn(); - const state = { - ...makeChatHost({ - assistantAgentId: "main", - agentsList: { defaultId: "main", mainKey: "main" }, - sessionKey: "agent:main:first", - chatMessage, - chatAttachments: [ - { - id: "staged-image", - mimeType: "image/png", - dataUrl: "data:image/png;base64,AAA", - }, - ], - }), - settings: { gatewayUrl: "ws://gateway.test/control" }, - initialUserMessage: createInitialUserMessageHandoff(), - chatMessagesBySession: new Map(), - imageLightbox: null, - imageLightboxRequestVersion: 0, - ...createInitialChatRealtimeState(), - resetChatInputHistoryNavigation, - resetChatScroll, - requestUpdate: vi.fn(), - } as unknown as ChatPageHost; - return { resetChatInputHistoryNavigation, resetChatScroll, state }; - } - - it("releases the active image lightbox on a route switch", () => { - const { state } = createRouteState(""); - const release = vi.fn(); - state.imageLightbox = { - src: "blob:managed-image", - title: "Generated image", - release, - }; - - resetChatStateForRouteSession(state, "agent:main:second"); - - expect(release).toHaveBeenCalledTimes(1); - expect(state.imageLightbox).toBeNull(); - }); - - it("retires realtime Talk before adopting the next route", () => { - const { state } = createRouteState(""); - const previousSessionKey = state.sessionKey; - const stop = vi.fn(() => { - expect(state.realtimeTalkSession).toBeNull(); - expect(state.sessionKey).toBe(previousSessionKey); - }); - state.realtimeTalkSession = { stop } as unknown as ChatPageHost["realtimeTalkSession"]; - state.realtimeTalkActive = true; - state.realtimeTalkStatus = "listening"; - state.realtimeTalkDetail = "live"; - state.realtimeTalkInputLevel.set(0.6); - state.realtimeTalkConversation = [ - { id: "utterance", role: "assistant", text: "stale", isStreaming: true }, - ]; - state.realtimeTalkVideoStream = {} as MediaStream; - state.realtimeTalkCameraDevices = [{ deviceId: "camera", label: "Camera" }]; - state.realtimeTalkVideoCapable = true; - state.realtimeTalkVideoPending = true; - state.realtimeTalkCameraError = true; - - resetChatStateForRouteSession(state, "agent:main:second"); - - expect(stop).toHaveBeenCalledOnce(); - expect(state.sessionKey).toBe("agent:main:second"); - expect(state.realtimeTalkActive).toBe(false); - expect(state.realtimeTalkStatus).toBe("idle"); - expect(state.realtimeTalkDetail).toBeNull(); - expect(state.realtimeTalkInputLevel.value).toBe(0); - expect(state.realtimeTalkConversation).toEqual([]); - expect(state.realtimeTalkVideoStream).toBeNull(); - expect(state.realtimeTalkCameraDevices).toEqual([]); - expect(state.realtimeTalkVideoCapable).toBe(false); - expect(state.realtimeTalkVideoPending).toBe(false); - expect(state.realtimeTalkCameraError).toBe(false); - }); - - it("clears transient detail content on a route switch", () => { - const { state } = createRouteState(""); - state.sidebarContent = { kind: "markdown", content: "First session detail" }; - state.sidebarLayout = openSlot({ columns: [] }, "detail"); - - resetChatStateForRouteSession(state, "agent:main:second"); - - expect(state.sidebarContent).toBeNull(); - }); - - it("restores one atomic history snapshot when returning to a session", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.chatMessages = [{ role: "assistant", content: "first session" }]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 400, totalMessages: 718 }; - state.currentSessionId = "session-first"; - state.chatDisplayedLeafEntryId = "leaf-first"; - - resetChatStateForRouteSession(state, "agent:main:second"); - state.chatMessages = [{ role: "assistant", content: "second session" }]; - state.chatHistoryPagination = { hasMore: false, totalMessages: 1 }; - state.currentSessionId = "session-second"; - state.chatDisplayedLeafEntryId = "leaf-second"; - - resetChatStateForRouteSession(state, "agent:main:first"); - - expect(state.chatMessages).toEqual([{ role: "assistant", content: "first session" }]); - expect(state.chatHistoryPagination).toEqual({ - hasMore: true, - nextOffset: 400, - totalMessages: 718, - }); - expect(state.currentSessionId).toBe("session-first"); - expect(state.chatDisplayedLeafEntryId).toBe("leaf-first"); - }); - - it("reapplies a live send projection when a subscribed pane switches into its scope", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state: owner } = createRouteState(""); - const { state: switchingPane } = createRouteState(""); - switchingPane.sessionKey = "agent:main:second"; - const item = { - id: "route-switch-live-send", - text: "send remains owned by the first pane", - createdAt: 1, - sessionKey: owner.sessionKey, - }; - owner.chatQueue = [item]; - const stopSwitchingPane = subscribeChatOutboxProjection(switchingPane); - - try { - expect(admitQueuedMessageForSession(owner, owner.sessionKey, item)).toBe(true); - expect( - updateQueuedMessageForSession(owner, owner.sessionKey, item.id, (entry) => ({ - ...entry, - sendAttempts: 1, - sendRunId: "route-switch-live-run", - sendState: "sending", - })), - ).toMatchObject({ sendState: "sending" }); - expect(loadChatComposerSnapshot(owner, owner.sessionKey)?.queue[0]?.sendState).toBe( - "waiting-reconnect", - ); - expect(switchingPane.chatQueue).toStrictEqual([]); - - resetChatStateForRouteSession(switchingPane, owner.sessionKey); - - expect(switchingPane.chatQueue).toEqual([ - expect.objectContaining({ id: item.id, sendState: "sending" }), - ]); - } finally { - removeQueuedMessage(owner, item.id); - stopSwitchingPane(); - } - }); - - it("hydrates a live target without persisting through the previous route owner", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state: owner } = createRouteState("stored target draft"); - owner.chatAttachments = []; - const item = { - id: "route-switch-persistence-owner", - text: "keep target projection live", - createdAt: 1, - sessionKey: owner.sessionKey, - }; - expect(persistChatComposerState(owner)).toBe(true); - owner.chatQueue = [item]; - expect(admitQueuedMessageForSession(owner, owner.sessionKey, item)).toBe(true); - expect( - updateQueuedMessageForSession(owner, owner.sessionKey, item.id, (entry) => ({ - ...entry, - sendAttempts: 1, - sendRunId: "route-switch-persistence-run", - sendState: "sending", - })), - ).toMatchObject({ sendState: "sending" }); - - const { state: peer } = createRouteState("stored target draft"); - peer.chatAttachments = []; - const peerPersistence = new ChatComposerPersistence(() => peer); - peerPersistence.start(); - peer.chatMessage = "newer pending peer draft"; - peerPersistence.schedule(); - - const { state: switchingPane } = createRouteState(""); - switchingPane.chatAttachments = []; - switchingPane.sessionKey = "agent:main:second"; - const previousRoutePersistence = new ChatComposerPersistence(() => switchingPane); - previousRoutePersistence.start(); - const requestUpdate = vi.fn(() => previousRoutePersistence.persistChangedState()); - switchingPane.requestUpdate = requestUpdate; - const stopSwitchingPane = subscribeChatOutboxProjection(switchingPane); - - try { - resetChatStateForRouteSession(switchingPane, owner.sessionKey); - - expect(requestUpdate).not.toHaveBeenCalled(); - expect(switchingPane.chatQueue[0]?.sendState).toBe("sending"); - peerPersistence.persistChangedState(); - expect(loadChatComposerSnapshot(owner, owner.sessionKey)?.draft).toBe( - "newer pending peer draft", - ); - } finally { - removeQueuedMessage(owner, item.id); - stopSwitchingPane(); - } - }); - - it("reapplies a running command projection when a subscribed pane switches into its scope", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state: owner } = createRouteState(""); - const { state: switchingPane } = createRouteState(""); - switchingPane.sessionKey = "agent:main:second"; - const item = { - id: "route-switch-live-command", - text: "/compact", - createdAt: 1, - localCommandArgs: "", - localCommandName: "compact", - sessionKey: owner.sessionKey, - }; - owner.chatQueue = [item]; - const stopSwitchingPane = subscribeChatOutboxProjection(switchingPane); - - try { - expect(admitQueuedMessageForSession(owner, owner.sessionKey, item)).toBe(true); - expect( - updateQueuedMessageForSession(owner, owner.sessionKey, item.id, (entry) => ({ - ...entry, - sendState: "executing-command", - })), - ).toMatchObject({ sendState: "executing-command" }); - expect(loadChatComposerSnapshot(owner, owner.sessionKey)?.queue[0]?.sendState).toBe( - "unconfirmed", - ); - expect(switchingPane.chatQueue).toStrictEqual([]); - - resetChatStateForRouteSession(switchingPane, owner.sessionKey); - - expect(switchingPane.chatQueue).toEqual([ - expect.objectContaining({ id: item.id, sendState: "executing-command" }), - ]); - } finally { - removeQueuedMessage(owner, item.id); - stopSwitchingPane(); - } - }); - - it("keeps a draft in its pane when browser persistence fails across a route switch", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { resetChatInputHistoryNavigation, resetChatScroll, state } = - createRouteState("memory-only draft"); - - expect( - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }), - ).toEqual({ restoredFallback: false, restoredStorageFailure: false }); - expect(state.chatMessage).toBe(""); - expect(state.chatAttachments).toEqual([]); - - expect(resetChatStateForRouteSession(state, "agent:main:first")).toEqual({ - restoredFallback: true, - restoredStorageFailure: true, - }); - expect(state.chatMessage).toBe("memory-only draft"); - expect(state.chatAttachments).toEqual([ - { - id: "staged-image", - mimeType: "image/png", - dataUrl: "data:image/png;base64,AAA", - }, - ]); - expect(state.chatError).toContain("remains available in this tab"); - expect(resetChatInputHistoryNavigation).toHaveBeenCalledTimes(2); - expect(resetChatScroll).toHaveBeenCalledTimes(2); - }); - - it("restores a retained command after leaving and returning to its session", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.chatAttachments = []; - const scope = resolveStoredChatOutboxScope(state, state.sessionKey); - resetChatStateForRouteSession(state, "agent:main:second"); - - expect( - retainChatComposerMemoryFallback(state, scope, { - message: "/approve approval-123 allow-once", - attachments: [ - { - id: "approval-command-attachment", - mimeType: "text/plain", - dataUrl: "data:text/plain;base64,YXBwcm92YWw=", - }, - ], - }), - ).toBeDefined(); - - expect(resetChatStateForRouteSession(state, "agent:main:first")).toEqual({ - restoredFallback: true, - restoredStorageFailure: false, - }); - expect(state.chatMessage).toBe("/approve approval-123 allow-once"); - expect(state.chatAttachments).toEqual([ - expect.objectContaining({ id: "approval-command-attachment" }), - ]); - }); - - it("preserves matching storage-failure fallback metadata", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("retry this draft"); - state.chatAttachments = []; - const scope = resolveStoredChatOutboxScope(state, state.sessionKey); - const scopeKey = storedChatOutboxScopeKey(scope); - state.chatComposerFallbackByScope = { - [scopeKey]: { - message: state.chatMessage, - attachments: [], - storageFailed: true, - draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, - sequence: 42, - }, - }; - - expect( - retainChatComposerMemoryFallback(state, scope, { - message: state.chatMessage, - attachments: [], - }), - ).toEqual({ sequence: 42 }); - expect(state.chatComposerFallbackByScope[scopeKey]).toEqual({ - message: "retry this draft", - attachments: [], - storageFailed: true, - draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, - sequence: 42, - }); - }); - - it("recovers into an empty storage-failure fallback without dropping retry metadata", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.chatAttachments = []; - const scope = resolveStoredChatOutboxScope(state, state.sessionKey); - const scopeKey = storedChatOutboxScopeKey(scope); - state.chatComposerFallbackByScope = { - [scopeKey]: { - message: "", - attachments: [], - storageFailed: true, - draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, - sequence: 43, - }, - }; - - expect( - retainChatComposerMemoryFallback(state, scope, { - message: "/approve approval-123 allow-once", - attachments: [ - { - id: "failed-clear-attachment", - mimeType: "text/plain", - }, - ], - }), - ).toEqual({ sequence: 43 }); - expect(state.chatComposerFallbackByScope[scopeKey]).toEqual({ - message: "/approve approval-123 allow-once", - attachments: [ - { - id: "failed-clear-attachment", - mimeType: "text/plain", - }, - ], - storageFailed: true, - draftRetry: { expectedDraftRevision: 4, draftRevision: 5 }, - sequence: 43, - }); - }); - - it("does not replace a newer alias-equivalent fallback", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.assistantAgentId = "work"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - const unresolvedScopeKey = storedChatOutboxScopeKey({ sessionKey: "workspace" }); - state.chatComposerFallbackByScope = { - [unresolvedScopeKey]: { - message: "newer alias draft", - attachments: [], - storageFailed: false, - sequence: 43, - }, - }; - const scope = resolveStoredChatOutboxScope(state, "agent:work:workspace"); - - expect( - retainChatComposerMemoryFallback(state, scope, { - message: "/redirect start over", - attachments: [], - }), - ).toBeUndefined(); - const resolvedScopeKey = storedChatOutboxScopeKey(scope); - expect(state.chatComposerFallbackByScope[unresolvedScopeKey]).toBeUndefined(); - expect(state.chatComposerFallbackByScope[resolvedScopeKey]?.message).toBe("newer alias draft"); - }); - - it("clears only the fallback owned by a completed retry", () => { - const { state } = createRouteState(""); - state.chatComposerFallbackByScope = { - first: { - message: "/redirect start over", - attachments: [], - storageFailed: false, - sequence: 44, - }, - second: { - message: "newer draft", - attachments: [], - storageFailed: false, - sequence: 45, - }, - }; - - expect(clearChatComposerMemoryFallback(state, { sequence: 44 })).toBe(true); - expect(state.chatComposerFallbackByScope).toEqual({ - second: { - message: "newer draft", - attachments: [], - storageFailed: false, - sequence: 45, - }, - }); - }); - - it("keeps command recovery pane-local without overwriting a newer stored draft", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.chatAttachments = []; - const scope = resolveStoredChatOutboxScope(state, state.sessionKey); - resetChatStateForRouteSession(state, "agent:main:second"); - - const { state: peer } = createRouteState("newer split-pane draft"); - peer.chatAttachments = []; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - expect( - retainChatComposerMemoryFallback(state, scope, { - message: "/redirect start over", - attachments: [], - }), - ).toBeDefined(); - - resetChatStateForRouteSession(state, "agent:main:first"); - - expect(state.chatMessage).toBe("/redirect start over"); - expect(loadChatComposerSnapshot(state, "agent:main:first")?.draft).toBe( - "newer split-pane draft", - ); - }); - - it("adopts an unresolved bare-main fallback when the default agent becomes known", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("unresolved memory draft"); - state.assistantAgentId = null; - state.agentsList = null; - state.sessionKey = "main"; - - resetChatStateForRouteSession(state, "agent:work:other", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - const unresolvedScopeKey = storedChatOutboxScopeKey({ sessionKey: "main" }); - expect(state.chatComposerFallbackByScope[unresolvedScopeKey]?.message).toBe( - "unresolved memory draft", - ); - - state.assistantAgentId = "work"; - state.agentsList = { agents: [], defaultId: "work", mainKey: "main", scope: "global" }; - expect(resetChatStateForRouteSession(state, "main")).toEqual({ - restoredFallback: true, - restoredStorageFailure: true, - }); - - const resolvedScopeKey = storedChatOutboxScopeKey(resolveStoredChatOutboxScope(state, "main")); - expect(state.chatMessage).toBe("unresolved memory draft"); - expect(state.chatAttachments).toHaveLength(1); - expect(state.chatComposerFallbackByScope[resolvedScopeKey]?.message).toBe( - "unresolved memory draft", - ); - expect(state.chatComposerFallbackByScope[unresolvedScopeKey]).toBeUndefined(); - }); - - it("keeps unresolved bare-main and raw-global fallbacks with their resolved owners", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - const unresolvedMainScopeKey = storedChatOutboxScopeKey({ sessionKey: "main" }); - const unresolvedGlobalScopeKey = storedChatOutboxScopeKey({ sessionKey: "global" }); - state.chatComposerFallbackByScope = { - [unresolvedMainScopeKey]: { - message: "default-agent fallback", - attachments: [], - storageFailed: false, - sequence: 1, - }, - [unresolvedGlobalScopeKey]: { - message: "selected-agent fallback", - attachments: [], - storageFailed: false, - sequence: 2, - }, - }; - state.assistantAgentId = "alpha"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "main", - scope: "global", - }; - - resetChatStateForRouteSession(state, "main"); - expect(state.chatMessage).toBe("default-agent fallback"); - expect(state.chatComposerFallbackByScope[unresolvedGlobalScopeKey]?.message).toBe( - "selected-agent fallback", - ); - - resetChatStateForRouteSession(state, "agent:work:other"); - resetChatStateForRouteSession(state, "global"); - expect(state.chatMessage).toBe("selected-agent fallback"); - }); - - it("adopts a failed custom-main fallback when defaults identify the alias", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("custom alias memory draft"); - state.assistantAgentId = null; - state.agentsList = null; - state.sessionKey = "workspace"; - - resetChatStateForRouteSession(state, "agent:work:other", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - const customAliasScopeKey = storedChatOutboxScopeKey({ sessionKey: "workspace" }); - expect(state.chatComposerFallbackByScope[customAliasScopeKey]?.message).toBe( - "custom alias memory draft", - ); - - state.assistantAgentId = "alpha"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - expect(resetChatStateForRouteSession(state, "agent:work:workspace")).toEqual({ - restoredFallback: true, - restoredStorageFailure: true, - }); - - const resolvedScopeKey = storedChatOutboxScopeKey({ agentId: "work", sessionKey: "global" }); - expect(state.chatMessage).toBe("custom alias memory draft"); - expect(state.chatAttachments).toHaveLength(1); - expect(state.chatComposerFallbackByScope[customAliasScopeKey]).toBeUndefined(); - expect(state.chatComposerFallbackByScope[resolvedScopeKey]?.message).toBe( - "custom alias memory draft", - ); - expect(retryChatComposerMemoryFallback(state, "agent:work:workspace")).toBe(true); - expect(loadChatComposerSnapshot(state, "agent:work:workspace")?.draft).toBe( - "custom alias memory draft", - ); - }); - - it("adopts a qualified custom-main fallback when defaults identify the alias", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("qualified alias memory draft"); - state.assistantAgentId = null; - state.agentsList = null; - state.sessionKey = "agent:work:workspace"; - - resetChatStateForRouteSession(state, "agent:alpha:other", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - const qualifiedScopeKey = storedChatOutboxScopeKey({ - agentId: "work", - sessionKey: "agent:work:workspace", - }); - expect(state.chatComposerFallbackByScope[qualifiedScopeKey]?.message).toBe( - "qualified alias memory draft", - ); - - state.assistantAgentId = "alpha"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - expect(resetChatStateForRouteSession(state, "agent:work:workspace")).toEqual({ - restoredFallback: true, - restoredStorageFailure: true, - }); - - const resolvedScopeKey = storedChatOutboxScopeKey({ agentId: "work", sessionKey: "global" }); - expect(state.chatMessage).toBe("qualified alias memory draft"); - expect(state.chatAttachments).toHaveLength(1); - expect(state.chatComposerFallbackByScope[qualifiedScopeKey]).toBeUndefined(); - expect(state.chatComposerFallbackByScope[resolvedScopeKey]?.message).toBe( - "qualified alias memory draft", - ); - expect(retryChatComposerMemoryFallback(state, "agent:work:workspace")).toBe(true); - expect(loadChatComposerSnapshot(state, "agent:work:workspace")?.draft).toBe( - "qualified alias memory draft", - ); - }); - - it("keeps custom and unresolved fallbacks with their distinct agents", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - const customAliasScopeKey = storedChatOutboxScopeKey({ sessionKey: "workspace" }); - const unresolvedScopeKey = storedChatOutboxScopeKey({ sessionKey: "global" }); - state.chatComposerFallbackByScope = { - [customAliasScopeKey]: { - message: "default-agent fallback", - attachments: [], - storageFailed: false, - sequence: 1, - }, - [unresolvedScopeKey]: { - message: "selected-agent fallback", - attachments: [], - storageFailed: false, - sequence: 2, - }, - }; - state.assistantAgentId = "alpha"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - - resetChatStateForRouteSession(state, "agent:work:workspace"); - expect(state.chatMessage).toBe("default-agent fallback"); - expect(state.chatComposerFallbackByScope[unresolvedScopeKey]?.message).toBe( - "selected-agent fallback", - ); - - resetChatStateForRouteSession(state, "agent:work:other"); - resetChatStateForRouteSession(state, "global"); - expect(state.chatMessage).toBe("selected-agent fallback"); - }); - - it("keeps only the newest failed fallback when aliases converge", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("older unresolved draft"); - state.assistantAgentId = null; - state.agentsList = null; - state.chatAttachments = []; - state.sessionKey = "main"; - resetChatStateForRouteSession(state, "workspace", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - - state.chatMessage = "newer custom-alias draft"; - resetChatStateForRouteSession(state, "agent:work:other", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 2 }, - }); - state.assistantAgentId = "work"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - - expect(resetChatStateForRouteSession(state, "global")).toEqual({ - restoredFallback: true, - restoredStorageFailure: true, - }); - expect(state.chatMessage).toBe("newer custom-alias draft"); - expect(retryChatComposerMemoryFallback(state, "global")).toBe(true); - expect(state.chatComposerFallbackByScope).toEqual({}); - - resetChatStateForRouteSession(state, "agent:work:other"); - resetChatStateForRouteSession(state, "global"); - expect(state.chatMessage).toBe("newer custom-alias draft"); - }); - - it("keeps only the newest attachment fallback when aliases converge", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - state.assistantAgentId = null; - state.agentsList = null; - state.sessionKey = "main"; - resetChatStateForRouteSession(state, "workspace", { - retainPreviousComposerInMemory: true, - }); - - state.chatAttachments = [ - { - id: "newer-custom-attachment", - mimeType: "image/png", - dataUrl: "data:image/png;base64,BBB", - }, - ]; - resetChatStateForRouteSession(state, "agent:work:other", { - retainPreviousComposerInMemory: true, - }); - state.assistantAgentId = "work"; - state.agentsList = { - agents: [], - defaultId: "work", - mainKey: "workspace", - scope: "global", - }; - - expect(resetChatStateForRouteSession(state, "global")).toEqual({ - restoredFallback: true, - restoredStorageFailure: false, - }); - expect(state.chatAttachments).toEqual([ - { - id: "newer-custom-attachment", - mimeType: "image/png", - dataUrl: "data:image/png;base64,BBB", - }, - ]); - const resolvedScopeKey = storedChatOutboxScopeKey({ agentId: "work", sessionKey: "global" }); - expect(Object.keys(state.chatComposerFallbackByScope)).toEqual([resolvedScopeKey]); - }); - - it("rebases a newer unresolved draft retry onto the selected agent revision", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state: resolved } = createRouteState("prior work draft"); - resolved.assistantAgentId = "work"; - resolved.agentsList = { agents: [], defaultId: "work", mainKey: "main", scope: "global" }; - resolved.sessionKey = "main"; - resolved.chatAttachments = []; - expect(persistChatComposerState(resolved)).toBe(true); - const committedRevision = loadChatComposerCommittedDraftRevision(resolved, "main"); - - const { state } = createRouteState("new unresolved draft"); - state.assistantAgentId = null; - state.agentsList = null; - state.sessionKey = "main"; - state.chatAttachments = []; - resetChatStateForRouteSession(state, "agent:work:other", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { - expectedDraftRevision: 0, - draftRevision: committedRevision + 1, - }, - }); - - state.assistantAgentId = "work"; - state.agentsList = { agents: [], defaultId: "work", mainKey: "main", scope: "global" }; - resetChatStateForRouteSession(state, "main"); - - expect(retryChatComposerMemoryFallback(state, "main")).toBe(true); - expect(loadChatComposerSnapshot(state, "main")?.draft).toBe("new unresolved draft"); - expect(state.chatComposerFallbackByScope).toEqual({}); - }); - - it("keeps staged attachments in the pane without reporting a storage failure", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState(""); - - expect( - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - }), - ).toEqual({ restoredFallback: false, restoredStorageFailure: false }); - expect(state.chatError).toBeNull(); - - expect(resetChatStateForRouteSession(state, "agent:main:first")).toEqual({ - restoredFallback: true, - restoredStorageFailure: false, - }); - expect(state.chatMessage).toBe(""); - expect(state.chatAttachments).toHaveLength(1); - expect(state.chatError).toBeNull(); - }); - - it("retries a failed draft after storage recovers", () => { - const storage = createStorageMock(); - const write = storage.setItem.bind(storage); - let storageAvailable = false; - vi.spyOn(storage, "setItem").mockImplementation((key, value) => { - if (!storageAvailable) { - throw new DOMException("quota exceeded", "QuotaExceededError"); - } - write(key, value); - }); - vi.stubGlobal("sessionStorage", storage); - const { state } = createRouteState("retry this draft"); - state.chatAttachments = []; - expect( - persistChatComposerState(state, "agent:main:first", { - draft: state.chatMessage, - expectedDraftRevision: 0, - draftRevision: 1, - }), - ).toBe(false); - - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - resetChatStateForRouteSession(state, "agent:main:first"); - storageAvailable = true; - - expect(retryChatComposerMemoryFallback(state, "agent:main:first")).toBe(true); - expect(loadChatComposerSnapshot(state, "agent:main:first")?.draft).toBe("retry this draft"); - expect(state.chatComposerFallbackByScope).toEqual({}); - expect(state.chatError).toBeNull(); - }); - - it("does not overwrite a newer split-pane draft while retrying", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("pane A draft"); - state.chatAttachments = []; - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - - const { state: peer } = createRouteState("newer pane B draft"); - peer.chatAttachments = []; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - - resetChatStateForRouteSession(state, "agent:main:first"); - expect(retryChatComposerMemoryFallback(state, "agent:main:first")).toBe(false); - expect(loadChatComposerSnapshot(state, "agent:main:first")?.draft).toBe("newer pane B draft"); - expect(state.chatMessage).toBe("pane A draft"); - expect(state.chatError).toContain("remains available in this tab"); - }); - - it("keeps a stale-revision conflict pane-local instead of retrying it as storage failure", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("older pane A draft"); - state.chatAttachments = []; - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - }); - - const { state: peer } = createRouteState("newer pane B draft"); - peer.chatAttachments = []; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - - resetChatStateForRouteSession(state, "agent:main:first"); - expect(retryChatComposerMemoryFallback(state, "agent:main:first")).toBe(false); - expect(loadChatComposerSnapshot(state, "agent:main:first")?.draft).toBe("newer pane B draft"); - expect(state.chatMessage).toBe("older pane A draft"); - expect(state.chatError).toBeNull(); - }); - - it("does not resurrect a stale fallback after a newer pane clears the draft", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - const { state } = createRouteState("stale pane A draft"); - state.chatAttachments = []; - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - - const { state: peer } = createRouteState("newer pane B draft"); - peer.chatAttachments = []; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - peer.chatMessage = ""; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - expect(loadChatComposerSnapshot(peer, "agent:main:first")).toBeNull(); - - resetChatStateForRouteSession(state, "agent:main:first"); - expect(retryChatComposerMemoryFallback(state, "agent:main:first")).toBe(false); - expect(loadChatComposerSnapshot(state, "agent:main:first")).toBeNull(); - expect(state.chatMessage).toBe("stale pane A draft"); - }); - - it("does not resurrect a stale fallback after its clear tombstone is pruned", () => { - vi.stubGlobal("sessionStorage", createStorageMock()); - let now = 1_000; - vi.spyOn(Date, "now").mockImplementation(() => ++now); - const { state } = createRouteState("stale pane A draft"); - state.chatAttachments = []; - resetChatStateForRouteSession(state, "agent:main:second", { - retainPreviousComposerInMemory: true, - previousDraftRetry: { expectedDraftRevision: 0, draftRevision: 1 }, - }); - - const { state: peer } = createRouteState("newer pane B draft"); - peer.chatAttachments = []; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - peer.chatMessage = ""; - expect(persistChatComposerState(peer, "agent:main:first")).toBe(true); - const clearRevision = loadChatComposerDraftRevision(peer, "agent:main:first"); - - const queued = Array.from({ length: 20 }, (_, index) => { - const sessionKey = `agent:main:queued-${index}`; - const item = { - id: `queued-${index}`, - text: `queued message ${index}`, - createdAt: index, - sendAttempts: 0, - sendRunId: `queued-run-${index}`, - sendState: "waiting-reconnect" as const, - sessionKey, - agentId: "main", - }; - const { state: queueState } = createRouteState(""); - queueState.chatAttachments = []; - queueState.sessionKey = sessionKey; - expect(admitStoredChatComposerQueueItem(queueState, sessionKey, item)).toBe(true); - return { item, queueState, sessionKey }; - }); - - expect(loadChatComposerSnapshot(peer, "agent:main:first")).toBeNull(); - expect(loadChatComposerDraftRevision(peer, "agent:main:first")).toBe(clearRevision); - - for (let index = 0; index < 20; index += 1) { - const { state: clearState } = createRouteState(""); - clearState.chatAttachments = []; - clearState.sessionKey = `agent:main:clear-fence-${index}`; - expect(persistChatComposerState(clearState)).toBe(true); - } - const storageKey = sessionStorage.key(0); - expect(storageKey).not.toBeNull(); - expect(sessionStorage.getItem(storageKey!)).not.toContain("agent:main:first"); - - for (const { item, queueState, sessionKey } of queued) { - expect(removeStoredChatComposerQueueItem(queueState, sessionKey, item.id, item)).toBe(true); - } - - expect(loadChatComposerSnapshot(peer, "agent:main:first")).toBeNull(); - expect(loadChatComposerDraftRevision(peer, "agent:main:first")).toBe(clearRevision); - resetChatStateForRouteSession(state, "agent:main:first"); - expect(retryChatComposerMemoryFallback(state, "agent:main:first")).toBe(false); - expect(loadChatComposerSnapshot(state, "agent:main:first")).toBeNull(); - expect(state.chatMessage).toBe("stale pane A draft"); - }); -}); - describe("resolveChatAvatarUrl", () => { it("prefers the authenticated avatar blob over persisted and protected URLs", () => { const state = { diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index e97a3a6f5403..5cc11cb844eb 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -4563,7 +4563,10 @@ describe("grouped chat rendering", () => { }, ); const imageBlob = new Blob(["png"], { type: "image/png" }); - const fetchMock = vi.fn(async () => ({ ok: true, blob: async () => imageBlob })); + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => ({ + ok: true, + blob: async () => imageBlob, + })); vi.stubGlobal("fetch", fetchMock); let copiedBlob: Blob | undefined; class ClipboardItemMock { diff --git a/ui/src/pages/chat/components/chat-thread.measure.test.ts b/ui/src/pages/chat/components/chat-thread.measure.test.ts index 0295661208de..42d203c31702 100644 --- a/ui/src/pages/chat/components/chat-thread.measure.test.ts +++ b/ui/src/pages/chat/components/chat-thread.measure.test.ts @@ -337,34 +337,6 @@ describe("chat transcript row measurement", () => { expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true); }); - it("keeps an unsettled restored offset with its cached session host", () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const messages = Array.from({ length: 20 }, (_, index) => ({ - role: index % 2 === 0 ? "user" : "assistant", - content: `message ${index}`, - timestamp: index, - })); - const renderSession = (sessionKey: string) => { - render( - renderChatThread(threadProps("pane-pending-scroll", sessionKey, messages), transcript), - container, - ); - }; - renderSession("agent:main:session-a"); - transcript.hostConnected(); - transcript.hostUpdated(); - transcript.scrollToOffset(420); - expect(transcript.pendingScrollOffsetFor("agent:main:session-a")).toBe(420); - - renderSession("agent:main:session-b"); - transcript.hostUpdated(); - expect(transcript.pendingScrollOffsetFor("agent:main:session-b")).toBeNull(); - - renderSession("agent:main:session-a"); - expect(transcript.pendingScrollOffsetFor("agent:main:session-a")).toBe(420); - }); - it("pauses an unmeasurable restore until loading commits an empty transcript", () => { const transcript = createTestTranscript(); const container = document.body.appendChild(document.createElement("div")); @@ -407,61 +379,6 @@ describe("chat transcript row measurement", () => { expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull(); }); - it("reuses measured hosts, remeasures width changes, and tears down evictions", async () => { - const transcript = createTestTranscript(); - const container = document.body.appendChild(document.createElement("div")); - const renderSession = async (sessionKey: string) => { - render(renderChatThread(threadProps("pane-host-cache", sessionKey), transcript), container); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - }; - await renderSession("agent:main:session-a"); - transcript.hostConnected(); - transcript.hostUpdated(); - await flushDeferredRowPrune(); - - type VirtualizerInternals = { - itemSizeCache: Map; - measure: () => void; - }; - type SessionHostInternals = { - connected: boolean; - measureRowRefs: Map; - virtualizerController: { getVirtualizer: () => VirtualizerInternals }; - }; - const controllerInternals = transcript as unknown as { - sessionVirtualizers: Map; - }; - const hostA = controllerInternals.sessionVirtualizers.get("agent:main:session-a"); - expect(hostA).toBeDefined(); - const virtualizerA = hostA?.virtualizerController.getVirtualizer(); - expect(virtualizerA?.itemSizeCache.size).toBeGreaterThan(0); - const measuredSizes = new Map(virtualizerA?.itemSizeCache); - - await renderSession("agent:main:session-b"); - await renderSession("agent:main:session-a"); - expect(controllerInternals.sessionVirtualizers.get("agent:main:session-a")).toBe(hostA); - expect(virtualizerA?.itemSizeCache).toEqual(measuredSizes); - - const measure = vi.spyOn(virtualizerA as VirtualizerInternals, "measure"); - for (const observer of resizeObservers) { - observer.emit(640, 600); - } - expect(measure).toHaveBeenCalled(); - - await renderSession("agent:main:session-c"); - await renderSession("agent:main:session-d"); - expect(controllerInternals.sessionVirtualizers.size).toBe(3); - expect(controllerInternals.sessionVirtualizers.has("agent:main:session-b")).toBe(false); - expect(hostA?.connected).toBe(false); - - await renderSession("agent:main:session-e"); - expect(controllerInternals.sessionVirtualizers.has("agent:main:session-a")).toBe(false); - expect(hostA?.measureRowRefs.size).toBe(0); - transcript.hostDisconnected(); - expect(observedElements.size).toBe(0); - }); - it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => { const transcript = createTestTranscript(); const container = document.body.appendChild(document.createElement("div")); @@ -571,9 +488,10 @@ describe("chat transcript row measurement", () => { transcript.hostUpdated(); await flushDeferredRowPrune(); + const thumbnailSource = source.replace(/\/full$/u, "/thumbnail"); const previousResource = observeChatMediaResource( "managed-image", - `${source.replace(/\/full$/u, "/thumbnail")}::old-token::`, + `${thumbnailSource}::old-token::`, ); expect(fetchMock).toHaveBeenCalledTimes(1); expect(previousResource.subscribers.size).toBe(1); @@ -593,7 +511,7 @@ describe("chat transcript row measurement", () => { const nextResource = observeChatMediaResource( "managed-image", - `${source.replace(/\/full$/u, "/thumbnail")}::next-token::`, + `${thumbnailSource}::next-token::`, ); expect(fetchMock).toHaveBeenCalledTimes(2); expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe( diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 830f937348ca..556e813ca00c 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -211,10 +211,6 @@ const CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES = 12; // A committed short transcript can legitimately remain at maxOffset=0. Give // initial measurement one second before treating that zero range as final. const CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES = 60; -// Keep the active transcript plus two recent sessions. Eviction always tears -// down observers first; otherwise a discarded host would leak row observers. -const CHAT_TRANSCRIPT_VIRTUALIZER_CACHE_LIMIT = 3; - function initialTranscriptRect(host: ReactiveControllerHost) { const width = host instanceof HTMLElement ? host.clientWidth : 0; const height = host instanceof HTMLElement ? host.clientHeight : 0; @@ -698,7 +694,6 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost { export class ChatTranscriptController implements ReactiveController { private activeSessionKey: string | null = null; private sessionVirtualizer: ChatSessionVirtualizerHost | null = null; - private readonly sessionVirtualizers = new Map(); private connected = false; constructor(private readonly host: ReactiveControllerHost) { @@ -715,37 +710,19 @@ export class ChatTranscriptController implements ReactiveController { this.activeSessionKey === null || !areUiSessionKeysEquivalent(this.activeSessionKey, props.sessionKey) ) { - this.sessionVirtualizer?.disconnect(); - let cachedKey: string | null = null; - let nextVirtualizer: ChatSessionVirtualizerHost | null = null; - for (const [sessionKey, virtualizer] of this.sessionVirtualizers) { - if (areUiSessionKeysEquivalent(sessionKey, props.sessionKey)) { - cachedKey = sessionKey; - nextVirtualizer = virtualizer; - break; - } - } - if (cachedKey !== null && nextVirtualizer) { - this.sessionVirtualizers.delete(cachedKey); - } else { - const savedPosition = getChatSessionScrollPosition(props.paneId, props.sessionKey); - const initialOffset = savedPosition?.anchorToEnd - ? null - : (savedPosition?.scrollTop ?? null); - nextVirtualizer = new ChatSessionVirtualizerHost( - this.host, - initialOffset, - initialOffset === null - ? undefined - : (position) => { - saveChatSessionScrollPosition(props.paneId, props.sessionKey, position); - }, - ); - } + this.sessionVirtualizer?.dispose(); + const savedPosition = getChatSessionScrollPosition(props.paneId, props.sessionKey); + const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? null); this.activeSessionKey = props.sessionKey; - this.sessionVirtualizer = nextVirtualizer; - this.sessionVirtualizers.set(props.sessionKey, nextVirtualizer); - this.evictInactiveVirtualizers(); + this.sessionVirtualizer = new ChatSessionVirtualizerHost( + this.host, + initialOffset, + initialOffset === null + ? undefined + : (position) => { + saveChatSessionScrollPosition(props.paneId, props.sessionKey, position); + }, + ); if (this.connected) { this.sessionVirtualizer.connect(); } @@ -787,23 +764,7 @@ export class ChatTranscriptController implements ReactiveController { hostDisconnected(): void { this.connected = false; - for (const virtualizer of this.sessionVirtualizers.values()) { - virtualizer.disconnect(); - } - } - - private evictInactiveVirtualizers(): void { - while (this.sessionVirtualizers.size > CHAT_TRANSCRIPT_VIRTUALIZER_CACHE_LIMIT) { - const oldest = this.sessionVirtualizers.entries().next().value as - | [string, ChatSessionVirtualizerHost] - | undefined; - if (!oldest) { - return; - } - const [sessionKey, virtualizer] = oldest; - this.sessionVirtualizers.delete(sessionKey); - virtualizer.dispose(); - } + this.sessionVirtualizer?.disconnect(); } } @@ -1892,7 +1853,7 @@ function renderChatThreadContents( ${transcript.liveAnnouncementText} diff --git a/ui/src/pages/chat/route-draft-focus-handoff.ts b/ui/src/pages/chat/route-draft-focus-handoff.ts index 2033df4b377d..24493c1462f9 100644 --- a/ui/src/pages/chat/route-draft-focus-handoff.ts +++ b/ui/src/pages/chat/route-draft-focus-handoff.ts @@ -14,7 +14,10 @@ type PendingHandoff = { export type ChatPaneElement = HTMLElement & { active?: boolean; + discardStagedAttachments?: () => void; paneId?: string; + prepareForEviction?: () => void; + presented?: boolean; sessionKey?: string; }; diff --git a/ui/src/pages/chat/route.ts b/ui/src/pages/chat/route.ts index 2814b84463db..5b0cda31b59d 100644 --- a/ui/src/pages/chat/route.ts +++ b/ui/src/pages/chat/route.ts @@ -2,23 +2,13 @@ import type { RouteLocation, RouteMatch } from "@openclaw/uirouter"; import { definePage } from "@openclaw/uirouter"; import { html, nothing } from "lit"; import { INTERNAL_SESSION_PATH_PARAM, pathForRoute, routePageSpec } from "../../app-route-paths.ts"; -import { sessionRefFromPath } from "../../app-session-route-paths.ts"; -import { resolveControlUiBasePath } from "../../app/browser.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { t } from "../../i18n/index.ts"; import type { BoardFace } from "../../lib/board/settings.ts"; -import { - buildCatalogSessionKey, - catalogSessionKeyFromSearch, -} from "../../lib/sessions/catalog-key.ts"; -import { - SESSION_FACE_PREFERENCE_PARAM, - SESSION_NAVIGATION_KEY_PARAM, -} from "../../lib/sessions/route-navigation.ts"; -import { locationWithoutDraft } from "./route-draft.ts"; import type { ChatRouteData } from "./route-loader.ts"; -type SessionOwnerMatch = Pick; +type SessionOwnerMatch = Pick; +const CHAT_PAGE_OWNER_KEY = "chat-page"; function renderAmbiguous(data: Extract) { return html` @@ -63,109 +53,9 @@ function sessionLoaderDeps( }`; } -function sessionOwnerKey(sessionKey: string): string { - return `chat-session:${sessionKey}`; -} - -function sessionTargetFromLocation(location: RouteLocation) { - const internalPath = new URLSearchParams(location.search).get(INTERNAL_SESSION_PATH_PARAM); - const pathname = internalPath ?? location.pathname; - return sessionRefFromPath(pathname, resolveControlUiBasePath(pathname)); -} - -function locationWithoutOwnerHints(location: RouteLocation): RouteLocation { - const withoutDraft = locationWithoutDraft(location); - const search = new URLSearchParams(withoutDraft.search); - search.delete(SESSION_FACE_PREFERENCE_PARAM); - search.delete(SESSION_NAVIGATION_KEY_PARAM); - const serialized = search.toString(); - return { ...withoutDraft, search: serialized ? `?${serialized}` : "" }; -} - -function routeLocationsEqual(left: RouteLocation, right: RouteLocation): boolean { - return ( - left.pathname === right.pathname && left.search === right.search && left.hash === right.hash - ); -} - -function sessionTargetsEqual( - left: ReturnType, - right: ReturnType, -): boolean { - if (!left || !right || left.agentId !== right.agentId || left.kind !== right.kind) { - return false; - } - if (left.kind === "main" && right.kind === "main") { - return true; - } - if (left.kind === "literal" && right.kind === "literal") { - return left.sessionKey === right.sessionKey && left.slugCandidate === right.slugCandidate; - } - return ( - left.kind === "short" && - right.kind === "short" && - left.shortId === right.shortId && - left.slugHint === right.slugHint - ); -} - -function settledSessionOwnerKey( - pending: SessionOwnerMatch, - settled: SessionOwnerMatch | undefined, -): string | undefined { - const settledData = settled?.data as ChatRouteData | undefined; - if (!settled || settledData?.kind !== "session") { - return undefined; - } - const canonical = settledData.canonicalLocation; - if ( - canonical && - routeLocationsEqual( - locationWithoutOwnerHints(pending.location), - locationWithoutOwnerHints(canonical), - ) - ) { - return sessionOwnerKey(settledData.sessionKey); - } - return sessionTargetsEqual( - sessionTargetFromLocation(pending.location), - sessionTargetFromLocation(settled.location), - ) - ? sessionOwnerKey(settledData.sessionKey) - : undefined; -} - -function sessionRenderOwnerKey( - face: BoardFace, - match: SessionOwnerMatch, - settled: SessionOwnerMatch | undefined, -): string | undefined { +function sessionRenderOwnerKey(match: SessionOwnerMatch): string | undefined { const data = match.data as ChatRouteData | undefined; - if (data?.kind === "ambiguous") { - return undefined; - } - if (data?.kind === "session") { - return sessionOwnerKey(data.sessionKey); - } - const search = new URLSearchParams(match.location.search); - const catalogKey = catalogSessionKeyFromSearch(match.location.search); - if (catalogKey) { - return sessionOwnerKey(buildCatalogSessionKey(catalogKey)); - } - const navigationKey = search.get(SESSION_NAVIGATION_KEY_PARAM)?.trim(); - if (navigationKey) { - return sessionOwnerKey(navigationKey); - } - const target = sessionTargetFromLocation(match.location); - if (target?.namespace !== face) { - return undefined; - } - if (target.kind === "literal" && target.slugCandidate === undefined) { - return sessionOwnerKey(target.sessionKey); - } - // Unresolved short and slug routes borrow identity only from the exact route - // that settled them; path resemblance alone cannot identify a session. - return settledSessionOwnerKey(match, settled); + return data?.kind === "ambiguous" ? undefined : CHAT_PAGE_OWNER_KEY; } function sessionPage(face: BoardFace) { @@ -182,10 +72,9 @@ function sessionPage(face: BoardFace) { component: () => import("./chat-page.ts").then(() => ({ header: true, - // ChatPage owns pane/session teardown. The route namespace only changes - // presentation, so it must not preempt that owner during face switches. - renderOwnerKey: (match: SessionOwnerMatch, settled?: SessionOwnerMatch) => - sessionRenderOwnerKey(face, match, settled), + // ChatPage's bounded inner cache owns per-session teardown, so session + // routes share the outer owner while their data and URL keep changing. + renderOwnerKey: sessionRenderOwnerKey, render: (data: unknown) => { const routeData = data as ChatRouteData | undefined; if (!routeData) { diff --git a/ui/src/pages/chat/scroll.test.ts b/ui/src/pages/chat/scroll.test.ts index 17bce05a77ea..f61a0ae69440 100644 --- a/ui/src/pages/chat/scroll.test.ts +++ b/ui/src/pages/chat/scroll.test.ts @@ -7,7 +7,6 @@ import { getChatSessionScrollPosition, handleChatScroll, resetChatScroll, - restoreChatScroll, saveChatSessionScrollPosition, scheduleChatScroll, } from "./scroll.ts"; @@ -119,9 +118,12 @@ describe("handleChatScroll", () => { it("sets chatUserNearBottom=false when scrolled well above threshold", () => { const { host } = createScrollHost({}); // distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold + host.chatLastScrollTop = 1600; const event = createScrollEvent(2000, 500, 400); handleChatScroll(host, event); expect(host.chatUserNearBottom).toBe(false); + expect(host.chatFollowLocked).toBe(true); + expect(host.chatHasAutoScrolled).toBe(true); }); it("shows the scroll-to-bottom affordance only beyond the shared end boundary", () => { @@ -309,54 +311,6 @@ describe("scheduleChatScroll", () => { expect(container.scrollTop).toBe(container.scrollHeight); }); - it("restores a session viewport and does not force-jump when new messages arrive", async () => { - const { host, container } = createScrollHost({ - scrollHeight: 2400, - scrollTop: 2000, - clientHeight: 400, - }); - - expect(restoreChatScroll(host, container as unknown as HTMLElement, 1500)).toBe(1500); - expect(host.chatHasAutoScrolled).toBe(true); - expect(host.chatFollowLocked).toBe(true); - expect(host.chatNewMessagesBelow).toBe(true); - - container.scrollHeight = 2600; - scheduleChatScroll(host, true); - await host.updateComplete; - - expect(container.scrollTop).toBe(1500); - expect(host.chatNewMessagesBelow).toBe(true); - }); - - it("locks a restored virtual viewport before its scroll height is measurable", () => { - const { host, container } = createScrollHost({ - scrollHeight: 0, - scrollTop: 0, - clientHeight: 400, - }); - - expect(restoreChatScroll(host, container as unknown as HTMLElement, 600)).toBe(0); - expect(host.chatFollowLocked).toBe(true); - expect(host.chatNewMessagesBelow).toBe(true); - - saveChatSessionScrollPosition("settled-pane", "settled-session", { - scrollTop: 600, - anchorToEnd: false, - }); - expect(restoreChatScroll(host, container as unknown as HTMLElement, 0)).toBe(0); - saveChatSessionScrollPosition("settled-pane", "settled-session", { - scrollTop: 0, - anchorToEnd: true, - }); - expect(getChatSessionScrollPosition("settled-pane", "settled-session")).toEqual({ - scrollTop: 0, - anchorToEnd: true, - }); - expect(host.chatFollowLocked).toBe(false); - expect(host.chatNewMessagesBelow).toBe(false); - }); - it("keeps only the newest equivalent session-key scroll position", () => { saveChatSessionScrollPosition("alias-pane", "main", { scrollTop: 100, diff --git a/ui/src/pages/chat/scroll.ts b/ui/src/pages/chat/scroll.ts index 4d26ffc174bb..a1374b070740 100644 --- a/ui/src/pages/chat/scroll.ts +++ b/ui/src/pages/chat/scroll.ts @@ -288,6 +288,9 @@ export function handleChatScroll(host: ChatScrollHost, event: Event): void { } const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (isUserScrollUp && distanceFromBottom > CHAT_TRANSCRIPT_END_THRESHOLD_PX) { + // Taking control before initial history settles must retire its queued + // force-scroll. Otherwise that delayed commit can overwrite the viewport. + host.chatHasAutoScrolled = true; host.chatFollowLocked = true; } else if (distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX) { host.chatFollowLocked = false; @@ -301,30 +304,6 @@ export function handleChatScroll(host: ChatScrollHost, event: Event): void { ); } -export function restoreChatScroll( - host: ChatScrollHost, - target: HTMLElement, - scrollTop: number, -): number { - cancelChatScroll(host); - const maxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight); - target.scrollTop = Math.min(Math.max(0, scrollTop), maxScrollTop); - const restoredScrollTop = target.scrollTop; - const distanceFromBottom = maxScrollTop - restoredScrollTop; - host.chatLastScrollTop = restoredScrollTop; - host.chatLastScrollHeight = target.scrollHeight; - host.chatHasAutoScrolled = true; - // A virtualized transcript may not expose its final scroll height yet. Keep - // the restored viewport locked until the requested offset becomes reachable. - host.chatFollowLocked = - scrollTop > maxScrollTop || distanceFromBottom > CHAT_TRANSCRIPT_END_THRESHOLD_PX; - host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD; - host.chatIsProgrammaticScroll = false; - host.chatProgrammaticScrollTarget = restoredScrollTop; - setNewMessagesBelow(host, host.chatFollowLocked); - return restoredScrollTop; -} - export function resetChatScroll(host: ChatScrollHost): void { cancelChatScroll(host); host.chatHasAutoScrolled = false; diff --git a/ui/src/pages/chat/session-message-cache.test.ts b/ui/src/pages/chat/session-message-cache.test.ts index f46eca8eb9b0..fba65f7fa42d 100644 --- a/ui/src/pages/chat/session-message-cache.test.ts +++ b/ui/src/pages/chat/session-message-cache.test.ts @@ -150,17 +150,23 @@ describe("session message cache", () => { it("restores messages, pagination, and backing session identity together", () => { const { host, cache } = createCacheContext(); + const messages = ["oldest", "latest"]; + const pagination = { hasMore: true as const, nextOffset: 400, totalMessages: 718 }; cacheHomeSnapshot(cache, host, { - messages: ["oldest", "latest"], - pagination: { hasMore: true, nextOffset: 400, totalMessages: 718 }, + messages, + pagination, sessionId: "session-1", }); - expect(readChatSessionSnapshot(cache, host, { sessionKey: "home" })).toEqual({ + const snapshot = readChatSessionSnapshot(cache, host, { sessionKey: "home" }); + expect(snapshot).toEqual({ messages: ["oldest", "latest"], pagination: { hasMore: true, nextOffset: 400, totalMessages: 718 }, sessionId: "session-1", }); + expect(snapshot?.messages).toBe(messages); + expect(snapshot?.pagination).toBe(pagination); + expect(readChatSessionSnapshot(cache, host, { sessionKey: "home" })).toBe(snapshot); }); it("appends an inactive-session message without losing snapshot metadata", () => { @@ -180,58 +186,15 @@ describe("session message cache", () => { }); }); - it("keeps deeper same-session history when another pane saves only the latest tail", () => { + it("claims a shared gateway event only once across retained panes", () => { const { host, cache } = createCacheContext(); - const retained = Array.from({ length: 140 }, (_, index) => ({ - content: `retained-${index + 1}`, - __openclaw: { seq: index + 1 }, - })); - cacheHomeSnapshot(cache, host, { - messages: retained, - pagination: { hasMore: false, totalMessages: 140 }, - sessionId: "session-1", - }); - const refreshedTail = Array.from({ length: 40 }, (_, index) => ({ - content: `fresh-${index + 101}`, - __openclaw: { seq: index + 101 }, - })); + const target = { sessionKey: "agent:ops:background" }; + const event = {}; - cacheHomeSnapshot(cache, host, { - messages: refreshedTail, - pagination: { hasMore: true, nextOffset: 40, totalMessages: 140 }, - sessionId: "session-1", - }); + appendChatMessageToCache(cache, host, target, "final", event); + appendChatMessageToCache(cache, host, target, "final", event); - const snapshot = readChatSessionSnapshot(cache, host, { sessionKey: "home" }); - expect(snapshot?.messages).toHaveLength(140); - expect(snapshot?.messages[99]).toBe(retained[99]); - expect(snapshot?.messages[100]).toBe(refreshedTail[0]); - expect(snapshot?.pagination).toEqual({ hasMore: false, totalMessages: 140 }); - }); - - it("keeps the newer same-depth snapshot when a stale pane saves later", () => { - const { host, cache } = createCacheContext(); - const current = [1, 2, 3].map((seq) => ({ - content: `current-${seq}`, - __openclaw: { seq }, - })); - cacheHomeSnapshot(cache, host, { - messages: current, - pagination: { hasMore: false, totalMessages: 3 }, - sessionId: "session-1", - }); - - cacheHomeSnapshot(cache, host, { - messages: current.slice(0, 2), - pagination: { hasMore: true, nextOffset: 2, totalMessages: 3 }, - sessionId: "session-1", - }); - - expect(readChatSessionSnapshot(cache, host, { sessionKey: "home" })).toEqual({ - messages: current, - pagination: { hasMore: false, totalMessages: 3 }, - sessionId: "session-1", - }); + expect(readChatMessagesFromCache(cache, host, target)).toEqual(["final"]); }); it("does not retain history across backing session changes", () => { diff --git a/ui/src/pages/chat/session-message-cache.ts b/ui/src/pages/chat/session-message-cache.ts index 5c698d3a0d70..5dda81c057c3 100644 --- a/ui/src/pages/chat/session-message-cache.ts +++ b/ui/src/pages/chat/session-message-cache.ts @@ -21,8 +21,9 @@ const MAX_CACHED_CHAT_WEIGHT = 24 * 1024 * 1024; // History reconciliation replaces changed messages and retains unchanged // objects, so serialization weight can follow the same immutable identity. const cachedMessageWeights = new WeakMap(); +const appendedEventClaims = new WeakMap>(); -export type ChatSessionSnapshot = { +type ChatSessionSnapshot = { displayedLeafEntryId?: string | null; messages: unknown[]; pagination: ChatHistoryPagination; @@ -30,9 +31,6 @@ export type ChatSessionSnapshot = { }; type CachedChatSessionSnapshot = { - // The producing array identifies an unchanged snapshot so route exit can - // refresh LRU order without rescanning a long transcript. - sourceMessages: unknown[]; snapshot: ChatSessionSnapshot; weight: number; }; @@ -91,7 +89,19 @@ export function appendChatMessageToCache( host: ChatMessageCacheHost, target: ChatMessageCacheTarget, message: unknown, + eventClaim?: object, ): void { + if (eventClaim) { + let claims = appendedEventClaims.get(cache); + if (!claims) { + claims = new WeakSet(); + appendedEventClaims.set(cache, claims); + } + if (claims.has(eventClaim)) { + return; + } + claims.add(eventClaim); + } const cacheKey = resolveChatMessageCacheKey(host, target); const existing = getSessionCacheValue(cache, cacheKey); if (!existing) { @@ -122,7 +132,6 @@ export function appendChatMessageToCache( } setSessionCacheValue(cache, cacheKey, { snapshot, - sourceMessages: snapshot.messages, weight, }); trimChatSessionSnapshotCache(cache); @@ -153,7 +162,7 @@ export function cacheChatSessionSnapshot( const cacheKey = resolveChatMessageCacheKey(host, target); const existing = getSessionCacheValue(cache, cacheKey); if ( - existing?.sourceMessages === snapshot.messages && + existing?.snapshot.messages === snapshot.messages && existing.snapshot.sessionId === snapshot.sessionId && existing.snapshot.displayedLeafEntryId === snapshot.displayedLeafEntryId && samePagination(existing.snapshot.pagination, snapshot.pagination) @@ -170,9 +179,7 @@ export function cacheChatSessionSnapshot( cache.delete(cacheKey); return; } - const bounded = boundChatSessionSnapshot( - mergeRetainedSessionDepth(existing?.snapshot ?? null, snapshot), - ); + const bounded = boundChatSessionSnapshot(snapshot); if (!bounded) { cache.delete(cacheKey); return; @@ -181,98 +188,12 @@ export function cacheChatSessionSnapshot( trimChatSessionSnapshotCache(cache); } -function mergeRetainedSessionDepth( - existing: ChatSessionSnapshot | null, - incoming: ChatSessionSnapshot, -): ChatSessionSnapshot { - if ( - !existing || - !existing.sessionId || - existing.sessionId !== incoming.sessionId || - existing.messages.length === 0 || - incoming.messages.length === 0 - ) { - return incoming; - } - const existingBounds = transcriptSequenceBounds(existing.messages); - const incomingBounds = transcriptSequenceBounds(incoming.messages); - const existingTotal = existing.pagination.totalMessages; - const incomingTotal = incoming.pagination.totalMessages; - if ( - existingBounds && - incomingBounds && - typeof existingTotal === "number" && - incomingTotal === existingTotal && - incomingBounds.newest < existingBounds.newest - ) { - return existing; - } - if ( - !existingBounds || - !incomingBounds || - typeof existingTotal !== "number" || - typeof incomingTotal !== "number" || - incomingTotal < existingTotal || - incomingBounds.oldest <= existingBounds.oldest || - incomingBounds.oldest > existingBounds.newest + 1 - ) { - return incoming; - } - const overlapStart = existing.messages.findIndex((message) => { - const sequence = readSessionMessageSequence(message); - return sequence !== null && sequence >= incomingBounds.oldest; - }); - const retainedPrefix = - overlapStart === -1 ? existing.messages : existing.messages.slice(0, overlapStart); - const messages = [...retainedPrefix, ...incoming.messages]; - const pagination = capSnapshotPagination(incoming.pagination, messages); - return pagination - ? { - ...(Object.hasOwn(incoming, "displayedLeafEntryId") - ? { displayedLeafEntryId: incoming.displayedLeafEntryId } - : {}), - messages, - pagination, - sessionId: incoming.sessionId, - } - : incoming; -} - -function transcriptSequenceBounds( - messages: readonly unknown[], -): { oldest: number; newest: number } | null { - let oldest: number | null = null; - let newest: number | null = null; - for (const message of messages) { - const sequence = readSessionMessageSequence(message); - if (sequence === null) { - continue; - } - oldest = oldest === null ? sequence : Math.min(oldest, sequence); - newest = newest === null ? sequence : Math.max(newest, sequence); - } - return oldest === null || newest === null ? null : { oldest, newest }; -} - export function readChatSessionSnapshot( cache: ChatMessageCache, host: ChatMessageCacheHost, target: ChatMessageCacheTarget, ): ChatSessionSnapshot | null { - const cached = getSessionCacheValue(cache, resolveChatMessageCacheKey(host, target)); - if (!cached) { - return null; - } - const messages = [...cached.snapshot.messages]; - cached.sourceMessages = messages; - return { - ...(Object.hasOwn(cached.snapshot, "displayedLeafEntryId") - ? { displayedLeafEntryId: cached.snapshot.displayedLeafEntryId } - : {}), - messages, - pagination: { ...cached.snapshot.pagination }, - sessionId: cached.snapshot.sessionId, - }; + return getSessionCacheValue(cache, resolveChatMessageCacheKey(host, target))?.snapshot ?? null; } function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSessionSnapshot | null { @@ -298,14 +219,14 @@ function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSess messageWeights.length - start, ); if (weight !== null && weight <= MAX_CACHED_CHAT_SNAPSHOT_WEIGHT) { + const messages = start === 0 ? snapshot.messages : snapshot.messages.slice(start); return { - sourceMessages: snapshot.messages, snapshot: { ...(Object.hasOwn(snapshot, "displayedLeafEntryId") ? { displayedLeafEntryId: snapshot.displayedLeafEntryId } : {}), - messages: snapshot.messages.slice(start), - pagination: { ...pagination }, + messages, + pagination: start === 0 ? pagination : { ...pagination }, sessionId: snapshot.sessionId, }, weight, diff --git a/ui/src/pages/chat/terminal-message-identity.ts b/ui/src/pages/chat/terminal-message-identity.ts index a6d020ae8408..806d645ac209 100644 --- a/ui/src/pages/chat/terminal-message-identity.ts +++ b/ui/src/pages/chat/terminal-message-identity.ts @@ -28,10 +28,6 @@ export function isLiveTerminalForRun(message: unknown, runId: string): boolean { ); } -export function clearAuthoritativeTerminal(host: object): void { - authoritativeTerminals.delete(host); -} - export function rememberAuthoritativeTerminal(options: { event: { clientRunId?: string | null; diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index 85a186b76d3c..25abf4904857 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -46,6 +46,27 @@ openclaw-chat-pane { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 60%, transparent); } +.chat-pane-cache { + display: grid; + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +.chat-pane-cache__pane { + opacity: 0; + grid-area: 1 / 1; +} + +.chat-pane-cache__pane--visible { + opacity: 1; + z-index: 1; +} + +.chat-pane-cache__pane:not(.chat-pane-cache__pane--active) .chat-inline-approval { + display: none; +} + .chat-split-view__pane { flex: 1 1 0; min-width: 0; @@ -57,6 +78,12 @@ openclaw-chat-pane { min-height: 0; } +.chat-split-view--narrow .chat-split-view__cell--narrow-hidden, +.chat-split-view--narrow .chat-split-view__column--narrow-hidden, +.chat-split-view--narrow resizable-divider { + display: none; +} + /* Narrow mode shows only the active pane; a focus ring is pure noise there. */ .chat-split-view--narrow .chat-split-view__cell--active { box-shadow: none; diff --git a/ui/src/test-helpers/app-sidebar-cases/session-navigation.ts b/ui/src/test-helpers/app-sidebar-cases/session-navigation.ts new file mode 100644 index 000000000000..20ee04c81501 --- /dev/null +++ b/ui/src/test-helpers/app-sidebar-cases/session-navigation.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { SESSION_NAVIGATION_INTENT_EVENT } from "../../lib/sessions/navigation-handoff.ts"; +import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts"; + +describe("AppSidebar retained session navigation", () => { + it("cancels a pending retained navigation when a newer session wins", async () => { + let pendingCommit: (() => boolean) | undefined; + const handleIntent = (event: Event) => { + const intent = event as CustomEvent<{ commit: () => boolean; sessionKey: string }>; + if (intent.detail.sessionKey === "agent:main:b") { + pendingCommit = intent.detail.commit; + event.preventDefault(); + } + }; + window.addEventListener(SESSION_NAVIGATION_INTENT_EVENT, handleIntent); + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar( + gateway, + createSessions("main", ["agent:main:a", "agent:main:b"]), + ); + const navigation = vi.fn(); + sidebar.onNavigate = navigation; + const selectSession = (sidebar as typeof sidebar & { selectSession: (key: string) => void }) + .selectSession; + + try { + selectSession("agent:main:b"); + expect(navigation).not.toHaveBeenCalled(); + selectSession("agent:main:a"); + + expect(pendingCommit?.()).toBe(false); + expect(navigation).toHaveBeenCalledOnce(); + expect(navigation).toHaveBeenCalledWith( + "chat", + expect.objectContaining({ pathname: "/chat/main/a" }), + ); + } finally { + window.removeEventListener(SESSION_NAVIGATION_INTENT_EVENT, handleIntent); + } + }); + + it("refuses to commit retained navigation after the sidebar disconnects", async () => { + let pendingCommit: (() => boolean) | undefined; + const handleIntent = (event: Event) => { + pendingCommit = (event as CustomEvent<{ commit: () => boolean }>).detail.commit; + event.preventDefault(); + }; + window.addEventListener(SESSION_NAVIGATION_INTENT_EVENT, handleIntent); + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar( + gateway, + createSessions("main", ["agent:main:a", "agent:main:b"]), + ); + const navigation = vi.fn(); + sidebar.onNavigate = navigation; + const selectSession = (sidebar as typeof sidebar & { selectSession: (key: string) => void }) + .selectSession; + + try { + selectSession("agent:main:b"); + sidebar.remove(); + expect(pendingCommit?.()).toBe(false); + expect(navigation).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(SESSION_NAVIGATION_INTENT_EVENT, handleIntent); + } + }); +}); diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index 1a2b83c24a46..a823109491fd 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -18,6 +18,7 @@ import { } from "../app-sidebar.ts"; import { waitForFast } from "../wait-for.ts"; import "./session-pagination.ts"; +import "./session-navigation.ts"; describe("AppSidebar session pagination", () => { it("does not show pagination controls at the ten-session boundary", async () => { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 0d66e9e96db3..230998b07893 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -33,6 +33,32 @@ export function controlUiSessionUrl(baseUrl: string, sessionKey: string): string return url.toString(); } +export async function navigateToControlUiSession(page: Page, sessionKey: string): Promise { + await page.evaluate((pathname) => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + navigate: (routeId: string, options: { pathname: string }) => void; + }; + }; + }; + if (!app.runtime) { + throw new Error("OpenClaw application runtime is unavailable"); + } + app.runtime.context.navigate("chat", { pathname }); + }, controlUiSessionPath(sessionKey)); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey)); + await page.waitForFunction( + (targetSessionKey) => + [...document.querySelectorAll("openclaw-chat-pane")].some( + (pane) => + pane.classList.contains("chat-pane-cache__pane--visible") && + (pane as HTMLElement & { sessionKey?: string }).sessionKey === targetSessionKey, + ), + sessionKey, + ); +} + export function controlUiBundledGatewayUrl(baseUrl: string): string { const url = new URL(baseUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";