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/memory-state.ts": ["exports", "types"],
|
||||||
"src/plugins/session-discussion-registry.ts": ["exports"],
|
"src/plugins/session-discussion-registry.ts": ["exports"],
|
||||||
"src/tasks/detached-task-runtime-state.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.
|
// Focused media tests consume these explicit seams; production uses the helpers in-module.
|
||||||
"src/agents/embedded-agent-subscribe.handlers.lifecycle.ts": ["exports"],
|
"src/agents/embedded-agent-subscribe.handlers.lifecycle.ts": ["exports"],
|
||||||
"src/gateway/server-methods/chat-webchat-media.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",
|
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
|
||||||
"deadcode:dependencies": "pnpm deadcode:full",
|
"deadcode:dependencies": "pnpm deadcode:full",
|
||||||
"deadcode:exports": "node --import tsx scripts/check-deadcode-exports.mts",
|
"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: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:report": "pnpm deadcode:full; pnpm deadcode:exports",
|
||||||
"deadcode:unused-files": "node --import tsx scripts/check-deadcode-unused-files.mts",
|
"deadcode:unused-files": "node --import tsx scripts/check-deadcode-unused-files.mts",
|
||||||
|
|||||||
Generated
+5
-5
@@ -2438,8 +2438,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/session-url-contract
|
version: link:../packages/session-url-contract
|
||||||
'@openclaw/uirouter':
|
'@openclaw/uirouter':
|
||||||
specifier: 0.1.0
|
specifier: 0.1.1
|
||||||
version: 0.1.0
|
version: 0.1.1
|
||||||
'@openclaw/workboard-contract':
|
'@openclaw/workboard-contract':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/workboard-contract
|
version: link:../packages/workboard-contract
|
||||||
@@ -4163,8 +4163,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
undici: '>=8.5.0 <9'
|
undici: '>=8.5.0 <9'
|
||||||
|
|
||||||
'@openclaw/uirouter@0.1.0':
|
'@openclaw/uirouter@0.1.1':
|
||||||
resolution: {integrity: sha512-w5tNj2FIukVJqJ1wt5wiDbrI6DI4tOkUbtqnnU5Fl4EgnRKFJtfHI3WrWTWTTJlXbbrwGSMptyrSZmQVdRo83Q==}
|
resolution: {integrity: sha512-aiZFvWmP/ndpS3em5xVtiPlj1k6asA8ueoNxGxekA1024EZlCwnICPZklG6g2sU4Wlntp9AYocLCr5/iuvL7hw==}
|
||||||
engines: {node: ^22.18.0 || >=24.11.0}
|
engines: {node: ^22.18.0 || >=24.11.0}
|
||||||
|
|
||||||
'@opentelemetry/api-logs@0.221.0':
|
'@opentelemetry/api-logs@0.221.0':
|
||||||
@@ -11448,7 +11448,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici: 8.9.0
|
undici: 8.9.0
|
||||||
|
|
||||||
'@openclaw/uirouter@0.1.0': {}
|
'@openclaw/uirouter@0.1.1': {}
|
||||||
|
|
||||||
'@opentelemetry/api-logs@0.221.0':
|
'@opentelemetry/api-logs@0.221.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ minimumReleaseAgeExclude:
|
|||||||
- "@openclaw/fs-safe@0.5.4"
|
- "@openclaw/fs-safe@0.5.4"
|
||||||
- "@openclaw/libterminal@0.3.2"
|
- "@openclaw/libterminal@0.3.2"
|
||||||
- "@openclaw/proxyline@0.3.4"
|
- "@openclaw/proxyline@0.3.4"
|
||||||
- "@openclaw/uirouter@0.1.0"
|
- "@openclaw/uirouter@0.1.1"
|
||||||
- "acpx"
|
- "acpx"
|
||||||
- "tokenjuice"
|
- "tokenjuice"
|
||||||
- "@agentclientprotocol/sdk"
|
- "@agentclientprotocol/sdk"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Media store retry tests cover the exact directory-recreation recovery boundary.
|
// Media store retry tests cover the exact directory-recreation recovery boundary.
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||||
@@ -53,7 +52,7 @@ describe("media store directory recreation", () => {
|
|||||||
return {
|
return {
|
||||||
...actualStore,
|
...actualStore,
|
||||||
write: async (...args: Parameters<typeof actualStore.write>) => {
|
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;
|
throw injectedError;
|
||||||
}
|
}
|
||||||
return await actualStore.write(...args);
|
return await actualStore.write(...args);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe("media store", () => {
|
|||||||
...actualStore,
|
...actualStore,
|
||||||
write: async (...args: Parameters<typeof actualStore.write>) => {
|
write: async (...args: Parameters<typeof actualStore.write>) => {
|
||||||
const [relativePath] = args;
|
const [relativePath] = args;
|
||||||
if (!injectedEnoent && relativePath.includes(`${params.segment}${path.sep}`)) {
|
if (!injectedEnoent && relativePath.includes(`${params.segment}/`)) {
|
||||||
injectedEnoent = true;
|
injectedEnoent = true;
|
||||||
await fs.rm(path.dirname(actualStore.path(relativePath)), {
|
await fs.rm(path.dirname(actualStore.path(relativePath)), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -126,7 +126,7 @@ describe("media store", () => {
|
|||||||
...actualStore,
|
...actualStore,
|
||||||
write: async (...args: Parameters<typeof actualStore.write>) => {
|
write: async (...args: Parameters<typeof actualStore.write>) => {
|
||||||
const [relativePath] = args;
|
const [relativePath] = args;
|
||||||
if (relativePath.includes(`failed-buffer${path.sep}`)) {
|
if (relativePath.includes("failed-buffer/")) {
|
||||||
attemptedRelPaths.push(relativePath);
|
attemptedRelPaths.push(relativePath);
|
||||||
const err = new Error("no space left on device") as NodeJS.ErrnoException;
|
const err = new Error("no space left on device") as NodeJS.ErrnoException;
|
||||||
err.code = "ENOSPC";
|
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-identity.test.ts",
|
||||||
"ui/src/pages/chat/chat-pane-lifecycle.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-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.message-cut.test.ts",
|
||||||
"ui/src/pages/chat/chat-pane.read-marker.test.ts",
|
"ui/src/pages/chat/chat-pane.read-marker.test.ts",
|
||||||
"ui/src/pages/chat/chat-pane.session-discussion.test.ts",
|
"ui/src/pages/chat/chat-pane.session-discussion.test.ts",
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"@openclaw/net-policy": "workspace:*",
|
"@openclaw/net-policy": "workspace:*",
|
||||||
"@openclaw/normalization-core": "workspace:*",
|
"@openclaw/normalization-core": "workspace:*",
|
||||||
"@openclaw/session-url-contract": "workspace:*",
|
"@openclaw/session-url-contract": "workspace:*",
|
||||||
"@openclaw/uirouter": "0.1.0",
|
"@openclaw/uirouter": "0.1.1",
|
||||||
"@openclaw/workboard-contract": "workspace:*",
|
"@openclaw/workboard-contract": "workspace:*",
|
||||||
"@tanstack/lit-virtual": "3.13.35",
|
"@tanstack/lit-virtual": "3.13.35",
|
||||||
"@tanstack/virtual-core": "3.17.6",
|
"@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", () => {
|
it("isolates retained session scopes and releases an exact Gateway-owner mismatch", () => {
|
||||||
const cases = [
|
const handoff = createChatAttachmentHandoff();
|
||||||
{ ownerMatches: false, scopeKey: "agent:main:one" },
|
const expectedOwner = {} as GatewayBrowserClient;
|
||||||
{ ownerMatches: true, scopeKey: "agent:main:two" },
|
const first = storedAttachment("first-scope", "image/png", true);
|
||||||
];
|
const second = storedAttachment("second-scope", "image/png", true);
|
||||||
for (const { ownerMatches, scopeKey } of cases) {
|
handoff.prepare({
|
||||||
const handoff = createChatAttachmentHandoff();
|
owner: expectedOwner,
|
||||||
const expectedOwner = {} as GatewayBrowserClient;
|
paneId: "p1",
|
||||||
const annotation = storedAttachment(
|
scopeKey: "agent:main:one",
|
||||||
`mismatch-${ownerMatches}-${scopeKey}`,
|
attachments: [first],
|
||||||
"image/png",
|
fallbacks: {},
|
||||||
true,
|
});
|
||||||
);
|
handoff.prepare({
|
||||||
handoff.prepare({
|
owner: expectedOwner,
|
||||||
owner: expectedOwner,
|
paneId: "p1",
|
||||||
paneId: "p1",
|
scopeKey: "agent:main:two",
|
||||||
scopeKey: "agent:main:one",
|
attachments: [second],
|
||||||
attachments: [annotation],
|
fallbacks: {},
|
||||||
fallbacks: {},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
handoff.consume({
|
handoff.consume({
|
||||||
owner: ownerMatches ? expectedOwner : ({} as GatewayBrowserClient),
|
owner: {} as GatewayBrowserClient,
|
||||||
paneId: "p1",
|
paneId: "p1",
|
||||||
scopeKey,
|
scopeKey: "agent:main:two",
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(getChatAttachmentDataUrl(annotation)).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", () => {
|
it("keeps payloads reused by a replacement prepare", () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const MAX_PENDING_CHAT_ATTACHMENT_ENTRIES = 32;
|
|||||||
|
|
||||||
type PendingChatAttachmentHandoff = {
|
type PendingChatAttachmentHandoff = {
|
||||||
owner: NonNullable<Parameters<ApplicationChatAttachmentHandoff["prepare"]>[0]["owner"]>;
|
owner: NonNullable<Parameters<ApplicationChatAttachmentHandoff["prepare"]>[0]["owner"]>;
|
||||||
|
paneId: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
attachments: ChatAttachment[];
|
attachments: ChatAttachment[];
|
||||||
fallbacks: Record<string, ChatComposerMemoryFallback>;
|
fallbacks: Record<string, ChatComposerMemoryFallback>;
|
||||||
@@ -37,17 +38,19 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
|
|||||||
}
|
}
|
||||||
release(handoffAttachments(handoff).filter((attachment) => !retainedIds.has(attachment.id)));
|
release(handoffAttachments(handoff).filter((attachment) => !retainedIds.has(attachment.id)));
|
||||||
};
|
};
|
||||||
const take = (paneId: string) => {
|
const entryKey = (paneId: string, scopeKey: string) => JSON.stringify([paneId, scopeKey]);
|
||||||
const handoff = pending.get(paneId);
|
const take = (key: string) => {
|
||||||
|
const handoff = pending.get(key);
|
||||||
if (handoff) {
|
if (handoff) {
|
||||||
pending.delete(paneId);
|
pending.delete(key);
|
||||||
}
|
}
|
||||||
return handoff;
|
return handoff;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => {
|
prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => {
|
||||||
const previous = take(paneId);
|
const key = entryKey(paneId, scopeKey);
|
||||||
|
const previous = take(key);
|
||||||
const fallbackEntries = Object.entries(fallbacks);
|
const fallbackEntries = Object.entries(fallbacks);
|
||||||
if (attachments.length === 0 && fallbackEntries.length === 0) {
|
if (attachments.length === 0 && fallbackEntries.length === 0) {
|
||||||
releaseHandoff(previous);
|
releaseHandoff(previous);
|
||||||
@@ -67,37 +70,44 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending.set(paneId, {
|
pending.set(key, {
|
||||||
owner,
|
owner,
|
||||||
|
paneId,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
attachments: [...attachments],
|
attachments: [...attachments],
|
||||||
fallbacks: Object.fromEntries(
|
fallbacks: Object.fromEntries(
|
||||||
fallbackEntries.map(([key, fallback]) => [
|
fallbackEntries.map(([fallbackKey, fallback]) => [
|
||||||
key,
|
fallbackKey,
|
||||||
{ ...fallback, attachments: [...fallback.attachments] },
|
{ ...fallback, attachments: [...fallback.attachments] },
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
// Route handoffs normally consume immediately. Bounds make abandoned
|
// Route handoffs normally consume immediately. Bounds make abandoned
|
||||||
// split panes release their packages instead of leaking for the tab lifetime.
|
// 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) {
|
if (pending.size <= MAX_PENDING_CHAT_ATTACHMENT_ENTRIES) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
releaseHandoff(take(oldestPaneId));
|
releaseHandoff(take(oldestKey));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
consume: ({ owner, paneId, scopeKey }) => {
|
consume: ({ owner, paneId, scopeKey }) => {
|
||||||
const match = take(paneId);
|
const match = take(entryKey(paneId, scopeKey));
|
||||||
// Reusing a pane id with another session or Gateway is terminal for the
|
// A Gateway mismatch is terminal for this exact presentation. Other
|
||||||
// old owner; keeping it would allow a later remount to recover stale evidence.
|
// retained session scopes under the same logical pane remain independent.
|
||||||
if (match?.owner === owner && match.scopeKey === scopeKey) {
|
if (match?.owner === owner) {
|
||||||
return { attachments: match.attachments, fallbacks: match.fallbacks };
|
return { attachments: match.attachments, fallbacks: match.fallbacks };
|
||||||
}
|
}
|
||||||
releaseHandoff(match);
|
releaseHandoff(match);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
clearPane: (paneId) => releaseHandoff(take(paneId)),
|
clearPane: (paneId) => {
|
||||||
|
for (const [key, handoff] of pending) {
|
||||||
|
if (handoff.paneId === paneId) {
|
||||||
|
releaseHandoff(take(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
for (const handoff of pending.values()) {
|
for (const handoff of pending.values()) {
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("openclaw-router-outlet chat ownership", () => {
|
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 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 row = { key: sessionKey, displayName: "Retained board" };
|
||||||
const chatTarget = sessionNavigationTarget({
|
const chatTarget = sessionNavigationTarget({
|
||||||
face: "chat",
|
face: "chat",
|
||||||
@@ -102,9 +103,9 @@ describe("openclaw-router-outlet chat ownership", () => {
|
|||||||
});
|
});
|
||||||
const dashboardTarget = sessionNavigationTarget({
|
const dashboardTarget = sessionNavigationTarget({
|
||||||
face: "dashboard",
|
face: "dashboard",
|
||||||
sessionKey,
|
sessionKey: nextSessionKey,
|
||||||
fallbackAgentId: "main",
|
fallbackAgentId: "main",
|
||||||
row,
|
row: { key: nextSessionKey, displayName: "Next retained board" },
|
||||||
});
|
});
|
||||||
const nextData = deferred<ChatRouteData>();
|
const nextData = deferred<ChatRouteData>();
|
||||||
const teardown = vi.fn(async () => undefined);
|
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(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chat");
|
||||||
expect(teardown).not.toHaveBeenCalled();
|
expect(teardown).not.toHaveBeenCalled();
|
||||||
|
|
||||||
nextData.resolve(sessionData(sessionKey, "dashboard"));
|
nextData.resolve(sessionData(nextSessionKey, "dashboard"));
|
||||||
await navigation;
|
await navigation;
|
||||||
await settleOutlet(outlet);
|
await settleOutlet(outlet);
|
||||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||||
@@ -200,7 +201,7 @@ describe("openclaw-router-outlet chat ownership", () => {
|
|||||||
router.stop();
|
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 firstKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001";
|
||||||
const secondKey = "agent:main:dashboard:12345678-0bbb-4000-8000-000000000002";
|
const secondKey = "agent:main:dashboard:12345678-0bbb-4000-8000-000000000002";
|
||||||
const pathname = "/chat/main/deploy-monitor-12345678";
|
const pathname = "/chat/main/deploy-monitor-12345678";
|
||||||
@@ -235,17 +236,19 @@ describe("openclaw-router-outlet chat ownership", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
location(pathname, `?${SESSION_NAVIGATION_KEY_PARAM}=${encodeURIComponent(secondKey)}`),
|
location(pathname, `?${SESSION_NAVIGATION_KEY_PARAM}=${encodeURIComponent(secondKey)}`),
|
||||||
);
|
);
|
||||||
await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull();
|
await settleOutlet(outlet);
|
||||||
expect(teardown).toHaveBeenCalledOnce();
|
expect(outlet.querySelector("mcp-app-view")).toBe(firstView);
|
||||||
|
expect(teardown).not.toHaveBeenCalled();
|
||||||
|
|
||||||
nextData.resolve(sessionData(secondKey, "chat"));
|
nextData.resolve(sessionData(secondKey, "chat"));
|
||||||
await navigation;
|
await navigation;
|
||||||
await settleOutlet(outlet);
|
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();
|
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 sessionKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001";
|
||||||
const nextData = deferred<ChatRouteData>();
|
const nextData = deferred<ChatRouteData>();
|
||||||
let loadCount = 0;
|
let loadCount = 0;
|
||||||
@@ -265,10 +268,12 @@ describe("openclaw-router-outlet chat ownership", () => {
|
|||||||
const outlet = createOutlet(router);
|
const outlet = createOutlet(router);
|
||||||
await router.navigate("chat", {}, undefined, location("/chat/main/alpha-12345678"));
|
await router.navigate("chat", {}, undefined, location("/chat/main/alpha-12345678"));
|
||||||
await settleOutlet(outlet);
|
await settleOutlet(outlet);
|
||||||
|
const firstView = outlet.querySelector("mcp-app-view");
|
||||||
|
|
||||||
const navigation = router.navigate("chat", {}, undefined, location("/chat/main/beta-12345678"));
|
const navigation = router.navigate("chat", {}, undefined, location("/chat/main/beta-12345678"));
|
||||||
await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull();
|
await settleOutlet(outlet);
|
||||||
expect(teardown).toHaveBeenCalledOnce();
|
expect(outlet.querySelector("mcp-app-view")).toBe(firstView);
|
||||||
|
expect(teardown).not.toHaveBeenCalled();
|
||||||
|
|
||||||
nextData.resolve({
|
nextData.resolve({
|
||||||
kind: "ambiguous",
|
kind: "ambiguous",
|
||||||
@@ -280,7 +285,7 @@ describe("openclaw-router-outlet chat ownership", () => {
|
|||||||
await navigation;
|
await navigation;
|
||||||
await settleOutlet(outlet);
|
await settleOutlet(outlet);
|
||||||
expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chooser");
|
expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chooser");
|
||||||
expect(outlet.querySelector("mcp-app-view")).not.toBeNull();
|
expect(teardown).toHaveBeenCalledOnce();
|
||||||
router.stop();
|
router.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { ThemeMode } from "../app/theme.ts";
|
|||||||
import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts";
|
import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts";
|
||||||
import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts";
|
import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts";
|
||||||
import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.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 { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
||||||
import type { NewSessionTarget } from "../pages/new-session/location.ts";
|
import type { NewSessionTarget } from "../pages/new-session/location.ts";
|
||||||
import type { SidebarWorkboardBoard, SidebarWorkboardRenderers } from "./app-sidebar-workboard.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 {
|
readNewSessionAccess(): SessionMethodAccess {
|
||||||
return readSessionMethodAccess(this.connected ? this.context?.gateway.snapshot : null, {
|
return readSessionMethodAccess(this.connected ? this.context?.gateway.snapshot : null, {
|
||||||
method: "sessions.create",
|
method: "sessions.create",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
|
|||||||
import { isCronSessionKey } from "../lib/session-display.ts";
|
import { isCronSessionKey } from "../lib/session-display.ts";
|
||||||
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
|
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
|
||||||
import { filterVisibleSessionRows, sessionMatchesArchivedFilter } from "../lib/sessions/index.ts";
|
import { filterVisibleSessionRows, sessionMatchesArchivedFilter } from "../lib/sessions/index.ts";
|
||||||
|
import { runSessionNavigationIntent } from "../lib/sessions/navigation-handoff.ts";
|
||||||
import {
|
import {
|
||||||
composerDraftSearch,
|
composerDraftSearch,
|
||||||
resolveSessionPreferredFace,
|
resolveSessionPreferredFace,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
areUiSessionKeysEquivalent,
|
areUiSessionKeysEquivalent,
|
||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
resolveUiConfiguredMainKey,
|
|
||||||
resolveUiDefaultAgentId,
|
resolveUiDefaultAgentId,
|
||||||
resolveUiSessionNavigationParentKey,
|
resolveUiSessionNavigationParentKey,
|
||||||
} from "../lib/sessions/session-key.ts";
|
} from "../lib/sessions/session-key.ts";
|
||||||
@@ -127,7 +127,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
private readonly runtimeSampledAtByRow = new WeakMap<GatewaySessionRow, number>();
|
private readonly runtimeSampledAtByRow = new WeakMap<GatewaySessionRow, number>();
|
||||||
private readonly attention = new SessionAttentionController(this);
|
private readonly attention = new SessionAttentionController(this);
|
||||||
|
|
||||||
// Controller order preserves the former inheritance-chain field initialization order.
|
|
||||||
declare readonly sessionOrganizer: SessionOrganizerController;
|
declare readonly sessionOrganizer: SessionOrganizerController;
|
||||||
declare readonly sidebarMenus: SidebarMenusController;
|
declare readonly sidebarMenus: SidebarMenusController;
|
||||||
|
|
||||||
@@ -174,7 +173,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
];
|
];
|
||||||
for (const row of liveRows) {
|
for (const row of liveRows) {
|
||||||
if (adopted.has(row.key) && !byKey.has(row.key)) {
|
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()];
|
return [...byKey.values()];
|
||||||
@@ -222,7 +221,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
void this.sessionData.loadChildSessions(session.key);
|
void this.sessionData.loadChildSessions(session.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The hidden main row needs an eager child fetch or its threads never surface.
|
|
||||||
const mainRow = this.mainSessionRow();
|
const mainRow = this.mainSessionRow();
|
||||||
if (
|
if (
|
||||||
mainRow &&
|
mainRow &&
|
||||||
@@ -257,10 +255,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0;
|
return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected projectSidebarSession(row: GatewaySessionRow): SidebarRecentSession {
|
|
||||||
return this.getSessionNavigationState().toSidebarSession(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRouteSessionKey(): string {
|
public getRouteSessionKey(): string {
|
||||||
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
|
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
|
||||||
}
|
}
|
||||||
@@ -320,9 +314,16 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
preferenceDerivedFace: true,
|
preferenceDerivedFace: true,
|
||||||
navigationKey: sessionKey,
|
navigationKey: sessionKey,
|
||||||
});
|
});
|
||||||
this.prepareSessionNavigation(sessionKey, target.options.pathname);
|
runSessionNavigationIntent(this, {
|
||||||
this.onNavigate?.(face, target.options);
|
commit: () => {
|
||||||
this.bindLiteralSession(sessionKey, this.selectedAgentIdForSessions(), target.options);
|
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. */
|
/** Collapsed zones keep full rows for true header counts and status dots. */
|
||||||
@@ -331,8 +332,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
rows,
|
rows,
|
||||||
grouping: this.sessionsGrouping,
|
grouping: this.sessionsGrouping,
|
||||||
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [],
|
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [],
|
||||||
// Raw gateway-owned order: grouping normalizes it against the full
|
// Normalize gateway order without dropping catalog-lagging categories.
|
||||||
// discovered category set without dropping catalog-lagging categories.
|
|
||||||
sectionOrder: this.knownSectionOrder(),
|
sectionOrder: this.knownSectionOrder(),
|
||||||
catalogIds:
|
catalogIds:
|
||||||
this.sessionsStatusFilter === "archived"
|
this.sessionsStatusFilter === "archived"
|
||||||
@@ -404,7 +404,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
if (session.isChild || event.defaultPrevented || event.button !== 0) {
|
if (session.isChild || event.defaultPrevented || event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Modified parent clicks build multi-select; middle-click keeps native new-tab behavior.
|
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.toggleSessionSelected(session.key);
|
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. */
|
/** Offline routes to Settings instead of a dead chat load. */
|
||||||
private openAgentConversation(agentId: string) {
|
private openAgentConversation(agentId: string) {
|
||||||
if (!this.connected) {
|
if (!this.connected) {
|
||||||
@@ -626,10 +618,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
|||||||
showCron: this.sessionsShowCron,
|
showCron: this.sessionsShowCron,
|
||||||
archivedFilter: this.sessionsStatusFilter,
|
archivedFilter: this.sessionsStatusFilter,
|
||||||
}).toSorted(this.compareSidebarSessionRows);
|
}).toSorted(this.compareSidebarSessionRows);
|
||||||
// The identity card is the main session's entry point; its row leaves the
|
// The identity card replaces the main row; promote children under all equivalent aliases.
|
||||||
// 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.
|
|
||||||
const mainSessionKey = this.selectedAgentMainSessionKey(selected);
|
const mainSessionKey = this.selectedAgentMainSessionKey(selected);
|
||||||
const lineageRoot = this.sessionData.activeSessionLineageRoot;
|
const lineageRoot = this.sessionData.activeSessionLineageRoot;
|
||||||
const lineageAgentId = normalizeAgentId(
|
const lineageAgentId = normalizeAgentId(
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ describeStandaloneMockServer("standalone Control UI mock server", () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page
|
page
|
||||||
.locator(".chat-thread .chat-bubble")
|
.locator(".chat-pane-cache__pane--active .chat-thread .chat-bubble")
|
||||||
.allTextContents()
|
.allTextContents()
|
||||||
.then((messages) => messages.map((message) => message.trim())),
|
.then((messages) => messages.map((message) => message.trim())),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Locator, Page } from "playwright";
|
import type { Locator, Page } from "playwright";
|
||||||
import { expect, it } from "vitest";
|
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";
|
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||||
|
|
||||||
const suite = createControlUiE2eSuite({
|
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 firstSession = "agent:main:attachment-session-a";
|
||||||
const secondSession = "agent:main:attachment-session-b";
|
const secondSession = "agent:main:attachment-session-b";
|
||||||
await suite.withPage(
|
await suite.withPage(
|
||||||
@@ -122,16 +126,17 @@ suite.define(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstSession));
|
await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstSession));
|
||||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
const activeComposer = () =>
|
||||||
await composer.fill("Private session A attachment");
|
page.locator(
|
||||||
await pastePng(composer);
|
'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox textarea',
|
||||||
|
);
|
||||||
|
await activeComposer().fill("Private session A attachment");
|
||||||
|
await pastePng(activeComposer());
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.getByRole("button", { name: "Send message" }).isDisabled())
|
.poll(() => page.getByRole("button", { name: "Send message" }).isDisabled())
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
await page.locator("openclaw-chat-pane").evaluate((pane, sessionKey) => {
|
await navigateToControlUiSession(page, secondSession);
|
||||||
(pane as HTMLElement & { sessionKey: string }).sessionKey = sessionKey;
|
|
||||||
}, secondSession);
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
@@ -141,17 +146,36 @@ suite.define(() => {
|
|||||||
.attachmentReadProof.aborts,
|
.attachmentReadProof.aborts,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toBe(1);
|
.toBe(0);
|
||||||
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).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 activeComposer().fill("Safe session B message");
|
||||||
await composer.press("Enter");
|
await activeComposer().press("Enter");
|
||||||
const request = await gateway.waitForRequest("chat.send");
|
const request = await gateway.waitForRequest("chat.send");
|
||||||
expect(request.params).toMatchObject({
|
expect(request.params).toMatchObject({
|
||||||
message: "Safe session B message",
|
message: "Safe session B message",
|
||||||
sessionKey: secondSession,
|
sessionKey: secondSession,
|
||||||
});
|
});
|
||||||
expect((request.params as { attachments?: unknown }).attachments).toBeUndefined();
|
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.
|
// Control UI E2E tests cover the redesigned chat composer.
|
||||||
import { expect, it } from "vitest";
|
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";
|
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||||
|
|
||||||
const suite = createControlUiE2eSuite({
|
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 }) => {
|
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, {
|
const gateway = await installMockGateway(page, {
|
||||||
defaultAgentId: "work",
|
defaultAgentId: "work",
|
||||||
sessionKey: "agent:work:main",
|
sessionKey: "agent:work:main",
|
||||||
methodResponses: {
|
methodResponses: {
|
||||||
"chat.metadata": {
|
"chat.startup": {
|
||||||
cases: [
|
cases: [
|
||||||
{
|
{
|
||||||
match: { agentId: "work" },
|
match: { sessionKey: "agent:work:main" },
|
||||||
response: {
|
response: startupResponse("work-session", workModel),
|
||||||
commands: [],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "work-model",
|
|
||||||
name: "Work Model",
|
|
||||||
provider: "openai",
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: { agentId: "other" },
|
match: { sessionKey: "agent:other:main" },
|
||||||
response: {
|
response: startupResponse("other-session", otherModel),
|
||||||
commands: [],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "other-model",
|
|
||||||
name: "Other Model",
|
|
||||||
provider: "anthropic",
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -672,177 +683,41 @@ suite.define(() => {
|
|||||||
ts: Date.now(),
|
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 page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:work:main"));
|
||||||
await gateway.waitForRequest("chat.startup");
|
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);
|
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
|
await expect
|
||||||
.poll(() => composer.locator('[data-chat-model-option="openai/work-model"]').count())
|
.poll(() =>
|
||||||
.toBe(1);
|
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
|
||||||
|
|
||||||
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(" "),
|
|
||||||
)
|
)
|
||||||
.not.toContain("GPT Default");
|
.toBe(1);
|
||||||
const metadataRequests = await gateway.getRequests("chat.metadata");
|
|
||||||
|
await navigateToControlUiSession(page, "agent:other:main");
|
||||||
|
const startupRequests = await gateway.getRequests("chat.startup");
|
||||||
expect(
|
expect(
|
||||||
metadataRequests.filter(
|
startupRequests.filter(
|
||||||
(request) => (request.params as { agentId?: string } | undefined)?.agentId === "work",
|
(request) =>
|
||||||
|
(request.params as { sessionKey?: string } | undefined)?.sessionKey ===
|
||||||
|
"agent:other:main",
|
||||||
),
|
),
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
expect(
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
|
||||||
metadataRequests.every(
|
await expect
|
||||||
(request) =>
|
.poll(() =>
|
||||||
typeof (request.params as { agentId?: string } | undefined)?.agentId === "string",
|
activeComposer().locator('[data-chat-model-option="anthropic/other-model"]').count(),
|
||||||
),
|
)
|
||||||
).toBe(true);
|
.toBe(1);
|
||||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
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 });
|
await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 });
|
||||||
const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length;
|
const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length;
|
||||||
await sessionLink(sessionB).click();
|
await sessionLink(sessionB).click();
|
||||||
await expect
|
|
||||||
.poll(async () => (await gateway.getRequests("chat.history")).length)
|
|
||||||
.toBeGreaterThan(historyRequestsBeforeReturn);
|
|
||||||
await expectTrace();
|
await expectTrace();
|
||||||
|
expect(await gateway.getRequests("chat.history")).toHaveLength(historyRequestsBeforeReturn);
|
||||||
if (artifactDir) {
|
if (artifactDir) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
@@ -252,10 +250,11 @@ suite.define(() => {
|
|||||||
expect(requireRecord(historyRequest.params)).toMatchObject({
|
expect(requireRecord(historyRequest.params)).toMatchObject({
|
||||||
sessionKey: "agent:main:session-b",
|
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,
|
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,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await expect
|
await expect
|
||||||
@@ -273,11 +272,11 @@ suite.define(() => {
|
|||||||
|
|
||||||
await waitForChatScrollIdle(page);
|
await waitForChatScrollIdle(page);
|
||||||
await scrollChatThreadToTop(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,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await scrollChatThreadToTop(page);
|
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,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await scrollChatThreadToTop(page);
|
await scrollChatThreadToTop(page);
|
||||||
@@ -407,18 +406,17 @@ suite.define(() => {
|
|||||||
);
|
);
|
||||||
await sessionB.click();
|
await sessionB.click();
|
||||||
await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 });
|
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 thread.hover();
|
||||||
await page.mouse.wheel(0, -1_000_000);
|
await page.mouse.wheel(0, -1_000_000);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page
|
activePane.evaluate(
|
||||||
.locator("openclaw-chat-pane")
|
(element) =>
|
||||||
.evaluate(
|
(element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages
|
||||||
(element) =>
|
.length,
|
||||||
(element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages
|
),
|
||||||
.length,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.toBe(140);
|
.toBe(140);
|
||||||
// Prepending preserves the visible anchor. A renewed upward gesture
|
// Prepending preserves the visible anchor. A renewed upward gesture
|
||||||
@@ -445,15 +443,15 @@ suite.define(() => {
|
|||||||
).chatSessionReturnSamples = samples;
|
).chatSessionReturnSamples = samples;
|
||||||
const deadline = performance.now() + 750;
|
const deadline = performance.now() + 750;
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
const pane = document.querySelector("openclaw-chat-pane") as
|
const pane = document.querySelector('openclaw-chat-pane[aria-hidden="false"]') as
|
||||||
| (HTMLElement & {
|
| (HTMLElement & {
|
||||||
state?: { chatMessages?: unknown[]; sessionKey?: string };
|
state?: { chatMessages?: unknown[]; sessionKey?: string };
|
||||||
})
|
})
|
||||||
| null;
|
| 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({
|
samples.push({
|
||||||
hiddenNotice: document.body.textContent?.includes("Showing last") ?? false,
|
hiddenNotice: pane?.textContent?.includes("Showing last") ?? false,
|
||||||
loading: document.querySelector(".chat-history-loading") !== null,
|
loading: pane?.querySelector(".chat-history-loading") !== null,
|
||||||
messageCount: pane?.state?.chatMessages?.length ?? 0,
|
messageCount: pane?.state?.chatMessages?.length ?? 0,
|
||||||
minOpacity: rows.reduce(
|
minOpacity: rows.reduce(
|
||||||
(minimum, row) => Math.min(minimum, Number.parseFloat(getComputedStyle(row).opacity)),
|
(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.hiddenNotice)).toBe(true);
|
||||||
expect(returnedSamples.every((sample) => !sample.loading)).toBe(true);
|
expect(returnedSamples.every((sample) => !sample.loading)).toBe(true);
|
||||||
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
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) {
|
if (artifactDir) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `${artifactDir}/retained-history-return.png`,
|
path: `${artifactDir}/retained-history-return.png`,
|
||||||
|
|||||||
@@ -655,13 +655,11 @@ suite.define(() => {
|
|||||||
expect(evictedImageIndex).toBeGreaterThanOrEqual(0);
|
expect(evictedImageIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl);
|
expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl);
|
||||||
|
|
||||||
const evictedPath = new URL(
|
const evictedUrl = new URL(
|
||||||
expectDefined(imageUrls[evictedImageIndex], "evicted managed image URL").replace(
|
expectDefined(imageUrls[evictedImageIndex], "evicted managed image URL"),
|
||||||
/\/full$/u,
|
|
||||||
"/thumbnail",
|
|
||||||
),
|
|
||||||
suite.server.baseUrl,
|
suite.server.baseUrl,
|
||||||
).pathname;
|
);
|
||||||
|
const evictedPath = evictedUrl.pathname.replace(/\/full$/u, "/thumbnail");
|
||||||
const fetchesBeforeRevisit = fetchedMedia.filter(
|
const fetchesBeforeRevisit = fetchedMedia.filter(
|
||||||
(request) => request.pathname === evictedPath,
|
(request) => request.pathname === evictedPath,
|
||||||
).length;
|
).length;
|
||||||
|
|||||||
@@ -106,13 +106,16 @@ suite.define(() => {
|
|||||||
|
|
||||||
const main = page.getByRole("main");
|
const main = page.getByRole("main");
|
||||||
const openModelSelect = async () => {
|
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 });
|
await trigger.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
return trigger;
|
return trigger;
|
||||||
};
|
};
|
||||||
const selectModel = async (value: string) => {
|
const selectModel = async (value: string) => {
|
||||||
await main.locator('[data-chat-model-select="true"]').click();
|
const activePane = main.locator('openclaw-chat-pane[aria-hidden="false"]');
|
||||||
const option = main.locator(`[data-chat-model-option="${value}"]`);
|
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.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
await option.click();
|
await option.click();
|
||||||
};
|
};
|
||||||
@@ -345,9 +348,10 @@ suite.define(() => {
|
|||||||
try {
|
try {
|
||||||
await page.goto(`${suite.server.baseUrl}chat`);
|
await page.goto(`${suite.server.baseUrl}chat`);
|
||||||
const main = page.getByRole("main");
|
const main = page.getByRole("main");
|
||||||
const modelSelect = main.locator('[data-chat-model-select="true"]').first();
|
const activePane = main.locator('openclaw-chat-pane[aria-hidden="false"]');
|
||||||
const effortSelect = main.locator('[data-chat-thinking-select="true"]').first();
|
const modelSelect = activePane.locator('[data-chat-model-select="true"]');
|
||||||
const thinkingSlider = main.locator('[data-chat-thinking-slider="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(",");
|
const expectedThinkingValues = thinkingLevels.map((level) => level.id).join(",");
|
||||||
|
|
||||||
await modelSelect.waitFor({ state: "visible", timeout: 10_000 });
|
await modelSelect.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
@@ -355,7 +359,7 @@ suite.define(() => {
|
|||||||
expect(await modelSelect.textContent()).not.toContain("@openai:");
|
expect(await modelSelect.textContent()).not.toContain("@openai:");
|
||||||
await modelSelect.click();
|
await modelSelect.click();
|
||||||
await expect
|
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);
|
.toBe(1);
|
||||||
expect(
|
expect(
|
||||||
(await main.locator("[data-chat-model-option]").allTextContents()).join(" "),
|
(await main.locator("[data-chat-model-option]").allTextContents()).join(" "),
|
||||||
@@ -379,7 +383,7 @@ suite.define(() => {
|
|||||||
});
|
});
|
||||||
await modelSelect.click();
|
await modelSelect.click();
|
||||||
await expect
|
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);
|
.toBe(1);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => thinkingSlider.getAttribute("data-chat-thinking-values"))
|
.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({
|
const context = await suite.newBrowserContext({
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
@@ -123,7 +123,7 @@ suite.define(() => {
|
|||||||
try {
|
try {
|
||||||
await page.goto(`${suite.server.baseUrl}chat`);
|
await page.goto(`${suite.server.baseUrl}chat`);
|
||||||
await waitForChatScrollIdle(page);
|
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);
|
await expect.poll(() => thread.count()).toBe(1);
|
||||||
const initialDistance = await thread.evaluate((element) => {
|
const initialDistance = await thread.evaluate((element) => {
|
||||||
const transcript = element as HTMLElement;
|
const transcript = element as HTMLElement;
|
||||||
@@ -151,17 +151,11 @@ suite.define(() => {
|
|||||||
});
|
});
|
||||||
expect(firstVisitDistance).toBeLessThanOrEqual(8);
|
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;
|
const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length;
|
||||||
await sessionLink(sessionA).click();
|
await sessionLink(sessionA).click();
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionA));
|
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);
|
await waitForChatScrollIdle(page);
|
||||||
|
expect(await gateway.getRequests("chat.history")).toHaveLength(historyRequestsBeforeReturn);
|
||||||
|
|
||||||
const restored = await thread.evaluate((element) => {
|
const restored = await thread.evaluate((element) => {
|
||||||
const transcript = element as HTMLElement;
|
const transcript = element as HTMLElement;
|
||||||
@@ -177,17 +171,13 @@ suite.define(() => {
|
|||||||
).toBeLessThanOrEqual(120);
|
).toBeLessThanOrEqual(120);
|
||||||
expect(restored.distanceFromBottom).toBeGreaterThan(8);
|
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;
|
const historyRequestsBeforeEndReturn = (await gateway.getRequests("chat.history")).length;
|
||||||
await sessionLink(sessionB).click();
|
await sessionLink(sessionB).click();
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionB));
|
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);
|
await waitForChatScrollIdle(page);
|
||||||
|
expect(await gateway.getRequests("chat.history")).toHaveLength(
|
||||||
|
historyRequestsBeforeEndReturn,
|
||||||
|
);
|
||||||
const endAnchoredDistance = await thread.evaluate((element) => {
|
const endAnchoredDistance = await thread.evaluate((element) => {
|
||||||
const transcript = element as HTMLElement;
|
const transcript = element as HTMLElement;
|
||||||
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight;
|
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight;
|
||||||
@@ -727,7 +717,10 @@ suite.define(() => {
|
|||||||
try {
|
try {
|
||||||
await page.goto(`${suite.server.baseUrl}chat`);
|
await page.goto(`${suite.server.baseUrl}chat`);
|
||||||
await page.locator(`.sidebar-recent-session[data-session-key="${secondKey}"]`).waitFor();
|
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);
|
await page.waitForTimeout(500);
|
||||||
const initialListCount = (await gateway.getRequests("sessions.list")).length;
|
const initialListCount = (await gateway.getRequests("sessions.list")).length;
|
||||||
const initialMetadataCount = (await gateway.getRequests("chat.metadata")).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`,
|
`.sidebar-recent-session[data-session-key="${secondKey}"] a.sidebar-recent-session__link`,
|
||||||
)
|
)
|
||||||
.click();
|
.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(
|
const emptyOutboxListRequests = (await gateway.getRequests("sessions.list")).slice(
|
||||||
initialListCount,
|
initialListCount,
|
||||||
);
|
);
|
||||||
@@ -746,7 +742,7 @@ suite.define(() => {
|
|||||||
expect(await gateway.getRequests("chat.metadata")).toHaveLength(initialMetadataCount);
|
expect(await gateway.getRequests("chat.metadata")).toHaveLength(initialMetadataCount);
|
||||||
const emptyOutboxListCount = initialListCount + emptyOutboxListRequests.length;
|
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 = (
|
const state = (
|
||||||
pane as HTMLElement & {
|
pane as HTMLElement & {
|
||||||
state: {
|
state: {
|
||||||
@@ -785,7 +781,10 @@ suite.define(() => {
|
|||||||
`.sidebar-recent-session[data-session-key="${firstKey}"] a.sidebar-recent-session__link`,
|
`.sidebar-recent-session[data-session-key="${firstKey}"] a.sidebar-recent-session__link`,
|
||||||
)
|
)
|
||||||
.click();
|
.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
|
await expect
|
||||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||||
.toBe(emptyOutboxListCount + 1);
|
.toBe(emptyOutboxListCount + 1);
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ suite.define(() => {
|
|||||||
] = marker;
|
] = marker;
|
||||||
return 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 avatar.waitFor({ state: "visible" });
|
||||||
await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/);
|
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> {
|
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;
|
const thread = element as HTMLElement;
|
||||||
return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight);
|
return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight);
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ export async function waitForChatScrollIdle(page: Page): Promise<void> {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.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 thread = element as HTMLElement;
|
||||||
const readGeometry = () => ({
|
const readGeometry = () => ({
|
||||||
clientHeight: thread.clientHeight,
|
clientHeight: thread.clientHeight,
|
||||||
@@ -159,7 +159,7 @@ export async function waitForChatScrollIdle(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollChatThreadToTop(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;
|
const thread = element as HTMLElement;
|
||||||
thread.scrollTop = 0;
|
thread.scrollTop = 0;
|
||||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
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[]> {
|
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 thread = element as HTMLElement;
|
||||||
const viewport = thread.getBoundingClientRect();
|
const viewport = thread.getBoundingClientRect();
|
||||||
return Array.from(thread.querySelectorAll(".chat-bubble"))
|
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("Older remote review", { exact: true }).waitFor();
|
||||||
await page.getByText("Remote architecture review", { exact: true }).click();
|
await page.getByText("Remote architecture review", { exact: true }).click();
|
||||||
await expect.poll(() => page.getByText("newer answer", { exact: true }).count()).toBe(1);
|
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
|
await expect
|
||||||
.poll(() => thread.evaluate((element) => element.scrollHeight > element.clientHeight + 100))
|
.poll(() => thread.evaluate((element) => element.scrollHeight > element.clientHeight + 100))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
@@ -645,37 +646,40 @@ suite.define(() => {
|
|||||||
element.dispatchEvent(new Event("scroll"));
|
element.dispatchEvent(new Event("scroll"));
|
||||||
});
|
});
|
||||||
await page.clock.runFor(100);
|
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
|
await expect
|
||||||
.poll(() => gateway.getRequests("sessions.catalog.read").then((requests) => requests.length))
|
.poll(() => gateway.getRequests("sessions.catalog.read").then((requests) => requests.length))
|
||||||
.toBe(initialReadCount + 1);
|
.toBe(initialReadCount + 1);
|
||||||
await page.locator(".chat-history-loading").waitFor();
|
await catalogPane.locator(".chat-history-loading").waitFor();
|
||||||
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
||||||
const anchor = await firstVisibleVirtualRow(thread);
|
const anchor = await firstVisibleVirtualRow(thread);
|
||||||
await startVirtualRowPrependProbe(thread, anchor);
|
await startVirtualRowPrependProbe(thread, anchor);
|
||||||
await gateway.resolveDeferred("sessions.catalog.read");
|
await gateway.resolveDeferred("sessions.catalog.read");
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page
|
catalogPane.evaluate(
|
||||||
.locator("openclaw-chat-pane")
|
(element) =>
|
||||||
.evaluate(
|
(element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length,
|
||||||
(element) =>
|
),
|
||||||
(element as HTMLElement & { catalogMessages: unknown[] }).catalogMessages.length,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.toBe(41);
|
.toBe(41);
|
||||||
await page.clock.runFor(100);
|
await page.clock.runFor(100);
|
||||||
expectStableVirtualRowPrepend(anchor, await finishVirtualRowPrependProbe(thread));
|
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
|
await expect
|
||||||
.poll(() => page.getByText("This session is on a paired device and is view-only.").count())
|
.poll(() => page.getByText("This session is on a paired device and is view-only.").count())
|
||||||
.toBe(1);
|
.toBe(1);
|
||||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||||
const expectCenteredLayout = async (screenshotName: string) => {
|
const expectCenteredLayout = async (screenshotName: string) => {
|
||||||
const [workbenchBox, threadBox, composerBox] = await Promise.all([
|
const [workbenchBox, threadBox, composerBox] = await Promise.all([
|
||||||
page.locator(".chat-workbench").boundingBox(),
|
catalogPane.locator(".chat-workbench").boundingBox(),
|
||||||
page.locator(".chat-thread-inner").boundingBox(),
|
catalogPane.locator(".chat-thread-inner").boundingBox(),
|
||||||
page.locator(".agent-chat__composer-shell").boundingBox(),
|
catalogPane.locator(".agent-chat__composer-shell").boundingBox(),
|
||||||
]);
|
]);
|
||||||
expect(workbenchBox).not.toBeNull();
|
expect(workbenchBox).not.toBeNull();
|
||||||
expect(threadBox).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(() => thread.evaluate((element) => element.scrollTop)).toBe(0);
|
||||||
await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1);
|
await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1);
|
||||||
await page.clock.runFor(500);
|
await page.clock.runFor(500);
|
||||||
expect(await page.locator(".chat-history-loading").count()).toBe(0);
|
expect(await catalogPane.locator(".chat-history-loading").count()).toBe(0);
|
||||||
expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0);
|
||||||
expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(exhaustedReadCount);
|
expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(exhaustedReadCount);
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -958,8 +958,11 @@ suite.define(() => {
|
|||||||
await page.goto(`${suite.server.baseUrl}chat`);
|
await page.goto(`${suite.server.baseUrl}chat`);
|
||||||
await expandCodingSection(page);
|
await expandCodingSection(page);
|
||||||
await page.getByText("Release checklist", { exact: true }).click();
|
await page.getByText("Release checklist", { exact: true }).click();
|
||||||
await expect.poll(() => page.getByText("prepare release", { exact: true }).count()).toBe(1);
|
const catalogPane = page
|
||||||
const composer = page.locator(".agent-chat__composer-combobox > textarea");
|
.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 composer.fill("continue with the final checks");
|
||||||
await gateway.setMethodResponse("sessions.list", {
|
await gateway.setMethodResponse("sessions.list", {
|
||||||
count: 1,
|
count: 1,
|
||||||
|
|||||||
@@ -115,8 +115,13 @@ suite.define(() => {
|
|||||||
await page.getByText("OpenCode release review", { exact: true }).click();
|
await page.getByText("OpenCode release review", { exact: true }).click();
|
||||||
await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1);
|
await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1);
|
||||||
await page.getByText("Pi architecture notes", { exact: true }).click();
|
await page.getByText("Pi architecture notes", { exact: true }).click();
|
||||||
await expect.poll(() => page.getByText("Pi transcript loaded").count()).toBe(1);
|
const piPane = page
|
||||||
expect(await page.locator(".agent-chat__composer-combobox > textarea").isDisabled()).toBe(true);
|
.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);
|
expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(2);
|
||||||
|
|
||||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
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 },
|
viewport: { width: 1280, height: 800 },
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const mediaPath =
|
const sourcePath =
|
||||||
"/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-000000000001/full";
|
"/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(
|
const imageBytes = await readFile(
|
||||||
path.join(process.cwd(), "docs/assets/openclaw-banner-dark.png"),
|
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",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: "Managed attachment proof" },
|
{ type: "text", text: "Managed attachment proof" },
|
||||||
{ type: "image", url: mediaPath, alt: "Managed proof image" },
|
{ type: "image", url: sourcePath, alt: "Managed proof image" },
|
||||||
],
|
],
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -230,16 +230,6 @@ suite.define(() => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toBe("connected");
|
.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) {
|
if (captureUiProofEnabled) {
|
||||||
await mkdir(reconnectProofArtifactDir, { recursive: true });
|
await mkdir(reconnectProofArtifactDir, { recursive: true });
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
|
|||||||
@@ -334,7 +334,9 @@ suite.define(() => {
|
|||||||
await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 });
|
await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 });
|
||||||
await rowFor(selected.key).locator("a").first().click();
|
await rowFor(selected.key).locator("a").first().click();
|
||||||
await assertSelectedRoute();
|
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) {
|
for (const row of batchRows) {
|
||||||
await rowFor(row.key).click({ modifiers: ["Meta"] });
|
await rowFor(row.key).click({ modifiers: ["Meta"] });
|
||||||
@@ -382,7 +384,9 @@ suite.define(() => {
|
|||||||
const archivedNotice = page.locator(".agent-chat__disabled-banner");
|
const archivedNotice = page.locator(".agent-chat__disabled-banner");
|
||||||
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
|
await archivedNotice.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived.");
|
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 archivedNotice.getByRole("button", { name: "Unarchive" }).click();
|
||||||
await waitForPatch(
|
await waitForPatch(
|
||||||
@@ -398,7 +402,9 @@ suite.define(() => {
|
|||||||
|
|
||||||
await assertSelectedRoute();
|
await assertSelectedRoute();
|
||||||
await archivedNotice.waitFor({ state: "detached", timeout: 10_000 });
|
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 {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ suite.define(() => {
|
|||||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstKey));
|
await page.goto(controlUiSessionUrl(suite.server.baseUrl, firstKey));
|
||||||
const firstRow = page.locator(`[data-session-key="${firstKey}"]`);
|
const firstRow = page.locator(`[data-session-key="${firstKey}"]`);
|
||||||
const secondRow = page.locator(`[data-session-key="${secondKey}"]`);
|
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 firstRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
await secondRow.waitFor({ state: "visible" });
|
await secondRow.waitFor({ state: "visible" });
|
||||||
await composer.waitFor({ state: "visible" });
|
await composer.waitFor({ state: "visible" });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BoardFace } from "../board/settings.ts";
|
||||||
|
|
||||||
type SessionNavigationHandoff = {
|
type SessionNavigationHandoff = {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@@ -13,10 +15,59 @@ type SessionNavigationHandoffOwner = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SESSION_NAVIGATION_HANDOFF_TTL_MS = 2_000;
|
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<
|
const sessionNavigationHandoffs = new WeakMap<
|
||||||
SessionNavigationHandoffOwner,
|
SessionNavigationHandoffOwner,
|
||||||
SessionNavigationHandoff
|
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(
|
export function prepareSessionNavigationHandoff(
|
||||||
owner: SessionNavigationHandoffOwner,
|
owner: SessionNavigationHandoffOwner,
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ export function cloneChatAttachmentsMetadata(
|
|||||||
return attachments.map(cloneChatAttachmentMetadata);
|
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 {
|
export function releaseChatAttachmentPayload(id: string): void {
|
||||||
const payload = payloads.get(id);
|
const payload = payloads.get(id);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type ChatComposerMemoryFallbackOwnership = {
|
|||||||
sequence: number;
|
sequence: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveChatComposerMemoryFallback(
|
function resolveChatComposerMemoryFallback(
|
||||||
state: ChatPageHost,
|
state: ChatPageHost,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
scopeOverride?: StoredChatOutboxScope,
|
scopeOverride?: StoredChatOutboxScope,
|
||||||
|
|||||||
@@ -374,6 +374,27 @@ describe("handleChatGatewayEvent", () => {
|
|||||||
).toEqual([payload.message]);
|
).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([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "canonical default-session finals under the main alias",
|
name: "canonical default-session finals under the main alias",
|
||||||
|
|||||||
@@ -193,12 +193,19 @@ function appendCachedChatMessage(
|
|||||||
state: ChatState,
|
state: ChatState,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
message: unknown,
|
message: unknown,
|
||||||
|
eventClaim: object,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
) {
|
) {
|
||||||
if (!state.chatMessagesBySession) {
|
if (!state.chatMessagesBySession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendChatMessageToCache(state.chatMessagesBySession, state, { sessionKey, agentId }, message);
|
appendChatMessageToCache(
|
||||||
|
state.chatMessagesBySession,
|
||||||
|
state,
|
||||||
|
{ sessionKey, agentId },
|
||||||
|
message,
|
||||||
|
eventClaim,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatEvent(
|
function handleChatEvent(
|
||||||
@@ -229,7 +236,7 @@ function handleChatEvent(
|
|||||||
const cacheAgentId = isUiGlobalSessionKey(payload.sessionKey)
|
const cacheAgentId = isUiGlobalSessionKey(payload.sessionKey)
|
||||||
? (payload.agentId ?? resolveUiDefaultAgentId(state))
|
? (payload.agentId ?? resolveUiDefaultAgentId(state))
|
||||||
: payload.agentId;
|
: payload.agentId;
|
||||||
appendCachedChatMessage(state, payload.sessionKey, finalMessage, cacheAgentId);
|
appendCachedChatMessage(state, payload.sessionKey, finalMessage, payload, cacheAgentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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 }));
|
const nativeGateways = vi.hoisted(() => ({ current: null as NativeGatewaysCapability | null }));
|
||||||
|
|
||||||
// The dedicated unit-mock-registry project keeps this complete, side-effect-only
|
// Keep this complete mock in the dedicated unit-mock-registry project.
|
||||||
// module mock from sharing a worker's mock registry with component tests.
|
|
||||||
vi.mock("./chat-pane.ts", () => ({}));
|
vi.mock("./chat-pane.ts", () => ({}));
|
||||||
vi.mock("../../app/native-gateways.runtime.ts", () => ({
|
vi.mock("../../app/native-gateways.runtime.ts", () => ({
|
||||||
nativeGatewaysCapability: () => nativeGateways.current,
|
nativeGatewaysCapability: () => nativeGateways.current,
|
||||||
@@ -54,6 +53,7 @@ type RenderedPane = HTMLElement & {
|
|||||||
focusComposer: boolean;
|
focusComposer: boolean;
|
||||||
chatMessagesBySession: ChatMessageCache;
|
chatMessagesBySession: ChatMessageCache;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
|
presented: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
paneTitle: string;
|
paneTitle: string;
|
||||||
narrow: boolean;
|
narrow: boolean;
|
||||||
@@ -62,7 +62,7 @@ type RenderedPane = HTMLElement & {
|
|||||||
gatewaysSnapshot: NativeGatewaysSnapshot | null;
|
gatewaysSnapshot: NativeGatewaysSnapshot | null;
|
||||||
onOpenSplitView?: () => void;
|
onOpenSplitView?: () => void;
|
||||||
onClosePane?: (paneId: string) => 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" };
|
type RenderedDivider = HTMLElement & { orientation: "horizontal" | "vertical" };
|
||||||
@@ -262,66 +262,9 @@ describe("chat page split layout host", () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(page.querySelector("resizable-divider")).toBeNull();
|
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");
|
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 () => {
|
it("passes the chat-owned gateway capability only to the rightmost pane", async () => {
|
||||||
const gatewaySnapshot: NativeGatewaysSnapshot = {
|
const gatewaySnapshot: NativeGatewaysSnapshot = {
|
||||||
gateways: [],
|
gateways: [],
|
||||||
@@ -644,7 +587,7 @@ describe("chat page split layout host", () => {
|
|||||||
await page.updateComplete;
|
await page.updateComplete;
|
||||||
|
|
||||||
const pane = page.querySelector<RenderedPane>("openclaw-chat-pane");
|
const pane = page.querySelector<RenderedPane>("openclaw-chat-pane");
|
||||||
pane?.onFaceChange?.("dashboard");
|
pane?.onFaceChange?.(pane.paneId, pane.sessionKey, "dashboard");
|
||||||
const expectedSearch = catalogSessionSearch(CATALOG_KEY);
|
const expectedSearch = catalogSessionSearch(CATALOG_KEY);
|
||||||
expect(navigation.navigate).toHaveBeenCalledWith("dashboard", {
|
expect(navigation.navigate).toHaveBeenCalledWith("dashboard", {
|
||||||
pathname: "/dashboard/research",
|
pathname: "/dashboard/research",
|
||||||
@@ -679,7 +622,7 @@ describe("chat page split layout host", () => {
|
|||||||
});
|
});
|
||||||
navigation.navigate.mockClear();
|
navigation.navigate.mockClear();
|
||||||
const pane = page.querySelector<RenderedPane>("openclaw-chat-pane");
|
const pane = page.querySelector<RenderedPane>("openclaw-chat-pane");
|
||||||
pane?.onFaceChange?.("dashboard");
|
pane?.onFaceChange?.(pane.paneId, pane.sessionKey, "dashboard");
|
||||||
expect(navigation.navigate).toHaveBeenCalledWith("dashboard", {
|
expect(navigation.navigate).toHaveBeenCalledWith("dashboard", {
|
||||||
pathname: "/dashboard/main/1234567890",
|
pathname: "/dashboard/main/1234567890",
|
||||||
});
|
});
|
||||||
@@ -783,7 +726,7 @@ describe("chat page split layout host", () => {
|
|||||||
expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { sessionKeys: [] });
|
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);
|
stubMatchMedia(true);
|
||||||
const page = new ChatPage();
|
const page = new ChatPage();
|
||||||
page.data = { sessionKey: "main" };
|
page.data = { sessionKey: "main" };
|
||||||
@@ -792,9 +735,12 @@ describe("chat page split layout host", () => {
|
|||||||
await page.updateComplete;
|
await page.updateComplete;
|
||||||
|
|
||||||
const panes = [...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")];
|
const panes = [...page.querySelectorAll<RenderedPane>("openclaw-chat-pane")];
|
||||||
expect(panes.map((pane) => pane.paneId)).toEqual(["p2"]);
|
expect(panes.map((pane) => pane.paneId)).toEqual(["p1", "p2"]);
|
||||||
expect(itemAt(panes, 0, "rendered pane").active).toBe(true);
|
expect(panes.filter((pane) => pane.active).map((pane) => pane.paneId)).toEqual(["p2"]);
|
||||||
expect(itemAt(panes, 0, "rendered pane").narrow).toBe(true);
|
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();
|
expect(page.querySelector("resizable-divider")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -815,7 +761,7 @@ describe("chat page split layout host", () => {
|
|||||||
|
|
||||||
const narrowPane = itemAt(
|
const narrowPane = itemAt(
|
||||||
page.querySelectorAll<RenderedPane>("openclaw-chat-pane"),
|
page.querySelectorAll<RenderedPane>("openclaw-chat-pane"),
|
||||||
0,
|
1,
|
||||||
"active narrow pane",
|
"active narrow pane",
|
||||||
);
|
);
|
||||||
expect(narrowPane).toBe(activePane);
|
expect(narrowPane).toBe(activePane);
|
||||||
|
|||||||
+94
-103
@@ -10,22 +10,22 @@ import "../../components/resizable-divider.ts";
|
|||||||
import { McpAppUnmountGate } from "../../components/mcp-app-unmount.ts";
|
import { McpAppUnmountGate } from "../../components/mcp-app-unmount.ts";
|
||||||
import { UI_COMMAND_EVENT, type UiCommandDetail } from "../../components/panel-toggle-contract.ts";
|
import { UI_COMMAND_EVENT, type UiCommandDetail } from "../../components/panel-toggle-contract.ts";
|
||||||
import { t } from "../../i18n/index.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 { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts";
|
||||||
import { resolveSessionKey } from "../../lib/sessions/index.ts";
|
|
||||||
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
|
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
|
||||||
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
||||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||||
import { persistSessionBoardFace } from "./chat-board-face-persistence.ts";
|
|
||||||
import { stillOwnsCanonicalLocation } from "./chat-canonical-location.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 { closeStagedPane, resumeStagedPanes } from "./chat-pane-attachment-handoff.ts";
|
||||||
import { ChatViewerPresenceController } from "./chat-viewer-presence.ts";
|
import { ChatViewerPresenceController } from "./chat-viewer-presence.ts";
|
||||||
import "../../styles/chat.css";
|
import "../../styles/chat.css";
|
||||||
import "./chat-pane.ts";
|
import "./chat-pane.ts";
|
||||||
import { RouteDraftComposerFocus, type ChatPaneElement } from "./route-draft-focus-handoff.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 { SessionChatRouteData } from "./route-loader.ts";
|
||||||
import type { ChatMessageCache } from "./session-message-cache.ts";
|
import type { ChatMessageCache } from "./session-message-cache.ts";
|
||||||
import {
|
import {
|
||||||
@@ -47,12 +47,12 @@ import {
|
|||||||
singlePaneLayout,
|
singlePaneLayout,
|
||||||
splitRatio,
|
splitRatio,
|
||||||
splitWeight,
|
splitWeight,
|
||||||
visiblePanesOf,
|
|
||||||
type ChatSplitLayout,
|
type ChatSplitLayout,
|
||||||
type ChatSplitPane,
|
type ChatSplitPane,
|
||||||
} from "./split-layout.ts";
|
} from "./split-layout.ts";
|
||||||
|
|
||||||
type DropIndicator = { paneId: string; zone: SplitDropZone; rect: SplitDropRect };
|
type DropIndicator = { paneId: string; zone: SplitDropZone; rect: SplitDropRect };
|
||||||
|
|
||||||
export class ChatPage extends OpenClawLightDomElement {
|
export class ChatPage extends OpenClawLightDomElement {
|
||||||
@consume({ context: applicationContext, subscribe: true })
|
@consume({ context: applicationContext, subscribe: true })
|
||||||
private context!: ApplicationContext;
|
private context!: ApplicationContext;
|
||||||
@@ -73,7 +73,6 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
);
|
);
|
||||||
private mediaQuery: MediaQueryList | null = null;
|
private mediaQuery: MediaQueryList | null = null;
|
||||||
private mobileNavMediaQuery: 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 dragDepth = 0;
|
||||||
private dragFrame = 0;
|
private dragFrame = 0;
|
||||||
private pendingDragOver: { pane: ChatPaneElement; x: number; y: number } | null = null;
|
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 readonly chatMessagesBySession: ChatMessageCache = new Map();
|
||||||
private classicColumnId = "c1";
|
private classicColumnId = "c1";
|
||||||
private classicPaneId = "p1";
|
private classicPaneId = "p1";
|
||||||
|
private routeHref = "";
|
||||||
private readonly mcpAppUnmountGate = new McpAppUnmountGate(this);
|
private readonly mcpAppUnmountGate = new McpAppUnmountGate(this);
|
||||||
private readonly viewerPresence = new ChatViewerPresenceController(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() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.routeHref = window.location.href;
|
||||||
this.layout = loadSettings().chatSplitLayout;
|
this.layout = loadSettings().chatSplitLayout;
|
||||||
this.mediaQuery = window.matchMedia("(max-width: 1099px)");
|
this.mediaQuery = window.matchMedia("(max-width: 1099px)");
|
||||||
this.narrow = this.mediaQuery.matches;
|
this.narrow = this.mediaQuery.matches;
|
||||||
@@ -100,6 +112,7 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
this.addEventListener("drop", this.handleDrop);
|
this.addEventListener("drop", this.handleDrop);
|
||||||
window.addEventListener("dragend", this.handleWindowDragEnd);
|
window.addEventListener("dragend", this.handleWindowDragEnd);
|
||||||
window.addEventListener(UI_COMMAND_EVENT, this.handleUiCommand);
|
window.addEventListener(UI_COMMAND_EVENT, this.handleUiCommand);
|
||||||
|
this.retainedSessions.connect();
|
||||||
this.syncRouteAgent();
|
this.syncRouteAgent();
|
||||||
this.syncRouteToActivePane();
|
this.syncRouteToActivePane();
|
||||||
const layout = this.layout ?? this.classicLayout();
|
const layout = this.layout ?? this.classicLayout();
|
||||||
@@ -107,6 +120,7 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
|
this.retainedSessions.disconnect();
|
||||||
this.viewerPresence.dispose();
|
this.viewerPresence.dispose();
|
||||||
this.subscriptions.clear();
|
this.subscriptions.clear();
|
||||||
this.mediaQuery?.removeEventListener("change", this.handleViewportChange);
|
this.mediaQuery?.removeEventListener("change", this.handleViewportChange);
|
||||||
@@ -134,12 +148,12 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
const activeSessionKey = this.layout ? (activePane?.sessionKey ?? null) : undefined;
|
const activeSessionKey = this.layout ? (activePane?.sessionKey ?? null) : undefined;
|
||||||
const draftRendered = this.draftFocus.rendered(data, activeSessionKey, this.consumedDraftData);
|
const draftRendered = this.draftFocus.rendered(data, activeSessionKey, this.consumedDraftData);
|
||||||
if (changedProperties.has("data")) {
|
if (changedProperties.has("data")) {
|
||||||
|
this.routeHref = window.location.href;
|
||||||
if (
|
if (
|
||||||
data?.canonicalLocation &&
|
data?.canonicalLocation &&
|
||||||
stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data)
|
stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data)
|
||||||
) {
|
) {
|
||||||
// data.face is the loader's resolved face, which may differ from the namespace
|
// Move a route matched under the wrong namespace to its resolved board face.
|
||||||
// this route was matched under; replacing under it moves the URL to that board.
|
|
||||||
this.context.replace(data.face ?? "chat", data.canonicalLocation);
|
this.context.replace(data.face ?? "chat", data.canonicalLocation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,8 +164,7 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
this.data === data &&
|
this.data === data &&
|
||||||
stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data)
|
stillOwnsCanonicalLocation(data.canonicalLocationSource, this.consumedDraftData === data)
|
||||||
) {
|
) {
|
||||||
// A lazy chat canonicalization can resolve while the old page remains
|
// A lazy canonicalization must never replace a newer route.
|
||||||
// mounted under a cold navigation. Never replace that newer route.
|
|
||||||
this.context.replace(
|
this.context.replace(
|
||||||
data.face ?? "chat",
|
data.face ?? "chat",
|
||||||
this.consumedDraftData === data ? locationWithoutDraft(location) : location,
|
this.consumedDraftData === data ? locationWithoutDraft(location) : location,
|
||||||
@@ -160,14 +173,13 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
});
|
});
|
||||||
this.syncRouteAgent();
|
this.syncRouteAgent();
|
||||||
this.syncRouteToActivePane();
|
this.syncRouteToActivePane();
|
||||||
|
this.retainedSessions.settleRoute(data.sessionKey);
|
||||||
}
|
}
|
||||||
if (data && draftRendered) {
|
if (data && draftRendered) {
|
||||||
// Process the route draft once so later focus changes cannot hand it to another pane.
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (this.isConnected && this.data === data && this.consumedDraftData !== data) {
|
if (this.isConnected && this.data === data && this.consumedDraftData !== data) {
|
||||||
this.draftFocus.beforeDraftCleanup(data);
|
this.draftFocus.beforeDraftCleanup(data);
|
||||||
this.consumedDraftData = 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.updateRoute(data.sessionKey, true, data.face ?? "chat");
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -203,7 +215,6 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
if (command.kind !== "split" && command.kind !== "close-pane" && command.kind !== "focus") {
|
if (command.kind !== "split" && command.kind !== "close-pane" && command.kind !== "focus") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Narrow viewports leave programmatic splits to the host's navigation fallback.
|
|
||||||
if (command.kind === "split" && this.narrow) {
|
if (command.kind === "split" && this.narrow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -225,6 +236,9 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
command.kind === "close-pane" && targetPane
|
command.kind === "close-pane" && targetPane
|
||||||
? closeStagedPane(this.context, this, layout, targetPane.id)
|
? closeStagedPane(this.context, this, layout, targetPane.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (targetPane) {
|
||||||
|
this.retainedSessions.discardPane(targetPane.id);
|
||||||
|
}
|
||||||
const next = applyUiCommandToSplitLayout(layout, command, sourceSessionKey);
|
const next = applyUiCommandToSplitLayout(layout, command, sourceSessionKey);
|
||||||
if (next === layout) {
|
if (next === layout) {
|
||||||
return;
|
return;
|
||||||
@@ -262,7 +276,6 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
const pane = target?.closest<ChatPaneElement>("openclaw-chat-pane");
|
const pane = target?.closest<ChatPaneElement>("openclaw-chat-pane");
|
||||||
if (!pane || !this.contains(pane)) {
|
if (!pane || !this.contains(pane)) {
|
||||||
// Keep the last preview while the pointer crosses dividers and pane gaps.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pendingDragOver = { pane, x: event.clientX, y: event.clientY };
|
this.pendingDragOver = { pane, x: event.clientX, y: event.clientY };
|
||||||
@@ -311,7 +324,6 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
const sessionKey = readSessionDragData(event.dataTransfer);
|
const sessionKey = readSessionDragData(event.dataTransfer);
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
const pane = target?.closest<ChatPaneElement>("openclaw-chat-pane");
|
const pane = target?.closest<ChatPaneElement>("openclaw-chat-pane");
|
||||||
// A divider or gap uses the retained preview so the drop matches its indicator.
|
|
||||||
const indicator =
|
const indicator =
|
||||||
(pane && this.contains(pane)
|
(pane && this.contains(pane)
|
||||||
? this.resolveDropIndicator(pane, event.clientX, event.clientY)
|
? 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 {
|
private applySessionDrop(sessionKey: string, paneId: string, zone: SplitDropZone): void {
|
||||||
const trimmed = sessionKey.trim();
|
const trimmed = sessionKey.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -472,26 +475,31 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
|
|
||||||
private readonly handlePaneSessionChange = (
|
private readonly handlePaneSessionChange = (
|
||||||
paneId: string,
|
paneId: string,
|
||||||
|
sourceSessionKey: string,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
options?: { replace?: boolean },
|
options?: { replace?: boolean },
|
||||||
) => {
|
): boolean => {
|
||||||
const trimmed = sessionKey.trim();
|
const trimmed = sessionKey.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed || window.location.href !== this.routeHref) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const layout = this.layout;
|
const resolvedLayout = this.layout ?? this.classicLayout();
|
||||||
if (!layout) {
|
const pane = findPane(resolvedLayout, paneId)?.pane;
|
||||||
|
if (!pane || !areUiSessionKeysEquivalent(pane.sessionKey, sourceSessionKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.layout) {
|
||||||
this.updateRoute(trimmed, options?.replace);
|
this.updateRoute(trimmed, options?.replace);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const pane = findPane(layout, paneId)?.pane;
|
if (pane.sessionKey === trimmed) {
|
||||||
if (!pane || pane.sessionKey === trimmed) {
|
return true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.persistLayout(setPaneSession(layout, paneId, trimmed));
|
this.persistLayout(setPaneSession(resolvedLayout, paneId, trimmed));
|
||||||
if (layout.activePaneId === paneId) {
|
if (resolvedLayout.activePaneId === paneId) {
|
||||||
this.updateRoute(trimmed, options?.replace);
|
this.updateRoute(trimmed, options?.replace);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly openSplitView = () => {
|
private readonly openSplitView = () => {
|
||||||
@@ -521,6 +529,7 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const survivingPane = closeStagedPane(this.context, this, layout, paneId);
|
const survivingPane = closeStagedPane(this.context, this, layout, paneId);
|
||||||
|
this.retainedSessions.discardPane(paneId);
|
||||||
const next = closePane(layout, paneId);
|
const next = closePane(layout, paneId);
|
||||||
if (!next && survivingPane) {
|
if (!next && survivingPane) {
|
||||||
const survivingLocation = findPane(layout, survivingPane.id);
|
const survivingLocation = findPane(layout, survivingPane.id);
|
||||||
@@ -550,52 +559,42 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
ownerKey: string,
|
ownerKey: string,
|
||||||
showGatewayPicker: boolean,
|
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`
|
return html`
|
||||||
<div
|
<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"
|
style="flex: ${weight} 1 0"
|
||||||
@pointerdown=${() => this.handleFocusPane(pane.id)}
|
@pointerdown=${() => this.handleFocusPane(pane.id)}
|
||||||
@focusin=${() => this.handleFocusPane(pane.id)}
|
@focusin=${() => this.handleFocusPane(pane.id)}
|
||||||
>
|
>
|
||||||
<openclaw-chat-pane
|
<div class="chat-pane-cache">
|
||||||
class=${splitMode ? "chat-split-view__pane" : ""}
|
${renderRetainedChatPanes({
|
||||||
data-mcp-app-owner-key=${ownerKey}
|
active,
|
||||||
.paneId=${pane.id}
|
chatMessagesBySession: this.chatMessagesBySession,
|
||||||
.chatMessagesBySession=${this.chatMessagesBySession}
|
consumedDraftData: this.consumedDraftData,
|
||||||
.sessionKey=${pane.sessionKey}
|
context: this.context,
|
||||||
.active=${active}
|
data: this.data,
|
||||||
.draft=${draft}
|
draftFocus: this.draftFocus,
|
||||||
.focusComposer=${focus}
|
mergedChrome: this.mergedChrome,
|
||||||
.routeFace=${this.data?.face ?? "chat"}
|
narrow: this.narrow,
|
||||||
.paneTitle=${title}
|
navDrawerOpen: this.navDrawerOpen,
|
||||||
.narrow=${this.narrow}
|
onboarding: this.closest(".shell--onboarding") !== null,
|
||||||
.mergedChrome=${this.mergedChrome && active}
|
onClosePane: splitMode ? this.handleClosePane : undefined,
|
||||||
.navDrawerOpen=${this.navDrawerOpen && active}
|
onFaceChange: this.retainedSessions.changeFace,
|
||||||
.nativeGateways=${showGatewayPicker ? nativeGateways : null}
|
onFocusPane: this.handleFocusPane,
|
||||||
.gatewaysSnapshot=${showGatewayPicker ? (nativeGateways?.snapshot ?? null) : null}
|
onOpenSplitView: splitMode || this.narrow ? undefined : this.openSplitView,
|
||||||
.onboarding=${this.closest(".shell--onboarding") !== null}
|
onPaneSessionChange: this.handlePaneSessionChange,
|
||||||
.onOpenSplitView=${splitMode || this.narrow ? undefined : this.openSplitView}
|
onSessionDeleted: this.retainedSessions.removeSession,
|
||||||
.onSplitDown=${splitMode ? this.handleSplitDown : undefined}
|
onSplitDown: splitMode ? this.handleSplitDown : undefined,
|
||||||
.onSplitRight=${splitMode ? this.handleSplitRight : undefined}
|
onSplitRight: splitMode ? this.handleSplitRight : undefined,
|
||||||
.onClosePane=${splitMode ? this.handleClosePane : undefined}
|
ownerKey,
|
||||||
.onFocusPane=${this.handleFocusPane}
|
pane,
|
||||||
.onPaneSessionChange=${this.handlePaneSessionChange}
|
sessionKeys: this.retainedSessions.retain(pane),
|
||||||
.onFaceChange=${(face: BoardFace) =>
|
showGatewayPicker,
|
||||||
this.handlePaneFaceChange(pane.id, pane.sessionKey, face)}
|
splitMode,
|
||||||
></openclaw-chat-pane>
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -606,30 +605,20 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
|
|
||||||
private renderSplitLayout(layout: ChatSplitLayout, splitMode: boolean) {
|
private renderSplitLayout(layout: ChatSplitLayout, splitMode: boolean) {
|
||||||
const activeLocation = findPane(layout, layout.activePaneId);
|
const activeLocation = findPane(layout, layout.activePaneId);
|
||||||
const renderedColumns =
|
const rightmostPane = this.narrow ? activeLocation?.pane : layout.columns.at(-1)?.panes.at(-1);
|
||||||
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);
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-split-view ${this.narrow ? "chat-split-view--narrow" : ""}">
|
<div class="chat-split-view ${this.narrow ? "chat-split-view--narrow" : ""}">
|
||||||
${repeat(
|
${repeat(
|
||||||
renderedColumns,
|
layout.columns,
|
||||||
(column) => column.id,
|
(column) => column.id,
|
||||||
(column, columnIndex) => html`
|
(column, columnIndex) => html`
|
||||||
<div
|
<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(
|
style="flex: ${splitWeight(
|
||||||
renderedColumnWeights,
|
layout.columnWeights,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
"rendered split column weight",
|
"rendered split column weight",
|
||||||
)} 1 0"
|
)} 1 0"
|
||||||
@@ -643,10 +632,10 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
pane.id === layout.activePaneId,
|
pane.id === layout.activePaneId,
|
||||||
splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"),
|
splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"),
|
||||||
splitMode,
|
splitMode,
|
||||||
JSON.stringify([column.id, pane.id, pane.sessionKey]),
|
JSON.stringify([column.id, pane.id]),
|
||||||
pane.id === rightmostPane?.id,
|
pane.id === rightmostPane?.id,
|
||||||
)}
|
)}
|
||||||
${paneIndex < column.panes.length - 1
|
${!this.narrow && paneIndex < column.panes.length - 1
|
||||||
? html`
|
? html`
|
||||||
<resizable-divider
|
<resizable-divider
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -672,7 +661,7 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
${columnIndex < renderedColumns.length - 1
|
${!this.narrow && columnIndex < layout.columns.length - 1
|
||||||
? html`
|
? html`
|
||||||
<resizable-divider
|
<resizable-divider
|
||||||
.splitRatio=${splitRatio(
|
.splitRatio=${splitRatio(
|
||||||
@@ -703,16 +692,18 @@ export class ChatPage extends OpenClawLightDomElement {
|
|||||||
override render() {
|
override render() {
|
||||||
const indicator = this.dropIndicator;
|
const indicator = this.dropIndicator;
|
||||||
const layout = this.layout ?? this.classicLayout();
|
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) =>
|
const renderedPaneOwners = layout.columns.flatMap((column) =>
|
||||||
column.panes
|
column.panes.map((pane) => ({ columnId: column.id, pane })),
|
||||||
.filter((pane) => renderedPaneIds.has(pane.id))
|
|
||||||
.map((pane) => ({ columnId: column.id, pane })),
|
|
||||||
);
|
);
|
||||||
const nextPaneKeys = new Set(
|
const nextPaneKeys = new Set(
|
||||||
renderedPaneOwners.map(({ columnId, pane }) =>
|
renderedPaneOwners.flatMap(({ columnId, pane }) => {
|
||||||
JSON.stringify([columnId, pane.id, pane.sessionKey]),
|
const ownerKey = JSON.stringify([columnId, pane.id]);
|
||||||
),
|
return this.retainedSessions
|
||||||
|
.retain(pane)
|
||||||
|
.map((sessionKey) => JSON.stringify([ownerKey, sessionKey]));
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const rendered = html`
|
const rendered = html`
|
||||||
<div class="chat-split-view__drop-container">
|
<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", () => {
|
describe("staged chat attachment pane handoff", () => {
|
||||||
it("discards a mounted package before clearing a closed pane handoff", () => {
|
it("discards a mounted package before clearing a closed pane handoff", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const pane = {
|
const root = {
|
||||||
paneId: "p1",
|
querySelectorAll: () => [
|
||||||
discardStagedAttachments: () => calls.push("discard"),
|
{ paneId: "p1", discardStagedAttachments: () => calls.push("discard-one") },
|
||||||
};
|
{ paneId: "p1", discardStagedAttachments: () => calls.push("discard-two") },
|
||||||
const root = { querySelectorAll: () => [pane] } as unknown as ParentNode;
|
{ paneId: "p2", discardStagedAttachments: () => calls.push("wrong-pane") },
|
||||||
|
],
|
||||||
|
} as unknown as ParentNode;
|
||||||
const context = {
|
const context = {
|
||||||
chatAttachmentHandoff: { clearPane: () => calls.push("clear") },
|
chatAttachmentHandoff: { clearPane: () => calls.push("clear") },
|
||||||
} as unknown as ApplicationContext;
|
} as unknown as ApplicationContext;
|
||||||
@@ -69,7 +71,7 @@ describe("staged chat attachment pane handoff", () => {
|
|||||||
} satisfies ChatSplitLayout;
|
} satisfies ChatSplitLayout;
|
||||||
|
|
||||||
expect(closeStagedPane(context, root, layout, "p1")?.id).toBe("p2");
|
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", () => {
|
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 context = { chatAttachmentHandoff: handoff } as unknown as ApplicationContext;
|
||||||
const displaced = storedAttachment("displaced");
|
const displaced = storedAttachment("displaced");
|
||||||
const mounted = storedAttachment("mounted");
|
const mounted = storedAttachment("mounted");
|
||||||
|
const remount = state([]);
|
||||||
handoff.prepare({
|
handoff.prepare({
|
||||||
owner,
|
owner,
|
||||||
paneId: "p1",
|
paneId: "p1",
|
||||||
scopeKey: "active",
|
scopeKey: storedChatOutboxScopeKey(resolveStoredChatOutboxScope(remount, remount.sessionKey)),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
fallbacks: {
|
fallbacks: {
|
||||||
collision: {
|
collision: {
|
||||||
@@ -225,7 +228,6 @@ describe("staged chat attachment pane handoff", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const remount = state([]);
|
|
||||||
remount.chatComposerFallbackByScope = {
|
remount.chatComposerFallbackByScope = {
|
||||||
collision: {
|
collision: {
|
||||||
attachments: [mounted],
|
attachments: [mounted],
|
||||||
|
|||||||
@@ -116,11 +116,9 @@ export function resumeStagedPanes(
|
|||||||
layout: ChatSplitLayout,
|
layout: ChatSplitLayout,
|
||||||
narrow: boolean,
|
narrow: boolean,
|
||||||
): void {
|
): void {
|
||||||
const visibleSessions = new Map(
|
const visiblePaneIds = new Set(visiblePanesOf(layout, narrow).map((pane) => pane.id));
|
||||||
visiblePanesOf(layout, narrow).map((pane) => [pane.id, pane.sessionKey]),
|
|
||||||
);
|
|
||||||
for (const pane of root.querySelectorAll<StagedAttachmentPane>("openclaw-chat-pane")) {
|
for (const pane of root.querySelectorAll<StagedAttachmentPane>("openclaw-chat-pane")) {
|
||||||
if (visibleSessions.get(pane.paneId) === pane.sessionKey) {
|
if (visiblePaneIds.has(pane.paneId)) {
|
||||||
pane.resumeStagedAttachments?.();
|
pane.resumeStagedAttachments?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,11 +131,14 @@ export function closeStagedPane(
|
|||||||
paneId: string,
|
paneId: string,
|
||||||
) {
|
) {
|
||||||
const survivingPane = panesOf(layout).find((candidate) => candidate.id !== paneId);
|
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,
|
(candidate) => candidate.paneId === paneId,
|
||||||
);
|
);
|
||||||
// Clear a mounted pane first so its disconnect cannot restage the closed package.
|
// Clear every retained presentation first so their disconnects cannot
|
||||||
pane?.discardStagedAttachments?.();
|
// restage a package under a later reused logical pane id.
|
||||||
|
for (const pane of mounted) {
|
||||||
|
pane.discardStagedAttachments?.();
|
||||||
|
}
|
||||||
context.chatAttachmentHandoff.clearPane(paneId);
|
context.chatAttachmentHandoff.clearPane(paneId);
|
||||||
return survivingPane;
|
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 type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
|
||||||
import { ChatTranscriptController } from "./components/chat-thread.ts";
|
import { ChatTranscriptController } from "./components/chat-thread.ts";
|
||||||
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
|
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
|
||||||
import type { ChatSessionScrollPosition } from "./scroll.ts";
|
|
||||||
import type { ChatMessageCache } from "./session-message-cache.ts";
|
import type { ChatMessageCache } from "./session-message-cache.ts";
|
||||||
|
|
||||||
export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||||
@@ -70,22 +69,59 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
|||||||
@consume({ context: applicationContext, subscribe: true })
|
@consume({ context: applicationContext, subscribe: true })
|
||||||
protected context!: ChatPageContext;
|
protected context!: ChatPageContext;
|
||||||
@property({ attribute: false }) paneId = "single";
|
@property({ attribute: false }) paneId = "single";
|
||||||
|
@property({ attribute: false }) presentationId = "single";
|
||||||
@property({ attribute: false }) chatMessagesBySession?: ChatMessageCache;
|
@property({ attribute: false }) chatMessagesBySession?: ChatMessageCache;
|
||||||
// Empty means "no route/layout opinion yet": the pane boots on the page
|
// 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
|
// state's default session and must not canonicalize or write global session
|
||||||
// bindings until the container supplies a real key (classic mode renders
|
// bindings until the container supplies a real key (classic mode renders
|
||||||
// before route data resolves).
|
// before route data resolves).
|
||||||
@property({ attribute: false }) sessionKey = "";
|
@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 }) draft?: string;
|
||||||
@property({ attribute: false }) focusComposer = false;
|
@property({ attribute: false }) focusComposer = false;
|
||||||
@property({ attribute: false }) routeFace: BoardFace = "chat";
|
@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 }) onFocusPane?: (paneId: string) => void;
|
||||||
@property({ attribute: false }) onPaneSessionChange?: (
|
@property({ attribute: false }) onPaneSessionChange?: (
|
||||||
paneId: string,
|
paneId: string,
|
||||||
nextSessionKey: string,
|
nextSessionKey: string,
|
||||||
options?: PaneSessionChangeOptions,
|
options?: PaneSessionChangeOptions,
|
||||||
|
) => boolean | void;
|
||||||
|
@property({ attribute: false }) onSessionDeleted?: (
|
||||||
|
paneId: string,
|
||||||
|
sessionKey: string,
|
||||||
|
replacementSessionKey: string,
|
||||||
) => void;
|
) => void;
|
||||||
@property({ attribute: false }) paneTitle = "";
|
@property({ attribute: false }) paneTitle = "";
|
||||||
@property({ attribute: false }) narrow = false;
|
@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 applyApplicationConfig(config: ChatPageContext["config"]["current"]): void;
|
||||||
protected abstract applySessionsState(state: ChatPageContext["sessions"]["state"]): void;
|
protected abstract applySessionsState(state: ChatPageContext["sessions"]["state"]): void;
|
||||||
protected abstract cancelHeaderRename(): void;
|
protected abstract cancelHeaderRename(): void;
|
||||||
protected abstract resetOlderMessagesViewport(
|
protected abstract resetOlderMessagesViewport(): void;
|
||||||
nextSessionKey?: string,
|
|
||||||
): ChatSessionScrollPosition | null;
|
|
||||||
protected abstract restoreOlderMessagesViewport(sessionKey: string, scrollTop: number): void;
|
|
||||||
protected abstract sendPendingSkillWorkshopRevision(expectedSessionKey: string): void;
|
protected abstract sendPendingSkillWorkshopRevision(expectedSessionKey: string): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ type TestChatPane = HTMLElement & {
|
|||||||
context: ApplicationContext;
|
context: ApplicationContext;
|
||||||
state: ChatPageHost;
|
state: ChatPageHost;
|
||||||
createSession: () => Promise<boolean>;
|
createSession: () => Promise<boolean>;
|
||||||
|
paneId: string;
|
||||||
|
sessionKey: string;
|
||||||
resetConfirmationOpen: boolean;
|
resetConfirmationOpen: boolean;
|
||||||
routeFace: "chat" | "dashboard";
|
routeFace: "chat" | "dashboard";
|
||||||
onFaceChange?: (face: "chat" | "dashboard") => void;
|
onFaceChange?: (paneId: string, sessionKey: string, face: "chat" | "dashboard") => void;
|
||||||
confirmConversationReset: () => Promise<boolean>;
|
confirmConversationReset: () => Promise<boolean>;
|
||||||
settleResetConfirmation: (confirmed: boolean) => void;
|
settleResetConfirmation: (confirmed: boolean) => void;
|
||||||
updated: () => 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", () => {
|
it("uses in-memory tab preferences while the route owns the face", () => {
|
||||||
const pane = createTestPane();
|
const pane = createTestPane();
|
||||||
pane.routeFace = "dashboard";
|
pane.routeFace = "dashboard";
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
|||||||
patch: Partial<BoardSessionView> & { face?: "chat" | "dashboard" },
|
patch: Partial<BoardSessionView> & { face?: "chat" | "dashboard" },
|
||||||
): void {
|
): void {
|
||||||
if (patch.face) {
|
if (patch.face) {
|
||||||
this.onFaceChange?.(patch.face);
|
this.onFaceChange?.(this.paneId, this.sessionKey, patch.face);
|
||||||
}
|
}
|
||||||
const persistedPatch = { ...patch };
|
const persistedPatch = { ...patch };
|
||||||
delete persistedPatch.face;
|
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 { BrowserAnnotationDraft } from "../../components/browser/browser-annotation.ts";
|
||||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||||
import {
|
import {
|
||||||
|
cloneChatAttachmentsForIndependentOwner,
|
||||||
getChatAttachmentDataUrl,
|
getChatAttachmentDataUrl,
|
||||||
registerChatAttachmentPayload,
|
registerChatAttachmentPayload,
|
||||||
releaseChatAttachmentPayload,
|
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(
|
function connectPaneThroughAttachmentRestore(
|
||||||
context: ApplicationContext,
|
context: ApplicationContext,
|
||||||
paneId: string,
|
paneId: string,
|
||||||
@@ -184,6 +196,29 @@ describe("staged attachment composer adoption", () => {
|
|||||||
remount.disconnectedCallback();
|
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", () => {
|
it("keeps generated context on the attachment and leaves the user's draft unchanged", () => {
|
||||||
const { pane, state } = createTestChatPane({
|
const { pane, state } = createTestChatPane({
|
||||||
client: {} as GatewayBrowserClient,
|
client: {} as GatewayBrowserClient,
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
|||||||
parseAgentSessionKey(state.sessionKey)?.agentId ??
|
parseAgentSessionKey(state.sessionKey)?.agentId ??
|
||||||
this.context.agentSelection.state.selectedId ??
|
this.context.agentSelection.state.selectedId ??
|
||||||
"main";
|
"main";
|
||||||
this.onPaneSessionChange?.(
|
this.onSessionDeleted?.(
|
||||||
this.paneId,
|
this.paneId,
|
||||||
|
state.sessionKey,
|
||||||
buildAgentMainSessionKey({
|
buildAgentMainSessionKey({
|
||||||
agentId,
|
agentId,
|
||||||
mainKey: resolveUiConfiguredMainKey({
|
mainKey: resolveUiConfiguredMainKey({
|
||||||
@@ -281,7 +282,9 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
|||||||
if (
|
if (
|
||||||
routeSessionKey &&
|
routeSessionKey &&
|
||||||
canonicalRouteSessionKey &&
|
canonicalRouteSessionKey &&
|
||||||
canonicalRouteSessionKey !== routeSessionKey
|
canonicalRouteSessionKey !== routeSessionKey &&
|
||||||
|
this.active &&
|
||||||
|
this.presented
|
||||||
) {
|
) {
|
||||||
this.onPaneSessionChange?.(this.paneId, canonicalRouteSessionKey, { replace: true });
|
this.onPaneSessionChange?.(this.paneId, canonicalRouteSessionKey, { replace: true });
|
||||||
state.requestUpdate?.();
|
state.requestUpdate?.();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { expectDefined } from "@openclaw/normalization-core";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
SessionCatalogSession,
|
SessionCatalogSession,
|
||||||
@@ -13,9 +12,8 @@ import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessio
|
|||||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||||
import "./chat-pane.ts";
|
import "./chat-pane.ts";
|
||||||
import { loadChatHistory } from "./chat-history.ts";
|
import { loadChatHistory } from "./chat-history.ts";
|
||||||
|
import { consumePaneSessionHandoff } from "./chat-pane-shared.ts";
|
||||||
import type { ChatPageHost } from "./chat-state-host.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 & {
|
type TestChatPane = HTMLElement & {
|
||||||
catalogMessages: unknown[];
|
catalogMessages: unknown[];
|
||||||
@@ -26,8 +24,8 @@ type TestChatPane = HTMLElement & {
|
|||||||
continueCatalogSession: (key: CatalogSessionKey) => Promise<void>;
|
continueCatalogSession: (key: CatalogSessionKey) => Promise<void>;
|
||||||
catalogLoadGeneration: number;
|
catalogLoadGeneration: number;
|
||||||
catalogSession: SessionCatalogSession | null;
|
catalogSession: SessionCatalogSession | null;
|
||||||
|
paneId: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
switchPaneSession: (nextSessionKey: string) => void;
|
|
||||||
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
||||||
catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record<string, unknown> | null;
|
catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record<string, unknown> | null;
|
||||||
handleTranscriptScroll: (event: Event) => void;
|
handleTranscriptScroll: (event: Event) => void;
|
||||||
@@ -40,8 +38,7 @@ type TestChatPane = HTMLElement & {
|
|||||||
hasOlderMessages: () => boolean;
|
hasOlderMessages: () => boolean;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
olderOffsetsSeen: Set<number>;
|
olderOffsetsSeen: Set<number>;
|
||||||
resetOlderMessagesViewport: (nextSessionKey?: string) => ChatSessionScrollPosition | null;
|
resetOlderMessagesViewport: () => void;
|
||||||
restoreOlderMessagesViewport: (sessionKey: string, scrollTop: number) => void;
|
|
||||||
transcriptScrollTop: number | null;
|
transcriptScrollTop: number | null;
|
||||||
transcript: {
|
transcript: {
|
||||||
activeSessionKey: string | null;
|
activeSessionKey: string | null;
|
||||||
@@ -144,11 +141,6 @@ function createCatalogContinuationPane(request: ReturnType<typeof vi.fn>) {
|
|||||||
canArchive: true,
|
canArchive: true,
|
||||||
};
|
};
|
||||||
pane.onPaneSessionChange = vi.fn();
|
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 };
|
return { client, key, pane, requestUpdate, sessions, sourceSessionKey, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,83 +160,6 @@ function nativeHistorySeq(message: unknown): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("chat pane native history pagination", () => {
|
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", () => {
|
it("does not request older rows from a complete imported snapshot", () => {
|
||||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||||
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||||
@@ -671,34 +586,35 @@ describe("chat pane native history pagination", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("chat pane catalog continuation lifecycle", () => {
|
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 request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued" });
|
||||||
const { key, pane, state } = createCatalogContinuationPane(request);
|
const { key, pane, state } = createCatalogContinuationPane(request);
|
||||||
|
|
||||||
await pane.continueCatalogSession(key);
|
await pane.continueCatalogSession(key);
|
||||||
|
|
||||||
expect(request).toHaveBeenCalledWith("sessions.catalog.continue", key);
|
expect(request).toHaveBeenCalledWith("sessions.catalog.continue", key);
|
||||||
const onPaneSessionChange = expectDefined(
|
expect(pane.onPaneSessionChange).toHaveBeenCalledWith("single", "agent:main:continued");
|
||||||
pane.onPaneSessionChange,
|
expect(consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:continued")).toEqual({
|
||||||
"catalog continuation navigation callback",
|
attachments: [],
|
||||||
);
|
draft: "Continue the original catalog conversation",
|
||||||
expect(onPaneSessionChange).toHaveBeenCalledWith("single", "agent:main:continued");
|
send: true,
|
||||||
expect(pane.switchPaneSession).toHaveBeenCalledWith("agent:main:continued");
|
});
|
||||||
|
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(
|
expect(
|
||||||
expectDefined(
|
consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:rejected-continuation"),
|
||||||
vi.mocked(pane.switchPaneSession).mock.invocationCallOrder[0],
|
).toBeNull();
|
||||||
"catalog continuation session switch order",
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
),
|
expect(state.chatSending).toBe(false);
|
||||||
).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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send a stale catalog draft after the user switches conversations", async () => {
|
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;
|
await pending;
|
||||||
|
|
||||||
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
||||||
expect(pane.switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
expect(state.sessionKey).toBe("agent:main:different-conversation");
|
expect(state.sessionKey).toBe("agent:main:different-conversation");
|
||||||
@@ -736,7 +651,6 @@ describe("chat pane catalog continuation lifecycle", () => {
|
|||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
||||||
expect(pane.switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
expect(state.chatMessage).toBe("Draft from the reconnected conversation");
|
expect(state.chatMessage).toBe("Draft from the reconnected conversation");
|
||||||
@@ -760,7 +674,6 @@ describe("chat pane catalog continuation lifecycle", () => {
|
|||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
expect(pane.onPaneSessionChange).not.toHaveBeenCalled();
|
||||||
expect(pane.switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
expect(state.handleChatDraftChange).not.toHaveBeenCalled();
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
expect(state.client).toBe(replacementClient);
|
expect(state.client).toBe(replacementClient);
|
||||||
@@ -782,7 +695,6 @@ describe("chat pane catalog continuation lifecycle", () => {
|
|||||||
continued.resolve({ sessionKey: "agent:main:stale-continuation" });
|
continued.resolve({ sessionKey: "agent:main:stale-continuation" });
|
||||||
await pending;
|
await pending;
|
||||||
|
|
||||||
expect(pane.switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
expect(state.chatSendingScopeKey).toBe("newer-conversation-send");
|
expect(state.chatSendingScopeKey).toBe("newer-conversation-send");
|
||||||
expect(state.chatSending).toBe(true);
|
expect(state.chatSending).toBe(true);
|
||||||
@@ -803,17 +715,21 @@ describe("chat pane catalog continuation lifecycle", () => {
|
|||||||
first.resolve({ sessionKey: "agent:main:stale-continuation" });
|
first.resolve({ sessionKey: "agent:main:stale-continuation" });
|
||||||
await staleContinuation;
|
await staleContinuation;
|
||||||
|
|
||||||
expect(pane.switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
expect(state.handleSendChat).not.toHaveBeenCalled();
|
||||||
expect(state.chatSending).toBe(true);
|
expect(state.chatSending).toBe(true);
|
||||||
|
|
||||||
second.resolve({ sessionKey: "agent:main:latest-continuation" });
|
second.resolve({ sessionKey: "agent:main:latest-continuation" });
|
||||||
await currentContinuation;
|
await currentContinuation;
|
||||||
|
|
||||||
expect(pane.switchPaneSession).toHaveBeenCalledOnce();
|
expect(pane.onPaneSessionChange).toHaveBeenCalledOnce();
|
||||||
expect(pane.switchPaneSession).toHaveBeenCalledWith("agent:main:latest-continuation");
|
expect(
|
||||||
expect(state.handleChatDraftChange).toHaveBeenCalledWith("Only send the latest catalog draft");
|
consumePaneSessionHandoff(pane.context, pane.paneId, "agent:main:latest-continuation"),
|
||||||
expect(state.handleSendChat).toHaveBeenCalledOnce();
|
).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 () => {
|
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.chatSending).toBe(false);
|
||||||
expect(state.handleSendChat).not.toHaveBeenCalled();
|
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_INTENT_IDLE_MS,
|
||||||
CHAT_HISTORY_TOUCH_INTENT_PX,
|
CHAT_HISTORY_TOUCH_INTENT_PX,
|
||||||
CHAT_HISTORY_UPWARD_KEYS,
|
CHAT_HISTORY_UPWARD_KEYS,
|
||||||
|
clearPaneSessionHandoff,
|
||||||
|
preparePaneSessionHandoff,
|
||||||
} from "./chat-pane-shared.ts";
|
} from "./chat-pane-shared.ts";
|
||||||
import { persistChatComposerState } from "./composer-persistence.ts";
|
import { persistChatComposerState } from "./composer-persistence.ts";
|
||||||
import {
|
import {
|
||||||
captureChatSessionScrollPosition,
|
captureChatSessionScrollPosition,
|
||||||
getChatSessionScrollPosition,
|
|
||||||
restoreChatScroll,
|
|
||||||
saveChatSessionScrollPosition,
|
saveChatSessionScrollPosition,
|
||||||
scheduleChatScroll,
|
scheduleChatScroll,
|
||||||
type ChatSessionScrollPosition,
|
|
||||||
} from "./scroll.ts";
|
} from "./scroll.ts";
|
||||||
|
|
||||||
export abstract class ChatPaneHistory extends ChatPaneSession {
|
export abstract class ChatPaneHistory extends ChatPaneSession {
|
||||||
@@ -59,30 +58,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
return pagination.hasMore && !state.chatLoading;
|
return pagination.hasMore && !state.chatLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected resetOlderMessagesViewport(nextSessionKey?: string): ChatSessionScrollPosition | null {
|
protected resetOlderMessagesViewport(): void {
|
||||||
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;
|
|
||||||
}
|
|
||||||
this.olderLoadGeneration += 1;
|
this.olderLoadGeneration += 1;
|
||||||
this.loadingOlder = false;
|
this.loadingOlder = false;
|
||||||
this.historyObserverArmed = false;
|
this.historyObserverArmed = false;
|
||||||
@@ -94,62 +70,11 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
window.clearTimeout(this.historyIntentTimer);
|
window.clearTimeout(this.historyIntentTimer);
|
||||||
this.historyIntentTimer = null;
|
this.historyIntentTimer = null;
|
||||||
}
|
}
|
||||||
this.transcriptScrollTop = restoredPosition?.scrollTop ?? null;
|
this.transcriptScrollTop = null;
|
||||||
this.olderCursorsSeen.clear();
|
this.olderCursorsSeen.clear();
|
||||||
this.olderOffsetsSeen.clear();
|
this.olderOffsetsSeen.clear();
|
||||||
this.nativePaginationSnapshot = null;
|
this.nativePaginationSnapshot = null;
|
||||||
this.clearHistoryObserver();
|
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 {
|
protected clearHistoryObserver(): void {
|
||||||
@@ -172,6 +97,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
typeof IntersectionObserver !== "function" ||
|
typeof IntersectionObserver !== "function" ||
|
||||||
|
!this.presented ||
|
||||||
!this.state?.connected ||
|
!this.state?.connected ||
|
||||||
this.loadingOlder ||
|
this.loadingOlder ||
|
||||||
!this.hasOlderMessages()
|
!this.hasOlderMessages()
|
||||||
@@ -248,7 +174,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
areUiSessionKeysEquivalent(renderedSessionKey, stateSessionKey)
|
areUiSessionKeysEquivalent(renderedSessionKey, stateSessionKey)
|
||||||
) {
|
) {
|
||||||
saveChatSessionScrollPosition(
|
saveChatSessionScrollPosition(
|
||||||
this.paneId,
|
this.presentationId,
|
||||||
renderedSessionKey,
|
renderedSessionKey,
|
||||||
captureChatSessionScrollPosition(root),
|
captureChatSessionScrollPosition(root),
|
||||||
);
|
);
|
||||||
@@ -423,8 +349,6 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
const sourceSessionKey = state.sessionKey;
|
const sourceSessionKey = state.sessionKey;
|
||||||
const sourceCatalogGeneration = this.catalogLoadGeneration;
|
const sourceCatalogGeneration = this.catalogLoadGeneration;
|
||||||
const continuation = Symbol("catalog-continuation");
|
const continuation = Symbol("catalog-continuation");
|
||||||
let adoptedSessionKey: string | null = null;
|
|
||||||
let adoptedCatalogGeneration: number | null = null;
|
|
||||||
this.activeCatalogContinuation = continuation;
|
this.activeCatalogContinuation = continuation;
|
||||||
state.chatSending = true;
|
state.chatSending = true;
|
||||||
state.requestUpdate();
|
state.requestUpdate();
|
||||||
@@ -455,29 +379,26 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
releaseStaleContinuation();
|
releaseStaleContinuation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
adoptedSessionKey = result.sessionKey;
|
preparePaneSessionHandoff(this.context, this.paneId, result.sessionKey, {
|
||||||
announceCatalogSessionContinued({ ...key, sessionKey: result.sessionKey });
|
attachments: [],
|
||||||
// Make the adopted session authoritative before routing; otherwise the
|
draft,
|
||||||
// outgoing catalog pane can immediately restore the previous chat URL.
|
send: true,
|
||||||
this.switchPaneSession(result.sessionKey);
|
});
|
||||||
adoptedCatalogGeneration = this.catalogLoadGeneration;
|
if (this.onPaneSessionChange?.(this.paneId, result.sessionKey) === false) {
|
||||||
this.onPaneSessionChange?.(this.paneId, result.sessionKey);
|
clearPaneSessionHandoff(this.context, this.paneId, result.sessionKey);
|
||||||
state.handleChatDraftChange(draft);
|
releaseStaleContinuation();
|
||||||
await state.handleSendChat();
|
return;
|
||||||
if (this.activeCatalogContinuation === continuation) {
|
|
||||||
this.activeCatalogContinuation = null;
|
|
||||||
}
|
}
|
||||||
|
announceCatalogSessionContinued({ ...key, sessionKey: result.sessionKey });
|
||||||
|
this.activeCatalogContinuation = null;
|
||||||
|
state.chatSending = false;
|
||||||
|
state.requestUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
this.activeCatalogContinuation !== continuation ||
|
this.activeCatalogContinuation !== continuation ||
|
||||||
!this.isConnectionScopeCurrent(scope) ||
|
!this.isConnectionScopeCurrent(scope) ||
|
||||||
(adoptedSessionKey === null
|
this.catalogLoadGeneration !== sourceCatalogGeneration ||
|
||||||
? this.catalogLoadGeneration !== sourceCatalogGeneration ||
|
state.sessionKey !== sourceSessionKey
|
||||||
state.sessionKey !== sourceSessionKey
|
|
||||||
: adoptedCatalogGeneration === null
|
|
||||||
? state.sessionKey !== sourceSessionKey && state.sessionKey !== adoptedSessionKey
|
|
||||||
: this.catalogLoadGeneration !== adoptedCatalogGeneration ||
|
|
||||||
state.sessionKey !== adoptedSessionKey)
|
|
||||||
) {
|
) {
|
||||||
releaseStaleContinuation();
|
releaseStaleContinuation();
|
||||||
return;
|
return;
|
||||||
@@ -513,24 +434,20 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
|
|||||||
try {
|
try {
|
||||||
const result = await state.sessions.forkAtMessage(sourceKey, entryId, agentParams);
|
const result = await state.sessions.forkAtMessage(sourceKey, entryId, agentParams);
|
||||||
const editorText = result.editorText ?? "";
|
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)) {
|
if (this.state !== state || !visibleSessionMatches(state, sourceKey, agentParams.agentId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.onPaneSessionChange?.(this.paneId, result.sessionKey);
|
if (this.onPaneSessionChange?.(this.paneId, result.sessionKey) === false) {
|
||||||
this.switchPaneSession(result.sessionKey);
|
return;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (error) {
|
||||||
state.lastError = error instanceof Error ? error.message : String(error);
|
state.lastError = error instanceof Error ? error.message : String(error);
|
||||||
state.chatError = state.lastError;
|
state.chatError = state.lastError;
|
||||||
|
|||||||
@@ -768,55 +768,6 @@ describe("chat pane presentation teardown", () => {
|
|||||||
true,
|
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", () => {
|
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 { t } from "../../i18n/index.ts";
|
||||||
import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts";
|
import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts";
|
||||||
import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.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 { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts";
|
||||||
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
||||||
import { resolveSessionCreateParams } from "../../lib/sessions/create.ts";
|
import { resolveSessionKey } from "../../lib/sessions/index.ts";
|
||||||
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
|
import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
|
||||||
import {
|
|
||||||
areUiSessionKeysEquivalent,
|
|
||||||
resolveAgentIdFromSessionKey,
|
|
||||||
} from "../../lib/sessions/session-key.ts";
|
|
||||||
import { invalidateChatAvatarCache, refreshChatAvatar } from "./chat-avatar.ts";
|
import { invalidateChatAvatarCache, refreshChatAvatar } from "./chat-avatar.ts";
|
||||||
import { clearChatHistory } from "./chat-history.ts";
|
|
||||||
import {
|
import {
|
||||||
type ChatAttachmentGatewayOwner,
|
type ChatAttachmentGatewayOwner,
|
||||||
discardStateStagedAttachments,
|
discardStateStagedAttachments,
|
||||||
@@ -33,11 +27,11 @@ import {
|
|||||||
replacePaneStagedAttachmentGatewayOwner,
|
replacePaneStagedAttachmentGatewayOwner,
|
||||||
restorePaneStagedAttachments,
|
restorePaneStagedAttachments,
|
||||||
} from "./chat-pane-attachment-handoff.ts";
|
} from "./chat-pane-attachment-handoff.ts";
|
||||||
import { ChatPaneBoard } from "./chat-pane-board.ts";
|
|
||||||
import {
|
import {
|
||||||
focusBrowserAnnotationComposerAfterUpdate,
|
focusBrowserAnnotationComposerAfterUpdate,
|
||||||
receiveBrowserAnnotation as admitBrowserAnnotation,
|
receiveBrowserAnnotation as admitBrowserAnnotation,
|
||||||
} from "./chat-pane-browser-annotation.ts";
|
} from "./chat-pane-browser-annotation.ts";
|
||||||
|
import { ChatPaneSessionCreation } from "./chat-pane-session-creation.ts";
|
||||||
import {
|
import {
|
||||||
CHAT_COMPOSER_TEXTAREA_SELECTOR,
|
CHAT_COMPOSER_TEXTAREA_SELECTOR,
|
||||||
CHAT_MODAL_SELECTOR,
|
CHAT_MODAL_SELECTOR,
|
||||||
@@ -45,9 +39,6 @@ import {
|
|||||||
CHAT_SPACE_ACTIVATION_SELECTOR,
|
CHAT_SPACE_ACTIVATION_SELECTOR,
|
||||||
CHAT_TEXT_ENTRY_SELECTOR,
|
CHAT_TEXT_ENTRY_SELECTOR,
|
||||||
keyboardEventPathMatches,
|
keyboardEventPathMatches,
|
||||||
NEW_SESSION_ACTIVE_RUN_MESSAGE,
|
|
||||||
NEW_SESSION_CREATE_FAILED_MESSAGE,
|
|
||||||
NEW_SESSION_LIST_LOADING_MESSAGE,
|
|
||||||
} from "./chat-pane-shared.ts";
|
} from "./chat-pane-shared.ts";
|
||||||
import { subscribeChatPaneStartup } from "./chat-pane-startup-subscriptions.ts";
|
import { subscribeChatPaneStartup } from "./chat-pane-startup-subscriptions.ts";
|
||||||
import { setChatError } from "./chat-send-queue-state.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 { handlePageGatewayEvent } from "./chat-state-events.ts";
|
||||||
import { createPageState } from "./chat-state-page.ts";
|
import { createPageState } from "./chat-state-page.ts";
|
||||||
import { invalidateChatMetadataCache, refreshPageChat } from "./chat-state-refresh.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 { resetChatViewState } from "./chat-view-state.ts";
|
||||||
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
|
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
|
||||||
import { clearChatModelSearchOnEscape } from "./components/chat-model-picker.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_DURATION_MS = 1_200;
|
||||||
const COMPOSER_PREFILL_ATTENTION_CLASS = "agent-chat__input--prefill-attention";
|
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 stagedAttachmentGatewayOwner: ChatAttachmentGatewayOwner = null;
|
||||||
private suppressStagedAttachmentHandoffOnDisconnect = false;
|
private suppressStagedAttachmentHandoffOnDisconnect = false;
|
||||||
|
|
||||||
@@ -100,7 +91,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearComposerPrefillAttention(): void {
|
protected clearComposerPrefillAttention(): void {
|
||||||
if (this.composerPrefillAttentionTimer !== null) {
|
if (this.composerPrefillAttentionTimer !== null) {
|
||||||
window.clearTimeout(this.composerPrefillAttentionTimer);
|
window.clearTimeout(this.composerPrefillAttentionTimer);
|
||||||
this.composerPrefillAttentionTimer = null;
|
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() {
|
protected syncActiveBindings() {
|
||||||
this.nativeDraftCleanup?.();
|
this.nativeDraftCleanup?.();
|
||||||
this.nativeDraftCleanup = null;
|
this.nativeDraftCleanup = null;
|
||||||
if (!this.active) {
|
if (!this.active || !this.presented) {
|
||||||
this.announceCommandPaletteTarget(null);
|
this.announceCommandPaletteTarget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -321,7 +205,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
this.applyActiveSessionBindings();
|
this.applyActiveSessionBindings();
|
||||||
this.nativeDraftCleanup = this.context.nativeChatDrafts.subscribe((draft) => {
|
this.nativeDraftCleanup = this.context.nativeChatDrafts.subscribe((draft) => {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
if (!state || !this.active) {
|
if (!state || !this.active || !this.presented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.handleChatDraftChange(draft);
|
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. */
|
/** Receives one complete browser annotation without mixing generated context into the user's draft. */
|
||||||
protected receiveBrowserAnnotation(event: Event): void {
|
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) {
|
if (!accepted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,7 +231,13 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
|
|
||||||
protected sendPendingSkillWorkshopRevision(expectedSessionKey: string) {
|
protected sendPendingSkillWorkshopRevision(expectedSessionKey: string) {
|
||||||
const state = this.state;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const revision = this.context.skillWorkshopRevision.consume(
|
const revision = this.context.skillWorkshopRevision.consume(
|
||||||
@@ -374,6 +264,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
protected readonly handleDocumentKeydown = (event: KeyboardEvent) => {
|
protected readonly handleDocumentKeydown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
this.active &&
|
this.active &&
|
||||||
|
this.presented &&
|
||||||
!event.defaultPrevented &&
|
!event.defaultPrevented &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
@@ -392,6 +283,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.active &&
|
this.active &&
|
||||||
|
this.presented &&
|
||||||
!event.defaultPrevented &&
|
!event.defaultPrevented &&
|
||||||
!event.isComposing &&
|
!event.isComposing &&
|
||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
@@ -446,9 +338,12 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
};
|
};
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
this.boardProviderLifecycleConnected = true;
|
this.boardProviderLifecycleConnected = this.presented;
|
||||||
this.resumeStagedAttachments();
|
this.resumeStagedAttachments();
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
if (!this.presented) {
|
||||||
|
this.minutePoll.stop();
|
||||||
|
}
|
||||||
const mountGatewayOwner = this.context.gateway.snapshot.client;
|
const mountGatewayOwner = this.context.gateway.snapshot.client;
|
||||||
this.stagedAttachmentGatewayOwner = mountGatewayOwner;
|
this.stagedAttachmentGatewayOwner = mountGatewayOwner;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
@@ -519,8 +414,15 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
}
|
}
|
||||||
chatState.attach(pageState);
|
chatState.attach(pageState);
|
||||||
chatState.restoreComposer({ preserveCurrent: true });
|
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);
|
restorePaneStagedAttachments(this.context, this.paneId, pageState, mountGatewayOwner);
|
||||||
chatState.startComposerPersistence();
|
chatState.startComposerPersistence();
|
||||||
|
if (sessionHandoff && !sessionHandoff.restore) {
|
||||||
|
this.applySessionHandoff(pageState.sessionKey, sessionHandoff, true);
|
||||||
|
}
|
||||||
if (this.draft !== undefined) {
|
if (this.draft !== undefined) {
|
||||||
this.state.handleChatDraftChange(this.draft);
|
this.state.handleChatDraftChange(this.draft);
|
||||||
}
|
}
|
||||||
@@ -611,14 +513,12 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
// after its transcript commit in deferSessionHydrationUntilTranscript.
|
// after its transcript commit in deferSessionHydrationUntilTranscript.
|
||||||
this.sessionDiscussionStates.delete(nextSessionKey);
|
this.sessionDiscussionStates.delete(nextSessionKey);
|
||||||
}
|
}
|
||||||
if (nextSessionKey && !areUiSessionKeysEquivalent(nextSessionKey, this.state.sessionKey)) {
|
if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) {
|
||||||
this.switchPaneSession(nextSessionKey);
|
|
||||||
} else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) {
|
|
||||||
this.catalogLoadGeneration += 1;
|
this.catalogLoadGeneration += 1;
|
||||||
this.openCatalogSession(catalogKey, this.state);
|
this.openCatalogSession(catalogKey, this.state);
|
||||||
} else if (nextSessionKey) {
|
} else if (nextSessionKey) {
|
||||||
// Route aliases name the same conversation. Adopt the canonical spelling
|
// A retained pane owns one conversation for its lifetime. Only its
|
||||||
// without clearing the active stream and tool state as a session switch.
|
// canonical spelling can change after Gateway defaults resolve.
|
||||||
this.state.sessionKey = nextSessionKey;
|
this.state.sessionKey = nextSessionKey;
|
||||||
// A pane routed straight onto the created session never runs the switch
|
// A pane routed straight onto the created session never runs the switch
|
||||||
// path, so its one-shot handoffs would expire unclaimed: the rejected turn
|
// 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.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();
|
this.syncActiveBindings();
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -723,7 +628,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
|||||||
this.presencePayload = undefined;
|
this.presencePayload = undefined;
|
||||||
this.announceCommandPaletteTarget(null);
|
this.announceCommandPaletteTarget(null);
|
||||||
dismissConfirmedActionPopovers(this);
|
dismissConfirmedActionPopovers(this);
|
||||||
resetChatViewState(this.paneId);
|
resetChatViewState(this.presentationId);
|
||||||
this.state = undefined;
|
this.state = undefined;
|
||||||
this.connectedClient = null;
|
this.connectedClient = null;
|
||||||
disposeQuestionPromptState(this.questionPromptState);
|
disposeQuestionPromptState(this.questionPromptState);
|
||||||
|
|||||||
@@ -139,9 +139,10 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
|||||||
const { catalogKey, fullMessageLoader, chatProps } = resolveChatMessageAccess(state);
|
const { catalogKey, fullMessageLoader, chatProps } = resolveChatMessageAccess(state);
|
||||||
const overlays = this.context?.overlays;
|
const overlays = this.context?.overlays;
|
||||||
const approvalSnapshot = overlays?.snapshot;
|
const approvalSnapshot = overlays?.snapshot;
|
||||||
const inlineApproval = this.active
|
const inlineApproval = findInlineApproval(
|
||||||
? findInlineApproval(approvalSnapshot?.approvalQueue ?? [], state.sessionKey)
|
approvalSnapshot?.approvalQueue ?? [],
|
||||||
: null;
|
state.sessionKey,
|
||||||
|
);
|
||||||
// Tool rows consult the global title store while rendering; point its
|
// Tool rows consult the global title store while rendering; point its
|
||||||
// fetcher at this pane's connection. Requests capture session + agent at
|
// fetcher at this pane's connection. Requests capture session + agent at
|
||||||
// schedule time, so later renders of other panes cannot re-route them.
|
// schedule time, so later renders of other panes cannot re-route them.
|
||||||
@@ -212,7 +213,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
|||||||
? this.paneWidth
|
? this.paneWidth
|
||||||
: (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth));
|
: (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth));
|
||||||
const sessionWorkspace = createSessionWorkspaceProps(state, {
|
const sessionWorkspace = createSessionWorkspaceProps(state, {
|
||||||
draftScope: this.paneId,
|
draftScope: this.presentationId,
|
||||||
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
|
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
|
||||||
});
|
});
|
||||||
const railSideDocked =
|
const railSideDocked =
|
||||||
@@ -262,9 +263,9 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
|||||||
const props: ChatProps = {
|
const props: ChatProps = {
|
||||||
transcript: this.transcript,
|
transcript: this.transcript,
|
||||||
backgroundTaskTranscript: this.backgroundTaskTranscript,
|
backgroundTaskTranscript: this.backgroundTaskTranscript,
|
||||||
paneId: this.paneId,
|
paneId: this.presentationId,
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
announceTranscript: this.active,
|
announceTranscript: this.active && this.presented,
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
this.onPaneSessionChange?.(this.paneId, 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 { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||||
import { clampText } from "../../lib/format.ts";
|
import { clampText } from "../../lib/format.ts";
|
||||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
|
||||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||||
import {
|
import {
|
||||||
scopedSessionPullRequestKey,
|
scopedSessionPullRequestKey,
|
||||||
@@ -23,12 +22,7 @@ import {
|
|||||||
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
|
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
|
||||||
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
||||||
import { catalogMessageId } from "./catalog-message-id.ts";
|
import { catalogMessageId } from "./catalog-message-id.ts";
|
||||||
import { refreshChatAvatar } from "./chat-avatar.ts";
|
import { loadChatBranches } from "./chat-history.ts";
|
||||||
import {
|
|
||||||
loadChatBranches,
|
|
||||||
loadChatHistory,
|
|
||||||
syncSelectedSessionMessageSubscription,
|
|
||||||
} from "./chat-history.ts";
|
|
||||||
import {
|
import {
|
||||||
CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS,
|
CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS,
|
||||||
catalogRawResult,
|
catalogRawResult,
|
||||||
@@ -36,35 +30,21 @@ import {
|
|||||||
nativeHistoryMessageIdentity,
|
nativeHistoryMessageIdentity,
|
||||||
summarizeSessionPullRequests,
|
summarizeSessionPullRequests,
|
||||||
} from "./chat-pane-shared.ts";
|
} from "./chat-pane-shared.ts";
|
||||||
import { applySelectedSessionProjection } from "./chat-pane-state.ts";
|
|
||||||
import { ChatPaneTaskSuggestions } from "./chat-pane-task-suggestions.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 type { ChatPageHost } from "./chat-state-host.ts";
|
||||||
import { refreshChatMetadata } from "./chat-state-refresh.ts";
|
import { resolveChatAgentId, saveRouteSessionSettings } from "./chat-state-route.ts";
|
||||||
import {
|
|
||||||
refreshRouteSessionOptions,
|
|
||||||
resetChatStateForRouteSession,
|
|
||||||
retryChatComposerMemoryFallback,
|
|
||||||
resolveChatAgentId,
|
|
||||||
saveRouteSessionSettings,
|
|
||||||
} from "./chat-state-route.ts";
|
|
||||||
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
|
|
||||||
import {
|
import {
|
||||||
dismissChatPullRequest,
|
dismissChatPullRequest,
|
||||||
listDismissedChatPullRequests,
|
listDismissedChatPullRequests,
|
||||||
} from "./components/chat-pull-requests.ts";
|
} 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";
|
import { scheduleChatScroll } from "./scroll.ts";
|
||||||
|
|
||||||
export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
||||||
protected async refreshSessionPullRequests(options: { refresh?: boolean } = {}): Promise<void> {
|
protected async refreshSessionPullRequests(options: { refresh?: boolean } = {}): Promise<void> {
|
||||||
|
if (!this.presented) {
|
||||||
|
sessionPullRequestsForGateway(this.context.gateway).unwatch(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scope = this.captureConnectionScope();
|
const scope = this.captureConnectionScope();
|
||||||
if (
|
if (
|
||||||
!scope ||
|
!scope ||
|
||||||
@@ -271,6 +251,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions {
|
|||||||
if (
|
if (
|
||||||
!state ||
|
!state ||
|
||||||
!this.active ||
|
!this.active ||
|
||||||
|
!this.presented ||
|
||||||
!this.sessionKey.trim() ||
|
!this.sessionKey.trim() ||
|
||||||
parseCatalogSessionKey(state.sessionKey)
|
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) {
|
protected openCatalogSession(key: CatalogSessionKey, state: ChatPageHost) {
|
||||||
this.catalogRequestedSessionKey = buildCatalogSessionKey(key);
|
this.catalogRequestedSessionKey = buildCatalogSessionKey(key);
|
||||||
this.catalogMessages = [];
|
this.catalogMessages = [];
|
||||||
|
|||||||
@@ -7,11 +7,146 @@ import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
|
|||||||
import type { BoardProvider } from "../../lib/board/provider.ts";
|
import type { BoardProvider } from "../../lib/board/provider.ts";
|
||||||
import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts";
|
import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts";
|
||||||
import type { BoardSnapshot, BoardTab } from "../../lib/board/types.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 { 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";
|
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||||
|
|
||||||
export type ChatPageContext = ApplicationContext;
|
export type ChatPageContext = ApplicationContext;
|
||||||
export type PaneSessionChangeOptions = { replace?: boolean };
|
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 = {
|
export type ResolvedBoardView = {
|
||||||
provider: BoardProvider;
|
provider: BoardProvider;
|
||||||
snapshot: BoardSnapshot;
|
snapshot: BoardSnapshot;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
|||||||
import type { ApplicationContext } from "../../app/context.ts";
|
import type { ApplicationContext } from "../../app/context.ts";
|
||||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||||
import "./chat-pane.ts";
|
import "./chat-pane.ts";
|
||||||
|
import { consumePaneSessionHandoff } from "./chat-pane-shared.ts";
|
||||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||||
|
|
||||||
type TestChatPane = HTMLElement & {
|
type TestChatPane = HTMLElement & {
|
||||||
@@ -12,8 +13,8 @@ type TestChatPane = HTMLElement & {
|
|||||||
context: ApplicationContext;
|
context: ApplicationContext;
|
||||||
forkFromMessage: (entryId: string) => Promise<void>;
|
forkFromMessage: (entryId: string) => Promise<void>;
|
||||||
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
||||||
|
paneId: string;
|
||||||
state: ChatPageHost;
|
state: ChatPageHost;
|
||||||
switchPaneSession: (sessionKey: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createSessionContext(
|
function createSessionContext(
|
||||||
@@ -91,21 +92,23 @@ describe("chat pane message cuts", () => {
|
|||||||
const client = {} as GatewayBrowserClient;
|
const client = {} as GatewayBrowserClient;
|
||||||
const { pane, state } = createTestChatPane({ client, sessions });
|
const { pane, state } = createTestChatPane({ client, sessions });
|
||||||
state.chatAttachments = [{ id: "old", mimeType: "image/jpeg", dataUrl: "data:old" }];
|
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");
|
await pane.forkFromMessage("user-entry");
|
||||||
|
|
||||||
expect(state.sessionKey).toBe("agent:main:forked");
|
expect(state.sessionKey).toBe("agent:main:current");
|
||||||
expect(state.chatAttachments).toEqual([
|
expect(state.chatAttachments).toEqual([
|
||||||
{
|
{ id: "old", mimeType: "image/jpeg", dataUrl: "data:old" },
|
||||||
id: expect.stringMatching(/^att-/),
|
|
||||||
mimeType: "image/png",
|
|
||||||
dataUrl: "data:image/png;base64,aW1hZ2U=",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
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 () => {
|
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 & {
|
export type TestChatPane = HTMLElement & {
|
||||||
catalogMessages: unknown[];
|
catalogMessages: unknown[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
presented: boolean;
|
||||||
|
presentationId: string;
|
||||||
chatMessagesBySession?: ChatMessageCache;
|
chatMessagesBySession?: ChatMessageCache;
|
||||||
chatState: { attach: (state: ChatPageHost) => void };
|
chatState: { attach: (state: ChatPageHost) => void };
|
||||||
context: ApplicationContext;
|
context: ApplicationContext;
|
||||||
@@ -39,6 +41,7 @@ export type TestChatPane = HTMLElement & {
|
|||||||
connectedCallback: () => void;
|
connectedCallback: () => void;
|
||||||
connectionGeneration: number;
|
connectionGeneration: number;
|
||||||
createSession: () => Promise<boolean>;
|
createSession: () => Promise<boolean>;
|
||||||
|
prepareForEviction: () => void;
|
||||||
restoreArchivedSession: (sessionKey: string) => Promise<void>;
|
restoreArchivedSession: (sessionKey: string) => Promise<void>;
|
||||||
disconnectedCallback: () => void;
|
disconnectedCallback: () => void;
|
||||||
discardStagedAttachments?: () => void;
|
discardStagedAttachments?: () => void;
|
||||||
@@ -76,7 +79,7 @@ export type TestChatPane = HTMLElement & {
|
|||||||
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
switchPaneSession: (nextSessionKey: string) => void;
|
updateComplete: Promise<boolean>;
|
||||||
deferSessionHydrationUntilTranscript: (
|
deferSessionHydrationUntilTranscript: (
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
transcriptLoad: Promise<unknown>,
|
transcriptLoad: Promise<unknown>,
|
||||||
|
|||||||
@@ -511,10 +511,16 @@ describe("chat pane initialization", () => {
|
|||||||
} as unknown as ApplicationContext;
|
} as unknown as ApplicationContext;
|
||||||
pane.sessionKey = "main";
|
pane.sessionKey = "main";
|
||||||
state.sessionKey = canonicalSessionKey;
|
state.sessionKey = canonicalSessionKey;
|
||||||
|
state.settings = {
|
||||||
|
sessionKey: canonicalSessionKey,
|
||||||
|
lastActiveSessionKey: canonicalSessionKey,
|
||||||
|
} as ChatPageHost["settings"];
|
||||||
state.hello = hello;
|
state.hello = hello;
|
||||||
state.loadAssistantIdentity = vi.fn(async () => {});
|
state.loadAssistantIdentity = vi.fn(async () => {});
|
||||||
pane.connectedClient = null;
|
pane.connectedClient = null;
|
||||||
pane.onPaneSessionChange = navigate;
|
pane.onPaneSessionChange = navigate;
|
||||||
|
pane.active = true;
|
||||||
|
pane.presented = true;
|
||||||
|
|
||||||
pane.applyGatewaySnapshot(snapshot);
|
pane.applyGatewaySnapshot(snapshot);
|
||||||
|
|
||||||
@@ -555,11 +561,6 @@ describe("chat pane initialization", () => {
|
|||||||
state.chatRunId = "run-reconnected";
|
state.chatRunId = "run-reconnected";
|
||||||
state.chatStream = "The response survived navigation.";
|
state.chatStream = "The response survived navigation.";
|
||||||
pane.sessionKey = canonicalSessionKey;
|
pane.sessionKey = canonicalSessionKey;
|
||||||
const switchPaneSession = vi.spyOn(pane, "switchPaneSession").mockImplementation((next) => {
|
|
||||||
state.sessionKey = next;
|
|
||||||
state.chatRunId = null;
|
|
||||||
state.chatStream = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
(
|
(
|
||||||
pane as TestChatPane & {
|
pane as TestChatPane & {
|
||||||
@@ -568,7 +569,6 @@ describe("chat pane initialization", () => {
|
|||||||
).willUpdate(new Map([["sessionKey", "main"]]));
|
).willUpdate(new Map([["sessionKey", "main"]]));
|
||||||
|
|
||||||
expect(state.sessionKey).toBe(canonicalSessionKey);
|
expect(state.sessionKey).toBe(canonicalSessionKey);
|
||||||
expect(switchPaneSession).not.toHaveBeenCalled();
|
|
||||||
expect(state.chatRunId).toBe("run-reconnected");
|
expect(state.chatRunId).toBe("run-reconnected");
|
||||||
expect(state.chatStream).toBe("The response survived navigation.");
|
expect(state.chatStream).toBe("The response survived navigation.");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||||
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
|
|
||||||
import { disposeSelectedSessionMessageSubscription } from "./chat-history.ts";
|
import { disposeSelectedSessionMessageSubscription } from "./chat-history.ts";
|
||||||
import { subscribeChatOutboxProjection } from "./chat-queue.ts";
|
import { subscribeChatOutboxProjection } from "./chat-queue.ts";
|
||||||
import { stopChatRealtimeTalk } from "./chat-realtime.ts";
|
import { stopChatRealtimeTalk } from "./chat-realtime.ts";
|
||||||
@@ -17,12 +16,6 @@ import {
|
|||||||
import type { AfterCommitEffect, RenderLifecycle } from "./render-lifecycle.ts";
|
import type { AfterCommitEffect, RenderLifecycle } from "./render-lifecycle.ts";
|
||||||
import { cancelChatScroll, scheduleCommittedChatScroll } from "./scroll.ts";
|
import { cancelChatScroll, scheduleCommittedChatScroll } from "./scroll.ts";
|
||||||
|
|
||||||
type PendingCreatedSessionComposer = {
|
|
||||||
sessionKey: string;
|
|
||||||
chatMessage: string;
|
|
||||||
chatAttachments: ChatAttachment[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChatRenderLifecycleScope = {
|
type ChatRenderLifecycleScope = {
|
||||||
connectionEpoch: number;
|
connectionEpoch: number;
|
||||||
cancellations: Set<() => void>;
|
cancellations: Set<() => void>;
|
||||||
@@ -42,7 +35,6 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||||||
private forceScrollAfterUpdate = false;
|
private forceScrollAfterUpdate = false;
|
||||||
private chatThreadResizeObserver: ResizeObserver | null = null;
|
private chatThreadResizeObserver: ResizeObserver | null = null;
|
||||||
private chatThreadResizeTarget: Element | null = null;
|
private chatThreadResizeTarget: Element | null = null;
|
||||||
private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null;
|
|
||||||
private readonly cleanups: Array<() => void> = [];
|
private readonly cleanups: Array<() => void> = [];
|
||||||
private renderLifecycleConnected = false;
|
private renderLifecycleConnected = false;
|
||||||
private renderLifecycleConnectionEpoch = 0;
|
private renderLifecycleConnectionEpoch = 0;
|
||||||
@@ -314,47 +306,14 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||||||
this.composerPersistence.start();
|
this.composerPersistence.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
persistComposerForRouteSwitch(): ChatComposerPersistResult {
|
persistComposerForEviction(): ChatComposerPersistResult {
|
||||||
return this.composerPersistence.persistForRouteSwitchResult();
|
return this.composerPersistence.persistForRouteSwitchResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
composerScopeForRouteSwitch(): StoredChatOutboxScope | null {
|
composerScopeForEviction(): StoredChatOutboxScope | null {
|
||||||
return this.composerPersistence.scopeForRouteSwitch();
|
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() {
|
private stopChatEffects() {
|
||||||
this.chatThreadResizeObserver?.disconnect();
|
this.chatThreadResizeObserver?.disconnect();
|
||||||
this.chatThreadResizeObserver = null;
|
this.chatThreadResizeObserver = null;
|
||||||
@@ -387,6 +346,5 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||||||
this.scrollAfterUpdate = false;
|
this.scrollAfterUpdate = false;
|
||||||
this.scrollContentChangedAfterUpdate = false;
|
this.scrollContentChangedAfterUpdate = false;
|
||||||
this.forceScrollAfterUpdate = false;
|
this.forceScrollAfterUpdate = false;
|
||||||
this.pendingCreatedSessionComposer = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { loadLocalAssistantIdentity } from "../../app/assistant-identity.ts";
|
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 { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts";
|
||||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
|
||||||
import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts";
|
import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts";
|
||||||
import {
|
import {
|
||||||
areUiSessionKeysEquivalent,
|
areUiSessionKeysEquivalent,
|
||||||
canonicalUiSessionKeyForPersistence,
|
|
||||||
isUiGlobalSessionKey,
|
isUiGlobalSessionKey,
|
||||||
isUiGlobalScopeConfigured,
|
isUiGlobalScopeConfigured,
|
||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
@@ -13,44 +11,7 @@ import {
|
|||||||
resolveUiSelectedGlobalAgentId,
|
resolveUiSelectedGlobalAgentId,
|
||||||
uiSessionRowMatchesSelectedChat,
|
uiSessionRowMatchesSelectedChat,
|
||||||
} from "../../lib/sessions/session-key.ts";
|
} 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 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) {
|
export function canCreateChatSession(state: ChatPageHost) {
|
||||||
return (
|
return (
|
||||||
@@ -93,47 +54,6 @@ export function selectedChatSessionRow(state: ChatPageHost) {
|
|||||||
return row;
|
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) {
|
export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string) {
|
||||||
if (
|
if (
|
||||||
state.settings.sessionKey === sessionKey &&
|
state.settings.sessionKey === sessionKey &&
|
||||||
@@ -144,156 +64,6 @@ export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string
|
|||||||
state.settings = patchSettings({ sessionKey, lastActiveSessionKey: sessionKey });
|
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) {
|
export function resolveChatAgentId(state: ChatPageHost) {
|
||||||
return normalizeAgentId(
|
return normalizeAgentId(
|
||||||
parseAgentSessionKey(state.sessionKey)?.agentId ??
|
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 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);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
let copiedBlob: Blob | undefined;
|
let copiedBlob: Blob | undefined;
|
||||||
class ClipboardItemMock {
|
class ClipboardItemMock {
|
||||||
|
|||||||
@@ -337,34 +337,6 @@ describe("chat transcript row measurement", () => {
|
|||||||
expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true);
|
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", () => {
|
it("pauses an unmeasurable restore until loading commits an empty transcript", () => {
|
||||||
const transcript = createTestTranscript();
|
const transcript = createTestTranscript();
|
||||||
const container = document.body.appendChild(document.createElement("div"));
|
const container = document.body.appendChild(document.createElement("div"));
|
||||||
@@ -407,61 +379,6 @@ describe("chat transcript row measurement", () => {
|
|||||||
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
|
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 () => {
|
it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => {
|
||||||
const transcript = createTestTranscript();
|
const transcript = createTestTranscript();
|
||||||
const container = document.body.appendChild(document.createElement("div"));
|
const container = document.body.appendChild(document.createElement("div"));
|
||||||
@@ -571,9 +488,10 @@ describe("chat transcript row measurement", () => {
|
|||||||
transcript.hostUpdated();
|
transcript.hostUpdated();
|
||||||
await flushDeferredRowPrune();
|
await flushDeferredRowPrune();
|
||||||
|
|
||||||
|
const thumbnailSource = source.replace(/\/full$/u, "/thumbnail");
|
||||||
const previousResource = observeChatMediaResource<string | null>(
|
const previousResource = observeChatMediaResource<string | null>(
|
||||||
"managed-image",
|
"managed-image",
|
||||||
`${source.replace(/\/full$/u, "/thumbnail")}::old-token::`,
|
`${thumbnailSource}::old-token::`,
|
||||||
);
|
);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(previousResource.subscribers.size).toBe(1);
|
expect(previousResource.subscribers.size).toBe(1);
|
||||||
@@ -593,7 +511,7 @@ describe("chat transcript row measurement", () => {
|
|||||||
|
|
||||||
const nextResource = observeChatMediaResource<string | null>(
|
const nextResource = observeChatMediaResource<string | null>(
|
||||||
"managed-image",
|
"managed-image",
|
||||||
`${source.replace(/\/full$/u, "/thumbnail")}::next-token::`,
|
`${thumbnailSource}::next-token::`,
|
||||||
);
|
);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
|
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
|
// A committed short transcript can legitimately remain at maxOffset=0. Give
|
||||||
// initial measurement one second before treating that zero range as final.
|
// initial measurement one second before treating that zero range as final.
|
||||||
const CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES = 60;
|
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) {
|
function initialTranscriptRect(host: ReactiveControllerHost) {
|
||||||
const width = host instanceof HTMLElement ? host.clientWidth : 0;
|
const width = host instanceof HTMLElement ? host.clientWidth : 0;
|
||||||
const height = host instanceof HTMLElement ? host.clientHeight : 0;
|
const height = host instanceof HTMLElement ? host.clientHeight : 0;
|
||||||
@@ -698,7 +694,6 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
|
|||||||
export class ChatTranscriptController implements ReactiveController {
|
export class ChatTranscriptController implements ReactiveController {
|
||||||
private activeSessionKey: string | null = null;
|
private activeSessionKey: string | null = null;
|
||||||
private sessionVirtualizer: ChatSessionVirtualizerHost | null = null;
|
private sessionVirtualizer: ChatSessionVirtualizerHost | null = null;
|
||||||
private readonly sessionVirtualizers = new Map<string, ChatSessionVirtualizerHost>();
|
|
||||||
private connected = false;
|
private connected = false;
|
||||||
|
|
||||||
constructor(private readonly host: ReactiveControllerHost) {
|
constructor(private readonly host: ReactiveControllerHost) {
|
||||||
@@ -715,37 +710,19 @@ export class ChatTranscriptController implements ReactiveController {
|
|||||||
this.activeSessionKey === null ||
|
this.activeSessionKey === null ||
|
||||||
!areUiSessionKeysEquivalent(this.activeSessionKey, props.sessionKey)
|
!areUiSessionKeysEquivalent(this.activeSessionKey, props.sessionKey)
|
||||||
) {
|
) {
|
||||||
this.sessionVirtualizer?.disconnect();
|
this.sessionVirtualizer?.dispose();
|
||||||
let cachedKey: string | null = null;
|
const savedPosition = getChatSessionScrollPosition(props.paneId, props.sessionKey);
|
||||||
let nextVirtualizer: ChatSessionVirtualizerHost | null = null;
|
const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? 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.activeSessionKey = props.sessionKey;
|
this.activeSessionKey = props.sessionKey;
|
||||||
this.sessionVirtualizer = nextVirtualizer;
|
this.sessionVirtualizer = new ChatSessionVirtualizerHost(
|
||||||
this.sessionVirtualizers.set(props.sessionKey, nextVirtualizer);
|
this.host,
|
||||||
this.evictInactiveVirtualizers();
|
initialOffset,
|
||||||
|
initialOffset === null
|
||||||
|
? undefined
|
||||||
|
: (position) => {
|
||||||
|
saveChatSessionScrollPosition(props.paneId, props.sessionKey, position);
|
||||||
|
},
|
||||||
|
);
|
||||||
if (this.connected) {
|
if (this.connected) {
|
||||||
this.sessionVirtualizer.connect();
|
this.sessionVirtualizer.connect();
|
||||||
}
|
}
|
||||||
@@ -787,23 +764,7 @@ export class ChatTranscriptController implements ReactiveController {
|
|||||||
|
|
||||||
hostDisconnected(): void {
|
hostDisconnected(): void {
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
for (const virtualizer of this.sessionVirtualizers.values()) {
|
this.sessionVirtualizer?.disconnect();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1892,7 +1853,7 @@ function renderChatThreadContents(
|
|||||||
<span
|
<span
|
||||||
class="chat-transcript-announcement agent-chat__sr-only"
|
class="chat-transcript-announcement agent-chat__sr-only"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live=${props.announceTranscript !== false ? "polite" : "off"}
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>${transcript.liveAnnouncementText}</span
|
>${transcript.liveAnnouncementText}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ type PendingHandoff = {
|
|||||||
|
|
||||||
export type ChatPaneElement = HTMLElement & {
|
export type ChatPaneElement = HTMLElement & {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
discardStagedAttachments?: () => void;
|
||||||
paneId?: string;
|
paneId?: string;
|
||||||
|
prepareForEviction?: () => void;
|
||||||
|
presented?: boolean;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+7
-118
@@ -2,23 +2,13 @@ import type { RouteLocation, RouteMatch } from "@openclaw/uirouter";
|
|||||||
import { definePage } from "@openclaw/uirouter";
|
import { definePage } from "@openclaw/uirouter";
|
||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { INTERNAL_SESSION_PATH_PARAM, pathForRoute, routePageSpec } from "../../app-route-paths.ts";
|
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 type { ApplicationContext } from "../../app/context.ts";
|
||||||
import { t } from "../../i18n/index.ts";
|
import { t } from "../../i18n/index.ts";
|
||||||
import type { BoardFace } from "../../lib/board/settings.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";
|
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" }>) {
|
function renderAmbiguous(data: Extract<ChatRouteData, { kind: "ambiguous" }>) {
|
||||||
return html`
|
return html`
|
||||||
@@ -63,109 +53,9 @@ function sessionLoaderDeps(
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionOwnerKey(sessionKey: string): string {
|
function sessionRenderOwnerKey(match: SessionOwnerMatch): string | undefined {
|
||||||
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 {
|
|
||||||
const data = match.data as ChatRouteData | undefined;
|
const data = match.data as ChatRouteData | undefined;
|
||||||
if (data?.kind === "ambiguous") {
|
return data?.kind === "ambiguous" ? undefined : CHAT_PAGE_OWNER_KEY;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionPage(face: BoardFace) {
|
function sessionPage(face: BoardFace) {
|
||||||
@@ -182,10 +72,9 @@ function sessionPage(face: BoardFace) {
|
|||||||
component: () =>
|
component: () =>
|
||||||
import("./chat-page.ts").then(() => ({
|
import("./chat-page.ts").then(() => ({
|
||||||
header: true,
|
header: true,
|
||||||
// ChatPage owns pane/session teardown. The route namespace only changes
|
// ChatPage's bounded inner cache owns per-session teardown, so session
|
||||||
// presentation, so it must not preempt that owner during face switches.
|
// routes share the outer owner while their data and URL keep changing.
|
||||||
renderOwnerKey: (match: SessionOwnerMatch, settled?: SessionOwnerMatch) =>
|
renderOwnerKey: sessionRenderOwnerKey,
|
||||||
sessionRenderOwnerKey(face, match, settled),
|
|
||||||
render: (data: unknown) => {
|
render: (data: unknown) => {
|
||||||
const routeData = data as ChatRouteData | undefined;
|
const routeData = data as ChatRouteData | undefined;
|
||||||
if (!routeData) {
|
if (!routeData) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getChatSessionScrollPosition,
|
getChatSessionScrollPosition,
|
||||||
handleChatScroll,
|
handleChatScroll,
|
||||||
resetChatScroll,
|
resetChatScroll,
|
||||||
restoreChatScroll,
|
|
||||||
saveChatSessionScrollPosition,
|
saveChatSessionScrollPosition,
|
||||||
scheduleChatScroll,
|
scheduleChatScroll,
|
||||||
} from "./scroll.ts";
|
} from "./scroll.ts";
|
||||||
@@ -119,9 +118,12 @@ describe("handleChatScroll", () => {
|
|||||||
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
|
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
|
||||||
const { host } = createScrollHost({});
|
const { host } = createScrollHost({});
|
||||||
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
|
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
|
||||||
|
host.chatLastScrollTop = 1600;
|
||||||
const event = createScrollEvent(2000, 500, 400);
|
const event = createScrollEvent(2000, 500, 400);
|
||||||
handleChatScroll(host, event);
|
handleChatScroll(host, event);
|
||||||
expect(host.chatUserNearBottom).toBe(false);
|
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", () => {
|
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);
|
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", () => {
|
it("keeps only the newest equivalent session-key scroll position", () => {
|
||||||
saveChatSessionScrollPosition("alias-pane", "main", {
|
saveChatSessionScrollPosition("alias-pane", "main", {
|
||||||
scrollTop: 100,
|
scrollTop: 100,
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ export function handleChatScroll(host: ChatScrollHost, event: Event): void {
|
|||||||
}
|
}
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
if (isUserScrollUp && distanceFromBottom > CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
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;
|
host.chatFollowLocked = true;
|
||||||
} else if (distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
} else if (distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
||||||
host.chatFollowLocked = false;
|
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 {
|
export function resetChatScroll(host: ChatScrollHost): void {
|
||||||
cancelChatScroll(host);
|
cancelChatScroll(host);
|
||||||
host.chatHasAutoScrolled = false;
|
host.chatHasAutoScrolled = false;
|
||||||
|
|||||||
@@ -150,17 +150,23 @@ describe("session message cache", () => {
|
|||||||
|
|
||||||
it("restores messages, pagination, and backing session identity together", () => {
|
it("restores messages, pagination, and backing session identity together", () => {
|
||||||
const { host, cache } = createCacheContext();
|
const { host, cache } = createCacheContext();
|
||||||
|
const messages = ["oldest", "latest"];
|
||||||
|
const pagination = { hasMore: true as const, nextOffset: 400, totalMessages: 718 };
|
||||||
cacheHomeSnapshot(cache, host, {
|
cacheHomeSnapshot(cache, host, {
|
||||||
messages: ["oldest", "latest"],
|
messages,
|
||||||
pagination: { hasMore: true, nextOffset: 400, totalMessages: 718 },
|
pagination,
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(readChatSessionSnapshot(cache, host, { sessionKey: "home" })).toEqual({
|
const snapshot = readChatSessionSnapshot(cache, host, { sessionKey: "home" });
|
||||||
|
expect(snapshot).toEqual({
|
||||||
messages: ["oldest", "latest"],
|
messages: ["oldest", "latest"],
|
||||||
pagination: { hasMore: true, nextOffset: 400, totalMessages: 718 },
|
pagination: { hasMore: true, nextOffset: 400, totalMessages: 718 },
|
||||||
sessionId: "session-1",
|
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", () => {
|
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 { host, cache } = createCacheContext();
|
||||||
const retained = Array.from({ length: 140 }, (_, index) => ({
|
const target = { sessionKey: "agent:ops:background" };
|
||||||
content: `retained-${index + 1}`,
|
const event = {};
|
||||||
__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 },
|
|
||||||
}));
|
|
||||||
|
|
||||||
cacheHomeSnapshot(cache, host, {
|
appendChatMessageToCache(cache, host, target, "final", event);
|
||||||
messages: refreshedTail,
|
appendChatMessageToCache(cache, host, target, "final", event);
|
||||||
pagination: { hasMore: true, nextOffset: 40, totalMessages: 140 },
|
|
||||||
sessionId: "session-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = readChatSessionSnapshot(cache, host, { sessionKey: "home" });
|
expect(readChatMessagesFromCache(cache, host, target)).toEqual(["final"]);
|
||||||
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",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not retain history across backing session changes", () => {
|
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
|
// History reconciliation replaces changed messages and retains unchanged
|
||||||
// objects, so serialization weight can follow the same immutable identity.
|
// objects, so serialization weight can follow the same immutable identity.
|
||||||
const cachedMessageWeights = new WeakMap<object, number>();
|
const cachedMessageWeights = new WeakMap<object, number>();
|
||||||
|
const appendedEventClaims = new WeakMap<ChatMessageCache, WeakSet<object>>();
|
||||||
|
|
||||||
export type ChatSessionSnapshot = {
|
type ChatSessionSnapshot = {
|
||||||
displayedLeafEntryId?: string | null;
|
displayedLeafEntryId?: string | null;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
pagination: ChatHistoryPagination;
|
pagination: ChatHistoryPagination;
|
||||||
@@ -30,9 +31,6 @@ export type ChatSessionSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CachedChatSessionSnapshot = {
|
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;
|
snapshot: ChatSessionSnapshot;
|
||||||
weight: number;
|
weight: number;
|
||||||
};
|
};
|
||||||
@@ -91,7 +89,19 @@ export function appendChatMessageToCache(
|
|||||||
host: ChatMessageCacheHost,
|
host: ChatMessageCacheHost,
|
||||||
target: ChatMessageCacheTarget,
|
target: ChatMessageCacheTarget,
|
||||||
message: unknown,
|
message: unknown,
|
||||||
|
eventClaim?: object,
|
||||||
): void {
|
): 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 cacheKey = resolveChatMessageCacheKey(host, target);
|
||||||
const existing = getSessionCacheValue(cache, cacheKey);
|
const existing = getSessionCacheValue(cache, cacheKey);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -122,7 +132,6 @@ export function appendChatMessageToCache(
|
|||||||
}
|
}
|
||||||
setSessionCacheValue(cache, cacheKey, {
|
setSessionCacheValue(cache, cacheKey, {
|
||||||
snapshot,
|
snapshot,
|
||||||
sourceMessages: snapshot.messages,
|
|
||||||
weight,
|
weight,
|
||||||
});
|
});
|
||||||
trimChatSessionSnapshotCache(cache);
|
trimChatSessionSnapshotCache(cache);
|
||||||
@@ -153,7 +162,7 @@ export function cacheChatSessionSnapshot(
|
|||||||
const cacheKey = resolveChatMessageCacheKey(host, target);
|
const cacheKey = resolveChatMessageCacheKey(host, target);
|
||||||
const existing = getSessionCacheValue(cache, cacheKey);
|
const existing = getSessionCacheValue(cache, cacheKey);
|
||||||
if (
|
if (
|
||||||
existing?.sourceMessages === snapshot.messages &&
|
existing?.snapshot.messages === snapshot.messages &&
|
||||||
existing.snapshot.sessionId === snapshot.sessionId &&
|
existing.snapshot.sessionId === snapshot.sessionId &&
|
||||||
existing.snapshot.displayedLeafEntryId === snapshot.displayedLeafEntryId &&
|
existing.snapshot.displayedLeafEntryId === snapshot.displayedLeafEntryId &&
|
||||||
samePagination(existing.snapshot.pagination, snapshot.pagination)
|
samePagination(existing.snapshot.pagination, snapshot.pagination)
|
||||||
@@ -170,9 +179,7 @@ export function cacheChatSessionSnapshot(
|
|||||||
cache.delete(cacheKey);
|
cache.delete(cacheKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bounded = boundChatSessionSnapshot(
|
const bounded = boundChatSessionSnapshot(snapshot);
|
||||||
mergeRetainedSessionDepth(existing?.snapshot ?? null, snapshot),
|
|
||||||
);
|
|
||||||
if (!bounded) {
|
if (!bounded) {
|
||||||
cache.delete(cacheKey);
|
cache.delete(cacheKey);
|
||||||
return;
|
return;
|
||||||
@@ -181,98 +188,12 @@ export function cacheChatSessionSnapshot(
|
|||||||
trimChatSessionSnapshotCache(cache);
|
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(
|
export function readChatSessionSnapshot(
|
||||||
cache: ChatMessageCache,
|
cache: ChatMessageCache,
|
||||||
host: ChatMessageCacheHost,
|
host: ChatMessageCacheHost,
|
||||||
target: ChatMessageCacheTarget,
|
target: ChatMessageCacheTarget,
|
||||||
): ChatSessionSnapshot | null {
|
): ChatSessionSnapshot | null {
|
||||||
const cached = getSessionCacheValue(cache, resolveChatMessageCacheKey(host, target));
|
return getSessionCacheValue(cache, resolveChatMessageCacheKey(host, target))?.snapshot ?? null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSessionSnapshot | null {
|
function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSessionSnapshot | null {
|
||||||
@@ -298,14 +219,14 @@ function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSess
|
|||||||
messageWeights.length - start,
|
messageWeights.length - start,
|
||||||
);
|
);
|
||||||
if (weight !== null && weight <= MAX_CACHED_CHAT_SNAPSHOT_WEIGHT) {
|
if (weight !== null && weight <= MAX_CACHED_CHAT_SNAPSHOT_WEIGHT) {
|
||||||
|
const messages = start === 0 ? snapshot.messages : snapshot.messages.slice(start);
|
||||||
return {
|
return {
|
||||||
sourceMessages: snapshot.messages,
|
|
||||||
snapshot: {
|
snapshot: {
|
||||||
...(Object.hasOwn(snapshot, "displayedLeafEntryId")
|
...(Object.hasOwn(snapshot, "displayedLeafEntryId")
|
||||||
? { displayedLeafEntryId: snapshot.displayedLeafEntryId }
|
? { displayedLeafEntryId: snapshot.displayedLeafEntryId }
|
||||||
: {}),
|
: {}),
|
||||||
messages: snapshot.messages.slice(start),
|
messages,
|
||||||
pagination: { ...pagination },
|
pagination: start === 0 ? pagination : { ...pagination },
|
||||||
sessionId: snapshot.sessionId,
|
sessionId: snapshot.sessionId,
|
||||||
},
|
},
|
||||||
weight,
|
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: {
|
export function rememberAuthoritativeTerminal(options: {
|
||||||
event: {
|
event: {
|
||||||
clientRunId?: string | null;
|
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);
|
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 {
|
.chat-split-view__pane {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -57,6 +78,12 @@ openclaw-chat-pane {
|
|||||||
min-height: 0;
|
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. */
|
/* Narrow mode shows only the active pane; a focus ring is pure noise there. */
|
||||||
.chat-split-view--narrow .chat-split-view__cell--active {
|
.chat-split-view--narrow .chat-split-view__cell--active {
|
||||||
box-shadow: none;
|
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";
|
} from "../app-sidebar.ts";
|
||||||
import { waitForFast } from "../wait-for.ts";
|
import { waitForFast } from "../wait-for.ts";
|
||||||
import "./session-pagination.ts";
|
import "./session-pagination.ts";
|
||||||
|
import "./session-navigation.ts";
|
||||||
|
|
||||||
describe("AppSidebar session pagination", () => {
|
describe("AppSidebar session pagination", () => {
|
||||||
it("does not show pagination controls at the ten-session boundary", async () => {
|
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();
|
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 {
|
export function controlUiBundledGatewayUrl(baseUrl: string): string {
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|||||||
Reference in New Issue
Block a user