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 <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-10 16:17:25 -07:00
committed by GitHub
parent c6b9be2646
commit 750d0dcd9e
75 changed files with 2425 additions and 3012 deletions
+4
View File
@@ -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"],
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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:
+1 -1
View File
@@ -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"
+1 -2
View File
@@ -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<typeof actualStore.write>) => {
if (args[0].includes(`${segment}${path.sep}`) && writeAttempts++ === 0) {
if (args[0].includes(`${segment}/`) && writeAttempts++ === 0) {
throw injectedError;
}
return await actualStore.write(...args);
+2 -2
View File
@@ -80,7 +80,7 @@ describe("media store", () => {
...actualStore,
write: async (...args: Parameters<typeof actualStore.write>) => {
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<typeof actualStore.write>) => {
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";
+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+54 -29
View File
@@ -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", () => {
+24 -14
View File
@@ -8,6 +8,7 @@ const MAX_PENDING_CHAT_ATTACHMENT_ENTRIES = 32;
type PendingChatAttachmentHandoff = {
owner: NonNullable<Parameters<ApplicationChatAttachmentHandoff["prepare"]>[0]["owner"]>;
paneId: string;
scopeKey: string;
attachments: ChatAttachment[];
fallbacks: Record<string, ChatComposerMemoryFallback>;
@@ -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()) {
+17 -12
View File
@@ -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<ChatRouteData>();
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<ChatRouteData>();
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();
});
});
+8 -1
View File
@@ -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",
@@ -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<GatewaySessionRow, number>();
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(
+1 -1
View File
@@ -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())),
)
@@ -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();
},
);
});
+62 -187
View File
@@ -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);
});
});
@@ -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<HTMLElement>("[data-chat-row-key]"));
const rows = Array.from(pane?.querySelectorAll<HTMLElement>("[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`,
+4 -6
View File
@@ -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;
@@ -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"))
@@ -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);
@@ -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:/);
+4 -4
View File
@@ -120,7 +120,7 @@ export async function copiedViaExec(page: Page): Promise<string[]> {
}
export async function chatThreadDistanceFromBottom(page: Page): Promise<number> {
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<void> {
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<void> {
}
export async function scrollChatThreadToTop(page: Page): Promise<void> {
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<string[]> {
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"))
+20 -16
View File
@@ -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();
});
+5 -2
View File
@@ -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,
@@ -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();
@@ -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,
},
@@ -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<string, unknown> };
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({
@@ -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();
}
@@ -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" });
+51
View File
@@ -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<SessionNavigationIntentOwner, object>();
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,
@@ -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) {
@@ -22,7 +22,7 @@ export type ChatComposerMemoryFallbackOwnership = {
sequence: number;
};
export function resolveChatComposerMemoryFallback(
function resolveChatComposerMemoryFallback(
state: ChatPageHost,
sessionKey: string,
scopeOverride?: StoredChatOutboxScope,
+21
View File
@@ -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",
+9 -2
View File
@@ -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;
@@ -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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<number, FrameRequestCallback>();
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();
});
});
@@ -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<string, string[]>();
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<string>): 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<ChatPaneElement>("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<ChatPaneElement>("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`<openclaw-chat-pane
class="chat-pane-cache__pane ${visible ? "chat-pane-cache__pane--visible" : ""} ${active
? "chat-pane-cache__pane--active"
: ""} ${params.splitMode ? "chat-split-view__pane" : ""}"
data-mcp-app-owner-key=${JSON.stringify([params.ownerKey, sessionKey])}
aria-hidden=${presented ? "false" : "true"}
?inert=${!presented}
.paneId=${params.pane.id}
.presentationId=${JSON.stringify([params.pane.id, sessionKey])}
.chatMessagesBySession=${params.chatMessagesBySession}
.sessionKey=${sessionKey}
.presented=${presented}
.active=${active}
.draft=${draft}
.focusComposer=${focus}
.routeFace=${params.data?.face ?? "chat"}
.paneTitle=${title}
.narrow=${params.narrow}
.mergedChrome=${params.mergedChrome && active}
.navDrawerOpen=${params.navDrawerOpen && active}
.nativeGateways=${params.showGatewayPicker ? nativeGateways : null}
.gatewaysSnapshot=${params.showGatewayPicker ? (nativeGateways?.snapshot ?? null) : null}
.onboarding=${params.onboarding}
.onOpenSplitView=${params.onOpenSplitView}
.onSplitDown=${params.onSplitDown}
.onSplitRight=${params.onSplitRight}
.onClosePane=${params.onClosePane}
.onFocusPane=${params.onFocusPane}
.onPaneSessionChange=${(
paneId: string,
nextSessionKey: string,
options?: { replace?: boolean },
) => params.onPaneSessionChange(paneId, sessionKey, nextSessionKey, options)}
.onSessionDeleted=${params.onSessionDeleted}
.onFaceChange=${params.onFaceChange}
></openclaw-chat-pane>`;
},
);
}
+13 -67
View File
@@ -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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("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<RenderedPane>("openclaw-chat-pane"),
0,
1,
"active narrow pane",
);
expect(narrowPane).toBe(activePane);
+94 -103
View File
@@ -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<ChatPaneElement>("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<ChatPaneElement>("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`
<div
class="chat-split-view__cell ${splitMode && active ? "chat-split-view__cell--active" : ""}"
class="chat-split-view__cell ${splitMode && active
? "chat-split-view__cell--active"
: ""} ${this.narrow && !active ? "chat-split-view__cell--narrow-hidden" : ""}"
style="flex: ${weight} 1 0"
@pointerdown=${() => this.handleFocusPane(pane.id)}
@focusin=${() => this.handleFocusPane(pane.id)}
>
<openclaw-chat-pane
class=${splitMode ? "chat-split-view__pane" : ""}
data-mcp-app-owner-key=${ownerKey}
.paneId=${pane.id}
.chatMessagesBySession=${this.chatMessagesBySession}
.sessionKey=${pane.sessionKey}
.active=${active}
.draft=${draft}
.focusComposer=${focus}
.routeFace=${this.data?.face ?? "chat"}
.paneTitle=${title}
.narrow=${this.narrow}
.mergedChrome=${this.mergedChrome && active}
.navDrawerOpen=${this.navDrawerOpen && active}
.nativeGateways=${showGatewayPicker ? nativeGateways : null}
.gatewaysSnapshot=${showGatewayPicker ? (nativeGateways?.snapshot ?? null) : null}
.onboarding=${this.closest(".shell--onboarding") !== null}
.onOpenSplitView=${splitMode || this.narrow ? undefined : this.openSplitView}
.onSplitDown=${splitMode ? this.handleSplitDown : undefined}
.onSplitRight=${splitMode ? this.handleSplitRight : undefined}
.onClosePane=${splitMode ? this.handleClosePane : undefined}
.onFocusPane=${this.handleFocusPane}
.onPaneSessionChange=${this.handlePaneSessionChange}
.onFaceChange=${(face: BoardFace) =>
this.handlePaneFaceChange(pane.id, pane.sessionKey, face)}
></openclaw-chat-pane>
<div class="chat-pane-cache">
${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,
})}
</div>
</div>
`;
}
@@ -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`
<div class="chat-split-view ${this.narrow ? "chat-split-view--narrow" : ""}">
${repeat(
renderedColumns,
layout.columns,
(column) => column.id,
(column, columnIndex) => html`
<div
class="chat-split-view__column"
class="chat-split-view__column ${this.narrow &&
!column.panes.some((pane) => pane.id === layout.activePaneId)
? "chat-split-view__column--narrow-hidden"
: ""}"
style="flex: ${splitWeight(
renderedColumnWeights,
layout.columnWeights,
columnIndex,
"rendered split column weight",
)} 1 0"
@@ -643,10 +632,10 @@ export class ChatPage extends OpenClawLightDomElement {
pane.id === layout.activePaneId,
splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"),
splitMode,
JSON.stringify([column.id, pane.id, pane.sessionKey]),
JSON.stringify([column.id, pane.id]),
pane.id === rightmostPane?.id,
)}
${paneIndex < column.panes.length - 1
${!this.narrow && paneIndex < column.panes.length - 1
? html`
<resizable-divider
orientation="horizontal"
@@ -672,7 +661,7 @@ export class ChatPage extends OpenClawLightDomElement {
`,
)}
</div>
${columnIndex < renderedColumns.length - 1
${!this.narrow && columnIndex < layout.columns.length - 1
? html`
<resizable-divider
.splitRatio=${splitRatio(
@@ -703,16 +692,18 @@ export class ChatPage extends OpenClawLightDomElement {
override render() {
const indicator = this.dropIndicator;
const layout = this.layout ?? this.classicLayout();
const renderedPaneIds = new Set(visiblePanesOf(layout, this.narrow).map((pane) => 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`
<div class="chat-split-view__drop-container">
@@ -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],
@@ -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<StagedAttachmentPane>("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<StagedAttachmentPane>("openclaw-chat-pane")].find(
const mounted = [...root.querySelectorAll<StagedAttachmentPane>("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;
}
+40 -7
View File
@@ -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;
}
+15 -1
View File
@@ -30,9 +30,11 @@ type TestChatPane = HTMLElement & {
context: ApplicationContext;
state: ChatPageHost;
createSession: () => Promise<boolean>;
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<boolean>;
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";
+1 -1
View File
@@ -351,7 +351,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
patch: Partial<BoardSessionView> & { 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;
@@ -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,
+5 -2
View File
@@ -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?.();
+34 -185
View File
@@ -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<void>;
catalogLoadGeneration: number;
catalogSession: SessionCatalogSession | null;
paneId: string;
sessionKey: string;
switchPaneSession: (nextSessionKey: string) => void;
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record<string, unknown> | null;
handleTranscriptScroll: (event: Event) => void;
@@ -40,8 +38,7 @@ type TestChatPane = HTMLElement & {
hasOlderMessages: () => boolean;
loadingOlder: boolean;
olderOffsetsSeen: Set<number>;
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<typeof vi.fn>) {
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();
});
});
+31 -114
View File
@@ -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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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;
@@ -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", () => {
+40 -135
View File
@@ -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<boolean> => {
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);
+7 -6
View File
@@ -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);
},
@@ -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;
}
@@ -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?.();
}
});
});
}
}
}
@@ -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<boolean>;
protected readonly createSession = async (): Promise<boolean> => {
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;
};
}
+7 -156
View File
@@ -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<void> {
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 = [];
+135
View File
@@ -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<string, PendingPaneSessionHandoff[]>
>();
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;
+14 -11
View File
@@ -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<void>;
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 () => {
+4 -1
View File
@@ -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<boolean>;
prepareForEviction: () => void;
restoreArchivedSession: (sessionKey: string) => Promise<void>;
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<boolean>;
deferSessionHydrationUntilTranscript: (
sessionKey: string,
transcriptLoad: Promise<unknown>,
+6 -6
View File
@@ -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.");
});
+2 -44
View File
@@ -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<TState extends ChatPageHost> 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<TState extends ChatPageHost> 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<TState extends ChatPageHost> implements Reactiv
this.scrollAfterUpdate = false;
this.scrollContentChangedAfterUpdate = false;
this.forceScrollAfterUpdate = false;
this.pendingCreatedSessionComposer = null;
}
}
+1 -231
View File
@@ -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 ??
File diff suppressed because it is too large Load Diff
@@ -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 {
@@ -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<unknown, number>;
measure: () => void;
};
type SessionHostInternals = {
connected: boolean;
measureRowRefs: Map<string, unknown>;
virtualizerController: { getVirtualizer: () => VirtualizerInternals };
};
const controllerInternals = transcript as unknown as {
sessionVirtualizers: Map<string, SessionHostInternals>;
};
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<string | null>(
"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<string | null>(
"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(
+14 -53
View File
@@ -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<string, ChatSessionVirtualizerHost>();
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(
<span
class="chat-transcript-announcement agent-chat__sr-only"
role="status"
aria-live="polite"
aria-live=${props.announceTranscript !== false ? "polite" : "off"}
aria-atomic="true"
>${transcript.liveAnnouncementText}</span
>
@@ -14,7 +14,10 @@ type PendingHandoff = {
export type ChatPaneElement = HTMLElement & {
active?: boolean;
discardStagedAttachments?: () => void;
paneId?: string;
prepareForEviction?: () => void;
presented?: boolean;
sessionKey?: string;
};
+7 -118
View File
@@ -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<RouteMatch, "data" | "location">;
type SessionOwnerMatch = Pick<RouteMatch, "data">;
const CHAT_PAGE_OWNER_KEY = "chat-page";
function renderAmbiguous(data: Extract<ChatRouteData, { kind: "ambiguous" }>) {
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<typeof sessionTargetFromLocation>,
right: ReturnType<typeof sessionTargetFromLocation>,
): 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) {
+3 -49
View File
@@ -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,
+3 -24
View File
@@ -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;
+15 -52
View File
@@ -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", () => {
+20 -99
View File
@@ -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<object, number>();
const appendedEventClaims = new WeakMap<ChatMessageCache, WeakSet<object>>();
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,
@@ -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;
+27
View File
@@ -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;
@@ -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);
}
});
});
@@ -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 () => {
+26
View File
@@ -33,6 +33,32 @@ export function controlUiSessionUrl(baseUrl: string, sessionKey: string): string
return url.toString();
}
export async function navigateToControlUiSession(page: Page, sessionKey: string): Promise<void> {
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<HTMLElement>("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:";