mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c6b9be2646
commit
750d0dcd9e
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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:/);
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:";
|
||||
|
||||
Reference in New Issue
Block a user