From a5ee5039ce9c7f59b40a5ac95b296f0cd747cf04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 01:07:21 -0400 Subject: [PATCH] test(ui): split the oversized Control UI e2e suites (#114889) * test(ui): split oversized Control UI e2e suites * test(ui): bound new-session locator polling * test(ui): fix split e2e metadata * test(ui): give startup waits an explicit budget --- config/max-lines-baseline.txt | 3 - .../ui/control-ui-chat-flow-playwright.yaml | 4 +- ui/src/app/bootstrap.test.ts | 11 +- ui/src/e2e/chat-flow.e2e.test.ts | 4560 ----------------- ui/src/e2e/chat-flow.follow-ups.e2e.test.ts | 583 +++ .../chat-flow.history-recovery.e2e.test.ts | 629 +++ ui/src/e2e/chat-flow.media-files.e2e.test.ts | 716 +++ ui/src/e2e/chat-flow.messaging.e2e.test.ts | 594 +++ .../chat-flow.models-reasoning.e2e.test.ts | 465 ++ ...t-flow.navigation-presentation.e2e.test.ts | 774 +++ ui/src/e2e/chat-flow.streaming.e2e.test.ts | 623 +++ ui/src/e2e/chat-flow.test-support.ts | 233 + .../e2e/control-ui-e2e-suite.test-support.ts | 93 + ...session-page.catalog-reconnect.e2e.test.ts | 427 ++ ...new-session-page.cloud-startup.e2e.test.ts | 944 ++++ ui/src/e2e/new-session-page.e2e.test.ts | 4467 ---------------- .../e2e/new-session-page.places.e2e.test.ts | 789 +++ ...ession-page.prompt-attachments.e2e.test.ts | 836 +++ ui/src/e2e/new-session-page.test-support.ts | 182 + ...-session-page.workspace-memory.e2e.test.ts | 719 +++ ...sion-page.workspace-validation.e2e.test.ts | 657 +++ .../session-management.archive.e2e.test.ts | 352 ++ ui/src/e2e/session-management.e2e.test.ts | 1926 ------- .../e2e/session-management.groups.e2e.test.ts | 790 +++ .../session-management.sidebar.e2e.test.ts | 677 +++ ui/src/e2e/session-management.test-support.ts | 144 + 26 files changed, 11237 insertions(+), 10961 deletions(-) delete mode 100644 ui/src/e2e/chat-flow.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.follow-ups.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.history-recovery.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.media-files.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.messaging.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.streaming.e2e.test.ts create mode 100644 ui/src/e2e/chat-flow.test-support.ts create mode 100644 ui/src/e2e/control-ui-e2e-suite.test-support.ts create mode 100644 ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts create mode 100644 ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts delete mode 100644 ui/src/e2e/new-session-page.e2e.test.ts create mode 100644 ui/src/e2e/new-session-page.places.e2e.test.ts create mode 100644 ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts create mode 100644 ui/src/e2e/new-session-page.test-support.ts create mode 100644 ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts create mode 100644 ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts create mode 100644 ui/src/e2e/session-management.archive.e2e.test.ts delete mode 100644 ui/src/e2e/session-management.e2e.test.ts create mode 100644 ui/src/e2e/session-management.groups.e2e.test.ts create mode 100644 ui/src/e2e/session-management.sidebar.e2e.test.ts create mode 100644 ui/src/e2e/session-management.test-support.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f5ac5fa288da..18e84a1385ee 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -986,9 +986,6 @@ src/worker/worker.runtime.test.ts ui/src/api/gateway.node.test.ts ui/src/api/types.ts ui/src/app/app-host.ts -ui/src/e2e/chat-flow.e2e.test.ts -ui/src/e2e/new-session-page.e2e.test.ts -ui/src/e2e/session-management.e2e.test.ts ui/src/lib/config/index.test.ts ui/src/lib/config/index.ts ui/src/lib/cron/index.test.ts diff --git a/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml b/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml index 4c637ba7325e..9e182517a012 100644 --- a/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml +++ b/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml @@ -12,9 +12,9 @@ scenario: docsRefs: - docs/web/control-ui.md codeRefs: - - ui/src/e2e/chat-flow.e2e.test.ts + - ui/src/e2e/chat-flow.messaging.e2e.test.ts execution: kind: playwright - path: ui/src/e2e/chat-flow.e2e.test.ts + path: ui/src/e2e/chat-flow.messaging.e2e.test.ts testNamePattern: sends a chat turn through the GUI and renders the final Gateway event summary: Focused Playwright smoke coverage for the Control UI chat flow. diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index f8022b18588e..8fa6818859e3 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -15,6 +15,11 @@ import { bootstrapApplication } from "./bootstrap.ts"; import type { ApplicationContext } from "./context.ts"; import { loadSettings, saveSettings } from "./settings.ts"; +// Startup progress (dynamic imports, gateway subscribe, router start) is not a +// performance assertion, so these waits must not inherit vi.waitFor's 1s default: +// under a loaded CI runner that budget expires before startup reaches the step. +const STARTUP_STEP_WAIT = { timeout: 15_000 }; + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -366,7 +371,7 @@ describe("normalizeInitialApplicationLocation", () => { features: { methods: ["openclaw.setup.detect"] }, }, } as Parameters[0]); - await vi.waitFor(() => expect(replaceRoute).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(replaceRoute).toHaveBeenCalledOnce(), STARTUP_STEP_WAIT); expect(replaceRoute).toHaveBeenCalledWith("model-setup", { search: "?firstRun=1" }); }); @@ -515,7 +520,7 @@ describe("normalizeInitialApplicationLocation", () => { try { const start = runtime.start(); - await vi.waitFor(() => expect(activeSubscriptions.size).toBe(1)); + await vi.waitFor(() => expect(activeSubscriptions.size).toBe(1), STARTUP_STEP_WAIT); runtime.stop(); await start; @@ -545,7 +550,7 @@ describe("normalizeInitialApplicationLocation", () => { try { const start = runtime.start(); - await vi.waitFor(() => expect(routerStart).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(routerStart).toHaveBeenCalledOnce(), STARTUP_STEP_WAIT); runtime.stop(); expect(routerStop).toHaveBeenCalledOnce(); diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts deleted file mode 100644 index 6007db65e659..000000000000 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ /dev/null @@ -1,4560 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -// Control UI tests cover chat flow behavior. -import { expectDefined } from "@openclaw/normalization-core"; -import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SESSION_DRAG_MIME } from "../lib/sessions/drag.ts"; -import { - canRunPlaywrightChromium, - controlUiSessionPath, - controlUiSessionUrl, - installMockGateway, - resolvePlaywrightChromiumExecutablePath, - startControlUiE2eServer, - type ControlUiE2eServer, - type MockGatewayRequest, -} from "../test-helpers/control-ui-e2e.ts"; - -const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); -const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); -const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; -const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; -const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; -const sessionAccessibilityProofDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "session-accessibility", -); -const managedImageCacheProofDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "managed-image-cache", -); -const channelStopProofDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "channel-stop", -); - -let server: ControlUiE2eServer; -// Browser contexts preserve test isolation; keep one process warm for this file. -let browser: Browser; -const openBrowserContexts = new Set(); - -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} - -function requireString(value: unknown, label: string): string { - if (typeof value !== "string" || !value.trim()) { - throw new Error(`Expected non-empty ${label}`); - } - return value; -} - -async function waitForRequests( - gateway: Awaited>, - method: string, - count: number, -): Promise { - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const requests = await gateway.getRequests(method); - if (requests.length >= count) { - return requests; - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } - throw new Error(`Timed out waiting for ${count} ${method} requests`); -} - -async function expectRequestCountStable( - gateway: Awaited>, - method: string, - count: number, - durationMs = 500, -): Promise { - const deadline = Date.now() + durationMs; - do { - expect(await gateway.getRequests(method)).toHaveLength(count); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - } while (Date.now() < deadline); -} - -async function installPlainHttpClipboardCapture(page: Page): Promise { - await page.addInitScript(() => { - Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); - (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec = []; - document.execCommand = ((command: string) => { - if (command !== "copy") { - return false; - } - // execCommand("copy") copies the active selection; the fallback selects - // its off-screen scratch textarea, so the focused element holds the text. - const active = document.activeElement as HTMLTextAreaElement | null; - (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec.push( - active?.value ?? "", - ); - return true; - }) as typeof document.execCommand; - }); -} - -async function copiedViaExec(page: Page): Promise { - return page.evaluate(() => (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec); -} - -async function chatThreadDistanceFromBottom(page: Page): Promise { - return page.locator(".chat-thread").evaluate((element) => { - const thread = element as HTMLElement; - return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight); - }); -} - -async function waitForChatScrollIdle(page: Page): Promise { - await expect - .poll( - () => - page.locator(".chat-thread").evaluate(async (element) => { - const thread = element as HTMLElement; - const readGeometry = () => ({ - clientHeight: thread.clientHeight, - scrollHeight: thread.scrollHeight, - scrollTop: Math.round(thread.scrollTop), - }); - const before = readGeometry(); - // The chat scroll owner may do one bounded 120/150ms late-size retry. - await new Promise((resolve) => { - globalThis.setTimeout(resolve, 180); - }); - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - const after = readGeometry(); - return ( - before.clientHeight === after.clientHeight && - before.scrollHeight === after.scrollHeight && - before.scrollTop === after.scrollTop - ); - }), - { timeout: 10_000 }, - ) - .toBe(true); -} - -async function scrollChatThreadToTop(page: Page): Promise { - await page.locator(".chat-thread").evaluate((element) => { - const thread = element as HTMLElement; - thread.scrollTop = 0; - thread.dispatchEvent(new Event("scroll", { bubbles: true })); - }); -} - -async function newBrowserContext(options: Parameters[0]) { - const context = await browser.newContext(options); - openBrowserContexts.add(context); - return context; -} - -async function closeBrowserContext(context: BrowserContext): Promise { - openBrowserContexts.delete(context); - await context.close().catch(() => {}); -} - -async function closeOpenBrowserContexts(): Promise { - await Promise.all([...openBrowserContexts].map((context) => closeBrowserContext(context))); -} - -async function captureSessionAccessibilityProof(page: Page, name: string): Promise { - if (!captureUiProofEnabled) { - return; - } - await mkdir(sessionAccessibilityProofDir, { recursive: true }); - const sidebar = page.locator("openclaw-app-sidebar"); - await page.screenshot({ - fullPage: true, - path: path.join(sessionAccessibilityProofDir, `${name}.png`), - }); - await writeFile( - path.join(sessionAccessibilityProofDir, `${name}.yml`), - await sidebar.ariaSnapshot(), - "utf8", - ); -} - -async function visibleChatBubbleTexts(page: Page): Promise { - return page.locator(".chat-thread").evaluate((element) => { - const thread = element as HTMLElement; - const viewport = thread.getBoundingClientRect(); - return Array.from(thread.querySelectorAll(".chat-bubble")) - .filter((candidate) => { - const rect = candidate.getBoundingClientRect(); - return ( - rect.height > 0 && - rect.width > 0 && - rect.bottom > viewport.top && - rect.top < viewport.bottom - ); - }) - .map((candidate) => candidate.textContent?.trim() ?? "") - .filter(Boolean); - }); -} -function chatSessionListResponse( - sessions: Array< - Record & { - key: string; - kind: string; - label: string; - updatedAt: number; - } - > = [ - { - key: "agent:main:session-a", - kind: "direct", - label: "Session A", - updatedAt: 2, - }, - { - key: "agent:main:session-b", - kind: "direct", - label: "Session B", - updatedAt: 1, - }, - ], -) { - return { - count: sessions.length, - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", - }, - path: "", - sessions, - ts: Date.now(), - }; -} - -async function sidebarSessionOrder(page: Page): Promise { - return page - .locator(".sidebar-recent-session") - .evaluateAll((rows) => - rows - .map((row) => row.getAttribute("data-session-key") ?? "") - .filter((key) => key.startsWith("agent:main:session-")), - ); -} - -describeControlUiE2e("Control UI mocked Gateway E2E", () => { - beforeAll(async () => { - if (!chromiumAvailable) { - throw new Error( - `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, - ); - } - browser = await chromium.launch({ executablePath: chromiumExecutablePath }); - try { - server = await startControlUiE2eServer(); - } catch (error) { - await browser.close(); - throw error; - } - }); - - afterAll(async () => { - await closeOpenBrowserContexts(); - await browser?.close(); - await server?.close(); - }); - - afterEach(async () => { - await closeOpenBrowserContexts(); - }); - - it("renders always-on pane headers without desktop topbar chrome", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1440 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ type: "text", text: "Split toolbar proof." }], - role: "assistant", - timestamp: Date.now(), - }, - ], - methodResponses: { "sessions.list": chatSessionListResponse() }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 }); - - // Desktop renders no topbar row: the sidebar owns navigation. - await expect.poll(() => page.locator(".topbar").isVisible()).toBe(false); - - const splitEntry = page.getByRole("button", { name: "Open split view" }); - await expect.poll(() => splitEntry.isVisible()).toBe(true); - await expect.poll(() => page.locator(".chat-pane__header").count()).toBe(1); - await page.evaluate(() => { - document.documentElement.classList.add("openclaw-native-macos"); - document.querySelector(".shell")?.classList.add("shell--nav-collapsed"); - }); - await expect - .poll(() => - page - .locator(".chat-pane__header") - .evaluate((header) => getComputedStyle(header).paddingLeft), - ) - .toBe("90px"); - await page.evaluate(() => { - document.documentElement.classList.remove("openclaw-native-macos"); - document.querySelector(".shell")?.classList.remove("shell--nav-collapsed"); - }); - await page.setViewportSize({ height: 900, width: 1100 }); - await expect.poll(() => splitEntry.isVisible()).toBe(true); - await page.setViewportSize({ height: 900, width: 1440 }); - await expect - .poll(() => - splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null), - ) - .toBe(true); - await page.locator("openclaw-chat-pane").evaluate((pane) => { - ( - globalThis as typeof globalThis & { - classicChatPane?: Element; - } - ).classicChatPane = pane; - }); - const startupRequestsBeforeSplit = (await gateway.getRequests("chat.startup")).length; - await gateway.deferNext("chat.startup"); - await splitEntry.click(); - await expect - .poll(async () => (await gateway.getRequests("chat.startup")).length) - .toBeGreaterThan(startupRequestsBeforeSplit); - - // Each pane owns the same in-flow header in classic and split layouts. - const panes = page.locator("openclaw-chat-pane.chat-split-view__pane"); - const headers = page.locator(".chat-pane__header"); - await expect.poll(() => panes.count()).toBe(2); - await panes.last().getByText("Split toolbar proof.").waitFor(); - await expect.poll(() => panes.last().locator(".chat-loading-skeleton").count()).toBe(0); - await gateway.resolveDeferred("chat.startup"); - await expect - .poll(() => - panes.first().evaluate( - (pane) => - ( - globalThis as typeof globalThis & { - classicChatPane?: Element; - } - ).classicChatPane === pane, - ), - ) - .toBe(true); - await expect.poll(() => headers.count()).toBe(2); - await expect - .poll(async () => { - const visible = await Promise.all((await headers.all()).map((pane) => pane.isVisible())); - return visible.every(Boolean); - }) - .toBe(true); - await expect.poll(() => splitEntry.count()).toBe(0); - // The pane header hosts the session workspace toggle (the old collapsed - // rail strip is gone). - await expect.poll(() => headers.first().locator(".chat-workspace-toggle").count()).toBe(1); - await expect.poll(() => page.locator(".chat-workspace-rail").count()).toBe(0); - - // Keyboard focus on a header action marks the pane active. - await headers.first().getByRole("button", { name: "Split down" }).focus(); - const cells = page.locator(".chat-split-view__cell"); - await expect.poll(() => cells.first().getAttribute("class")).toContain("--active"); - - const lastPane = page.locator(".chat-split-view__pane").last(); - await lastPane.click({ position: { x: 20, y: 80 } }); - await expect.poll(() => cells.last().getAttribute("class")).toContain("--active"); - const targetHeader = headers.first(); - await expect - .poll(() => - targetHeader.evaluate((header) => { - const owner = header.closest("openclaw-chat-pane"); - return ( - owner === header.parentElement && owner?.classList.contains("chat-split-view__pane") - ); - }), - ) - .toBe(true); - - const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); - await dataTransfer.evaluate( - (transfer, data) => { - transfer.setData(data.mime, data.sessionKey); - }, - { mime: SESSION_DRAG_MIME, sessionKey: "agent:main:session-b" }, - ); - const unrelatedTarget = page.locator(".chat-split-view"); - const unrelatedDrag = { - bubbles: true, - clientX: 0, - clientY: 0, - dataTransfer, - }; - await unrelatedTarget.dispatchEvent("dragenter", unrelatedDrag); - await unrelatedTarget.dispatchEvent("dragover", unrelatedDrag); - await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(0); - await unrelatedTarget.dispatchEvent("drop", unrelatedDrag); - await expect.poll(() => panes.count()).toBe(2); - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath("agent:main:session-a")); - - // Start with no retained pane preview and target the visible header. - const targetBox = await targetHeader.boundingBox(); - if (!targetBox) { - throw new Error("expected the pane header to have a layout box"); - } - const directHeaderDrag = { - bubbles: true, - clientX: targetBox.x + targetBox.width / 2, - clientY: targetBox.y + targetBox.height / 2, - dataTransfer, - }; - await targetHeader.dispatchEvent("dragenter", directHeaderDrag); - await targetHeader.dispatchEvent("dragover", directHeaderDrag); - await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(1); - await targetHeader.dispatchEvent("drop", directHeaderDrag); - await dataTransfer.dispose(); - - await expect.poll(() => panes.count()).toBe(3); - await expect - .poll(async () => - (await page.locator(".chat-pane__session-title").allTextContents()).map((title) => - title.trim(), - ), - ) - .toContain("Session B"); - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath("agent:main:session-b")); - } finally { - await closeBrowserContext(context); - } - }); - - it("sends a chat turn through the GUI and renders the final Gateway event", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Ready for an end-to-end GUI check.").waitFor({ timeout: 10_000 }); - - const prompt = "verify the control UI e2e harness"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - expect(params.sessionKey).toBe("main"); - expect(params.message).toBe(prompt); - expect(params.deliver).toBe(false); - - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - await gateway.emitChatFinal({ runId, text: "Harness verified." }); - - await page - .locator(".chat-thread-inner") - .getByText("Harness verified.") - .waitFor({ timeout: 10_000 }); - - const spacedPairCommand = "/ pair qr"; - await page.locator(".agent-chat__composer-combobox textarea").fill(spacedPairCommand); - await page.getByRole("button", { name: "Send message" }).click(); - - const commandRequests = await waitForRequests(gateway, "chat.send", 2); - const commandParams = requireRecord(commandRequests[1]?.params); - expect(commandParams.message).toBe(spacedPairCommand); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps a browser-local prompt before a clock-skewed Gateway reply", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - const prompt = "verify clock-skewed chat order"; - const partial = "The Gateway is replying from an earlier clock."; - const reply = "The Gateway reply stayed below its prompt."; - const appearsBefore = (lowerSelector: string, lowerText: string) => - page.locator(".chat-thread-inner").evaluate( - (thread: Element, texts: { lowerSelector: string; lowerText: string; prompt: string }) => { - const findByText = (selector: string, text: string) => - Array.from(thread.querySelectorAll(selector)).find((row) => - (row.textContent ?? "").includes(text), - ); - const promptRow = findByText(".chat-group.user", texts.prompt); - const lowerRow = findByText(texts.lowerSelector, texts.lowerText); - if (!promptRow || !lowerRow) { - return false; - } - return promptRow.getBoundingClientRect().top < lowerRow.getBoundingClientRect().top; - }, - { lowerSelector, lowerText, prompt }, - ); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.deferNext("chat.send"); - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const runId = requireString( - requireRecord(sendRequest.params).idempotencyKey, - "chat send idempotency key", - ); - const browserTimestamp = await page.evaluate(() => Date.now()); - const gatewayTimestamp = browserTimestamp - 60_000; - await gateway.emitGatewayEvent("chat", { - deltaText: partial, - message: { - content: [{ text: partial, type: "text" }], - role: "assistant", - timestamp: gatewayTimestamp, - }, - runId, - sessionKey: "main", - state: "delta", - }); - await page.locator(".chat-bubble.streaming", { hasText: partial }).waitFor({ - timeout: 10_000, - }); - expect(await appearsBefore(".chat-bubble.streaming", partial)).toBe(true); - - await gateway.emitGatewayEvent("chat", { - message: { - content: [{ text: reply, type: "text" }], - role: "assistant", - timestamp: gatewayTimestamp, - }, - runId, - sessionKey: "main", - state: "final", - }); - await page.locator(".chat-group.assistant .chat-text", { hasText: reply }).waitFor({ - timeout: 10_000, - }); - expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); - await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); - } finally { - await closeBrowserContext(context); - } - }); - - it("reconciles authoritative history before a trailing final by run identity", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { historyMessages: [] }); - try { - await page.goto(`${server.baseUrl}chat`); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.fill("reconcile the terminal event ordering"); - await page.getByRole("button", { name: "Send message" }).click(); - const send = await gateway.waitForRequest("chat.send"); - const runId = requireString( - requireRecord(send.params).idempotencyKey, - "chat send idempotency key", - ); - const finalText = "One authoritative final response."; - const messageId = "assistant-authoritative-final"; - const authoritative = { - __openclaw: { id: messageId, seq: 2 }, - content: [{ text: finalText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }; - await gateway.emitGatewayEvent("chat", { - deltaText: finalText, - message: { - content: [{ text: finalText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor(); - await gateway.setHistoryMessages([ - { - __openclaw: { id: "user-reconcile", seq: 1 }, - content: [{ text: "reconcile the terminal event ordering", type: "text" }], - role: "user", - timestamp: Date.now() - 1, - }, - authoritative, - ]); - const historyRequestsBefore = (await gateway.getRequests("chat.history")).length; - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [], - clientRunId: runId, - hasActiveRun: false, - message: authoritative, - messageId, - messageSeq: 2, - session: { - activeRunIds: [], - hasActiveRun: false, - key: "main", - kind: "direct", - status: "done", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - await expect - .poll(async () => (await gateway.getRequests("chat.history")).length) - .toBeGreaterThan(historyRequestsBefore); - await page.locator(".chat-thread-inner").getByText(finalText, { exact: true }).waitFor(); - await expect - .poll(() => - page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), - ) - .toBe(1); - - await gateway.emitChatFinal({ runId, text: finalText }); - await expect - .poll(() => page.locator(".chat-group.assistant .chat-duplicate-count").count()) - .toBe(0); - await expect - .poll(() => - page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), - ) - .toBe(1); - - await gateway.emitChatFinal({ runId: "a-different-legitimate-run", text: finalText }); - await expect - .poll(() => - page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), - ) - .toBe(2); - } finally { - await closeBrowserContext(context); - } - }); - - it("restores the selected session transcript after a hard reload", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const historyText = "Transcript survives a hard reload."; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: historyText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - sessionKey: "agent:main:main", - }); - - try { - await page.goto(controlUiSessionUrl(server.baseUrl, "main")); - await page.getByText(historyText).waitFor({ timeout: 10_000 }); - await gateway.waitForRequest("chat.startup"); - - await page.reload(); - - await page.getByText(historyText).waitFor({ timeout: 10_000 }); - await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(2); - } finally { - await closeBrowserContext(context); - } - }); - - it("sends idle stop aliases as ordinary chat messages", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - await composer.fill("wait"); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - expect(requireRecord(sendRequest.params).message).toBe("wait"); - expect(await gateway.getRequests("chat.abort")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("sends /stop to the exact selected channel session and clears its working indicator", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const channelSessionKey = "agent:main:openclaw-weixin:direct:wechat-user"; - const gateway = await installMockGateway(page, { - sessionKey: channelSessionKey, - methodResponses: { - "sessions.abort": { abortedRunId: null, ok: true, status: "aborted" }, - "sessions.list": chatSessionListResponse([ - { - hasActiveRun: true, - key: channelSessionKey, - kind: "direct", - label: "WeChat user", - status: "running", - updatedAt: Date.now(), - }, - ]), - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - await gateway.waitForRequest("sessions.list"); - const workingIndicator = page.locator(".chat-working-indicator"); - await workingIndicator.waitFor({ state: "visible", timeout: 10_000 }); - - await composer.fill("/stop"); - await page.getByRole("option", { name: /\/stop/ }).waitFor(); - await composer.press("Enter"); - - const abortRequest = await gateway.waitForRequest("sessions.abort"); - expect(requireRecord(abortRequest.params)).toEqual({ - key: channelSessionKey, - clearQueued: true, - }); - await gateway.setMethodResponse( - "sessions.list", - chatSessionListResponse([ - { - activeRunIds: [], - hasActiveRun: false, - key: channelSessionKey, - kind: "direct", - label: "WeChat user", - status: "running", - updatedAt: Date.now(), - }, - ]), - ); - await gateway.emitGatewayEvent("sessions.changed", { - activeRunIds: [], - hasActiveRun: false, - reason: "abort", - sessionKey: channelSessionKey, - status: "running", - updatedAt: Date.now(), - }); - await workingIndicator.waitFor({ state: "detached", timeout: 10_000 }); - await expectRequestCountStable(gateway, "chat.abort", 0); - await expectRequestCountStable(gateway, "chat.send", 0); - await expect.poll(() => page.getByRole("listbox").count()).toBe(0); - expect(await composer.inputValue()).toBe(""); - if (captureUiProofEnabled) { - await mkdir(channelStopProofDir, { recursive: true }); - await page.screenshot({ - path: path.join(channelStopProofDir, "stopped.png"), - fullPage: true, - }); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("persists the chat send shortcut and keeps multiline and IME input safe", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - - await composer.fill("default enter send"); - await composer.press("Enter"); - const defaultRequest = await gateway.waitForRequest("chat.send"); - const defaultParams = requireRecord(defaultRequest.params); - expect(defaultParams.message).toBe("default enter send"); - await gateway.emitChatFinal({ - runId: requireString(defaultParams.idempotencyKey, "default send idempotency key"), - text: "Default shortcut received.", - }); - await page - .locator(".chat-thread-inner") - .getByText("Default shortcut received.") - .waitFor({ timeout: 10_000 }); - - // The send shortcut moved to the Settings appearance page; picking it - // there must apply to the chat composer after navigating back. - await page.goto(`${server.baseUrl}settings/appearance`); - const shortcutSelect = page.locator("[data-settings-send-shortcut]"); - await shortcutSelect.selectOption("modifier-enter"); - expect(await shortcutSelect.inputValue()).toBe("modifier-enter"); - - await page.goto(`${server.baseUrl}chat`); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - expect(await composer.getAttribute("aria-keyshortcuts")).toBe("Control+Enter Meta+Enter"); - - await composer.fill("plain enter stays in the draft"); - await composer.press("Enter"); - expect(await composer.inputValue()).toContain("\n"); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - await composer.fill("composition must not send"); - await composer.dispatchEvent("compositionstart"); - await composer.press("Control+Enter"); - await composer.dispatchEvent("compositionend"); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - await composer.fill("modifier send"); - await composer.press("Meta+Enter"); - const modifierRequest = await gateway.waitForRequest("chat.send"); - expect(requireRecord(modifierRequest.params).message).toBe("modifier send"); - } finally { - await closeBrowserContext(context); - } - }); - - it("steers an active run when the session row only reports hasActiveRun", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "main"; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Active run is waiting for steering.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - methodResponses: { - "sessions.list": chatSessionListResponse([ - { - hasActiveRun: true, - key: "agent:main:main", - kind: "direct", - label: "Main", - updatedAt: Date.now(), - }, - ]), - }, - sessionKey, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Active run is waiting for steering.").waitFor({ timeout: 10_000 }); - await gateway.waitForRequest("sessions.list"); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - await page - .locator(".agent-chat__composer-combobox textarea") - .fill("/steer use the smaller fix"); - await page.getByRole("button", { name: "Send message" }).click(); - - const steerRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(steerRequest.params); - expect(params.sessionKey).toBe(sessionKey); - expect(params.message).toBe("use the smaller fix"); - expect(params.deliver).toBe(false); - - await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 }); - expect(await page.getByText("No active run").count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("downloads an assistant document with the server-provided Unicode filename", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const source = "/tmp/openclaw/测试 report.pdf"; - const mediaUrl = `/__openclaw__/assistant-media?source=${encodeURIComponent(source)}&mediaTicket=ticket-download`; - const requestedUrls: URL[] = []; - // The document opens in a new tab, so intercept at the context boundary. - await context.route("**/__openclaw__/assistant-media?**", async (route) => { - const url = new URL(route.request().url()); - requestedUrls.push(url); - await route.fulfill({ - body: "%PDF-1.4\n", - contentType: "application/pdf", - headers: { - "Content-Disposition": `attachment; filename="__ report.pdf"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%20report.pdf`, - }, - }); - }); - await installMockGateway(page, { - historyMessages: [ - { - role: "assistant", - content: [ - { type: "text", text: "Your report is ready." }, - { - type: "attachment", - attachment: { - kind: "document", - label: "测试 report.pdf", - mimeType: "application/pdf", - url: mediaUrl, - }, - }, - ], - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const link = page.getByRole("link", { name: "测试 report.pdf" }); - await link.waitFor({ state: "visible", timeout: 10_000 }); - const [download] = await Promise.all([page.waitForEvent("download"), link.click()]); - - expect(download.suggestedFilename()).toBe("测试 report.pdf"); - expect(requestedUrls).toHaveLength(1); - expect(requestedUrls[0]?.searchParams.get("source")).toBe(source); - expect(requestedUrls[0]?.searchParams.get("mediaTicket")).toBe("ticket-download"); - } finally { - await closeBrowserContext(context); - } - }); - - it("renders a direct tool-result image from Gateway history", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const imageData = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X3q8AAAAAElFTkSuQmCC"; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [ - { alt: "Tool result preview", data: imageData, mimeType: "image/png", type: "image" }, - ], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const image = page.getByAltText("Tool result preview"); - await image.waitFor({ state: "visible", timeout: 10_000 }); - expect(await image.getAttribute("src")).toBe(`data:image/png;base64,${imageData}`); - await gateway.waitForRequest("chat.startup"); - await expect - .poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth)) - .toBe(1); - } finally { - await closeBrowserContext(context); - } - }); - - it("renders a canonical inbound image through the ticketed media route", async () => { - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const requestedMediaUrls: URL[] = []; - await page.route("**/__openclaw__/assistant-media?**", async (route) => { - const request = route.request(); - const url = new URL(request.url()); - requestedMediaUrls.push(url); - expect(url.searchParams.get("source")).toBe("media://inbound/telegram-photo.png"); - if (url.searchParams.get("meta") === "1") { - expect(request.headers().authorization).toBe("Bearer e2e-device-token"); - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - available: true, - mediaTicket: "ticket-inbound", - mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), - }), - }); - return; - } - expect(url.searchParams.get("mediaTicket")).toBe("ticket-inbound"); - await route.fulfill({ - contentType: "image/png", - body: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=", - "base64", - ), - }); - }); - await installMockGateway(page, { - historyMessages: [ - { - id: "user-inbound-media-ref", - role: "user", - content: [{ type: "text", text: "🖼️ Attached image" }], - __openclaw: { - media: [{ path: "media://inbound/telegram-photo.png", contentType: "image/png" }], - }, - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(2); - const image = page.locator("img.chat-message-image"); - await image.waitFor({ state: "visible", timeout: 10_000 }); - await expect - .poll(() => - image.evaluate((element) => - element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0, - ), - ) - .toBe(1); - if (artifactDir) { - await mkdir(artifactDir, { recursive: true }); - await page.screenshot({ - fullPage: true, - path: `${artifactDir}/canonical-inbound-image.png`, - }); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("evicts and refetches managed image Blob URLs after the cache reaches capacity", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const originalCreateObjectURL = URL.createObjectURL.bind(URL); - const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL); - const proof = { created: [] as string[], revoked: [] as string[] }; - Object.defineProperty(globalThis, "managedImageCacheProof", { - configurable: true, - value: proof, - }); - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: (blob: Blob) => { - const blobUrl = originalCreateObjectURL(blob); - proof.created.push(blobUrl); - return blobUrl; - }, - }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: (blobUrl: string) => { - proof.revoked.push(blobUrl); - originalRevokeObjectURL(blobUrl); - }, - }); - }); - - const imageUrls = Array.from({ length: 65 }, (_, index) => { - const id = String(index + 1).padStart(12, "0"); - return `/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-${id}/full`; - }); - const fetchedMedia: Array<{ - authorization: string | undefined; - pathname: string; - requesterSessionKey: string | undefined; - }> = []; - await page.route("**/api/chat/media/outgoing/**", async (route) => { - const request = route.request(); - const url = new URL(request.url()); - fetchedMedia.push({ - authorization: request.headers().authorization, - pathname: url.pathname, - requesterSessionKey: request.headers()["x-openclaw-requester-session-key"], - }); - await route.fulfill({ - body: 'managed preview', - contentType: "image/svg+xml", - }); - }); - - const historyFor = (indexes: number[], labelPrefix: string) => [ - { - content: indexes.map((index) => ({ - alt: `${labelPrefix} ${index + 1}`, - type: "image", - url: imageUrls[index], - })), - role: "assistant", - timestamp: Date.now(), - }, - ]; - const gateway = await installMockGateway(page, { - historyMessages: historyFor( - Array.from({ length: 64 }, (_, index) => index), - "Initial managed image", - ), - }); - const readBlobProof = () => - page.evaluate(() => { - const proof = ( - globalThis as typeof globalThis & { - managedImageCacheProof: { created: string[]; revoked: string[] }; - } - ).managedImageCacheProof; - return { created: [...proof.created], revoked: [...proof.revoked] }; - }); - let proofMessageSequence = 100; - const replaceHistory = async (messages: unknown[], visibleAlt: string) => { - const historyRequestsBefore = (await gateway.getRequests("chat.history")).length; - await gateway.setHistoryMessages(messages); - proofMessageSequence += 1; - await gateway.emitGatewayEvent("session.message", { - activeRunIds: [], - hasActiveRun: false, - message: messages[0], - messageId: `managed-image-cache-proof-${proofMessageSequence}`, - messageSeq: proofMessageSequence, - session: { - activeRunIds: [], - hasActiveRun: false, - key: "main", - kind: "direct", - status: "done", - updatedAt: Date.now(), - }, - sessionKey: "main", - }); - await expect - .poll(async () => (await gateway.getRequests("chat.history")).length, { - timeout: 15_000, - }) - .toBeGreaterThan(historyRequestsBefore); - await page.getByAltText(visibleAlt).waitFor({ state: "visible", timeout: 10_000 }); - }; - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.waitForRequest("chat.startup"); - await expect.poll(async () => (await readBlobProof()).created.length).toBe(64); - await expect - .poll(() => - page - .locator("img.chat-message-image") - .evaluateAll( - (images) => - images.filter( - (image) => - image instanceof HTMLImageElement && - image.complete && - image.naturalWidth === 160, - ).length, - ), - ) - .toBe(64); - - await replaceHistory( - historyFor([0], "Recently viewed managed image"), - "Recently viewed managed image 1", - ); - expect((await readBlobProof()).created).toHaveLength(64); - - await replaceHistory(historyFor([64], "Overflow managed image"), "Overflow managed image 65"); - await expect.poll(async () => (await readBlobProof()).created.length).toBe(65); - const overflowProof = await readBlobProof(); - const retainedRecentBlobUrl = expectDefined( - overflowProof.created[0], - "recent managed image Blob URL", - ); - const evictedBlobUrl = expectDefined( - overflowProof.created[1], - "evicted managed image Blob URL", - ); - expect(overflowProof.revoked).toContain(evictedBlobUrl); - expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl); - - const evictedPath = new URL( - expectDefined(imageUrls[1], "evicted managed image URL"), - server.baseUrl, - ).pathname; - const fetchesBeforeRevisit = fetchedMedia.filter( - (request) => request.pathname === evictedPath, - ).length; - await replaceHistory(historyFor([1], "Refetched managed image"), "Refetched managed image 2"); - const revisitedImage = page.getByAltText("Refetched managed image 2"); - await expect - .poll(() => - revisitedImage.evaluate((image) => - image instanceof HTMLImageElement && image.complete ? image.naturalWidth : 0, - ), - ) - .toBe(160); - await expect.poll(async () => (await readBlobProof()).created.length).toBe(66); - const finalProof = await readBlobProof(); - const evictedImageFetches = fetchedMedia.filter( - (request) => request.pathname === evictedPath, - ).length; - expect(evictedImageFetches).toBe(fetchesBeforeRevisit + 1); - expect(fetchedMedia).not.toHaveLength(0); - expect( - fetchedMedia.every((request) => request.authorization === "Bearer e2e-device-token"), - ).toBe(true); - expect( - fetchedMedia.every((request) => request.requesterSessionKey === "agent:main:main"), - ).toBe(true); - - const proofSummary = { - cacheCapacity: 64, - createdBlobUrls: finalProof.created.length, - evictedBlobIndex: 1, - evictedImageFetches, - refetchedImageNaturalWidth: await revisitedImage.evaluate( - (image) => (image as HTMLImageElement).naturalWidth, - ), - retainedRecentBlobRevoked: finalProof.revoked.includes(retainedRecentBlobUrl), - revokedBlobUrls: finalProof.revoked.length, - }; - if (captureUiProofEnabled) { - await mkdir(managedImageCacheProofDir, { recursive: true }); - await page.evaluate((summary) => { - const panel = document.createElement("pre"); - panel.setAttribute("data-managed-image-cache-proof", "true"); - panel.style.cssText = - "position:fixed;right:16px;bottom:16px;z-index:99999;max-width:460px;padding:16px;border:2px solid #5eead4;border-radius:10px;background:#0f172a;color:#ccfbf1;font:14px/1.45 monospace;white-space:pre-wrap"; - panel.textContent = `Managed image cache browser proof\n${JSON.stringify(summary, null, 2)}`; - document.body.append(panel); - }, proofSummary); - await page.screenshot({ - fullPage: true, - path: path.join(managedImageCacheProofDir, "after-refetch.png"), - }); - await writeFile( - path.join(managedImageCacheProofDir, "after-refetch.json"), - `${JSON.stringify(proofSummary, null, 2)}\n`, - "utf8", - ); - } - if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { - process.stdout.write( - `${JSON.stringify({ proof: "managed-image-cache", ...proofSummary })}\n`, - ); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("opens current context and latest-run usage from the composer ring", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - historyMessages: [ - { role: "user", content: "Show current usage", timestamp: Date.now() - 1_000 }, - { - role: "assistant", - content: "Usage ready.", - cost: { - input: 0.003456, - output: 0.018, - cacheRead: 0.0015, - cacheWrite: 0.0005, - total: 0.023456, - }, - model: "gpt-5.5", - provider: "openai", - timestamp: Date.now(), - }, - ], - methodResponses: { - "sessions.list": { - count: 1, - defaults: { - contextTokens: 200_000, - model: "gpt-5.5", - modelProvider: "openai", - }, - path: "", - sessions: [ - { - contextTokens: 200_000, - estimatedCostUsd: 0.023456, - inputTokens: 757_300, - key: "main", - kind: "direct", - model: "gpt-5.5", - modelProvider: "openai", - outputTokens: 42_300, - totalTokens: 46_000, - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const trigger = page.locator("summary.context-ring"); - await trigger.waitFor({ timeout: 10_000 }); - await trigger.click(); - - const popover = page.locator(".context-usage__popover"); - await expect.poll(() => popover.isVisible()).toBe(true); - await expect.poll(() => popover.textContent()).toContain("46k / 200k · 23%"); - await expect.poll(() => popover.textContent()).toContain("757.3k"); - await expect.poll(() => popover.textContent()).toContain("42.3k"); - await expect.poll(() => popover.textContent()).toContain("Est. cost"); - await expect.poll(() => popover.textContent()).toContain("$0.023"); - await expect.poll(() => popover.textContent()).toContain("Cost by Type"); - await expect.poll(() => popover.textContent()).toContain("$0.0035"); - await expect.poll(() => popover.textContent()).toContain("$0.018"); - await expect.poll(() => popover.textContent()).toContain("$0.0015"); - await expect.poll(() => popover.textContent()).toContain("$0.0005"); - await expect.poll(() => popover.textContent()).toContain("openai"); - await expect.poll(() => popover.textContent()).toContain("gpt-5.5"); - - await page.keyboard.press("Escape"); - await expect.poll(() => popover.isHidden()).toBe(true); - } finally { - await closeBrowserContext(context); - } - }); - - it("routes page typing to the active composer without stealing text input focus", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Type whenever you are ready.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Type whenever you are ready.").click(); - - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await expect - .poll(() => composer.evaluate((element) => element === document.activeElement)) - .toBe(false); - - await page.keyboard.type("first character preserved"); - expect(await composer.inputValue()).toBe("first character preserved"); - await expect - .poll(() => composer.evaluate((element) => element === document.activeElement)) - .toBe(true); - - await page.getByRole("button", { name: "Open command palette" }).click(); - const paletteInput = page.locator(".cmd-palette__input"); - await paletteInput.waitFor({ state: "visible", timeout: 10_000 }); - await expect - .poll(() => paletteInput.evaluate((element) => element === document.activeElement)) - .toBe(true); - await page.keyboard.type("session search"); - - expect(await paletteInput.inputValue()).toBe("session search"); - expect(await composer.inputValue()).toBe("first character preserved"); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps stale context visible as approximate without warning or compaction", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - methodResponses: { - "sessions.list": { - count: 1, - defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" }, - path: "", - sessions: [ - { - contextTokens: 200_000, - key: "main", - kind: "direct", - totalTokens: 190_000, - totalTokensFresh: false, - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const trigger = page.locator("summary.context-ring"); - await trigger.waitFor({ timeout: 10_000 }); - expect((await trigger.textContent())?.trim()).toBe("~95%"); - expect(await trigger.getAttribute("aria-label")).toBe( - "Thread context usage: ~190k of 200k (~95%)", - ); - expect( - await trigger.evaluate((element) => element.classList.contains("context-ring--warning")), - ).toBe(false); - - await trigger.click(); - await expect - .poll(() => page.locator(".context-usage__popover").textContent()) - .toContain("~190k / 200k · ~95%"); - expect(await page.locator(".context-ring__action").count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps a targetless message-tool source reply beside the automatic final reply", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - const prompt = "send progress through the message tool and then finish"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - expect(params).toMatchObject({ sessionKey: "main", message: prompt, deliver: false }); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - - await gateway.emitChatFinal({ - runId, - text: "Visible progress from the targetless message tool.", - }); - await page - .locator(".chat-thread-inner") - .getByText("Visible progress from the targetless message tool.") - .waitFor({ timeout: 10_000 }); - - await gateway.emitChatFinal({ runId, text: "Visible automatic final reply." }); - await page - .locator(".chat-thread-inner") - .getByText("Visible automatic final reply.") - .waitFor({ timeout: 10_000 }); - const bubbleTexts = await page.locator(".chat-thread .chat-bubble").allTextContents(); - for (const expectedText of [ - prompt, - "Visible progress from the targetless message tool.", - "Visible automatic final reply.", - ]) { - expect(bubbleTexts.some((text) => text.includes(expectedText))).toBe(true); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps the composer clear when a stale native input replay arrives after send", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [{ text: "Ready for stale replay check.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 }); - - const prompt = "submitted message"; - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - await gateway.waitForRequest("chat.send"); - expect(await composer.inputValue()).toBe(""); - - const afterReplay = await composer.evaluate((element, submitted) => { - const textarea = element as HTMLTextAreaElement; - textarea.value = submitted; - textarea.dispatchEvent( - new InputEvent("input", { - bubbles: true, - data: submitted, - inputType: "insertText", - }), - ); - return textarea.value; - }, prompt); - - expect(afterReplay).toBe(""); - expect(await composer.inputValue()).toBe(""); - - await composer.pressSequentially(prompt); - expect(await composer.inputValue()).toBe(prompt); - } finally { - await closeBrowserContext(context); - } - }); - - it("copies a code block over a non-secure context via the execCommand fallback", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - // Simulate a plain-HTTP deployment where navigator.clipboard is unavailable. - await installPlainHttpClipboardCapture(page); - const code = "const hello = 1;"; - const gateway = await installMockGateway(page, { - historyMessages: [ - { - content: [ - { - text: `${"long response line\n\n".repeat(80)}\`\`\`js\n${code}\n\`\`\``, - type: "text", - }, - ], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const copyButton = page.locator(".code-block-copy").first(); - await copyButton.waitFor({ timeout: 10_000 }); - await waitForChatScrollIdle(page); - await copyButton.evaluate((element) => element.scrollIntoView({ block: "center" })); - const thread = page.locator(".chat-thread"); - const scrollTopBefore = await thread.evaluate((element) => - Math.round((element as HTMLElement).scrollTop), - ); - // The copied class clears after 1500ms, so click and read it in one browser step. - const copied = await copyButton.evaluate(async (element) => { - const button = element as HTMLButtonElement; - const owner = element.closest("openclaw-chat-pane") as - | (HTMLElement & { - updateComplete: Promise; - }) - | null; - if (!owner) { - throw new Error("Chat pane owner is unavailable"); - } - let copyObserver: MutationObserver | undefined; - const copySettled = new Promise((resolve) => { - copyObserver = new MutationObserver(() => { - if (button.classList.contains("copied")) { - copyObserver?.disconnect(); - resolve(); - } - }); - copyObserver.observe(button, { attributeFilter: ["class"], attributes: true }); - }); - button.click(); - await owner.updateComplete; - if (!button.classList.contains("copied")) { - await copySettled; - } - copyObserver?.disconnect(); - return button.classList.contains("copied"); - }); - expect(copied).toBe(true); - expect(await copiedViaExec(page)).toContain(code); - await expect - .poll(() => thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop))) - .toBe(scrollTopBefore); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("copies a workspace file path over a non-secure context via the execCommand fallback", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await installPlainHttpClipboardCapture(page); - const gateway = await installMockGateway(page, { - methodResponses: { - "artifacts.list": { artifacts: [] }, - "sessions.files.list": { - browser: { entries: [], path: "" }, - files: [ - { - kind: "modified", - missing: false, - name: "AGENTS.md", - path: "/workspace/AGENTS.md", - size: 2048, - }, - ], - root: "/workspace", - sessionKey: "main", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.locator(".chat-workspace-toggle").click(); - await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ - timeout: 10_000, - }); - - await page.getByRole("button", { name: "Copy path" }).click(); - - expect(await copiedViaExec(page)).toContain("/workspace/AGENTS.md"); - expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("starts the workspace files panel collapsed and toggles it open", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "artifacts.list": { - artifacts: [ - { - download: { mode: "bytes" }, - id: "artifact-1", - mimeType: "image/png", - sizeBytes: 128, - title: "preview.png", - type: "image", - }, - ], - }, - "sessions.files.list": { - browser: { - entries: [ - { - kind: "directory", - name: "src", - path: "src", - sessionKind: "modified", - }, - { - kind: "file", - name: "package.json", - path: "package.json", - size: 4096, - }, - ], - path: "", - }, - files: [ - { - kind: "modified", - missing: false, - name: "AGENTS.md", - path: "/workspace/AGENTS.md", - size: 2048, - }, - ], - root: "/workspace", - sessionKey: "main", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - // Collapsed rails render nothing; the title-bar toggle carries the - // changed-file badge. - const opener = page.locator(".chat-workspace-toggle"); - await opener.waitFor({ timeout: 10_000 }); - expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0); - expect(await page.locator(".chat-workspace-rail").count()).toBe(0); - - await opener.click(); - await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 }); - await expect.poll(() => opener.getAttribute("aria-expanded")).toBe("true"); - await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ - timeout: 10_000, - }); - await page - .locator(".chat-workspace-rail__file-name", { hasText: "preview.png" }) - .waitFor({ timeout: 10_000 }); - await page.getByText("Project files").waitFor({ timeout: 10_000 }); - await page.locator(".chat-workspace-rail__file-name", { hasText: "package.json" }).waitFor({ - timeout: 10_000, - }); - expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); - expect(await gateway.getRequests("artifacts.list")).toHaveLength(1); - // The rail docks flush to the window edge (no content gutter). - expect( - await page.locator(".chat-workspace-rail").evaluate((element) => { - return window.innerWidth - element.getBoundingClientRect().right; - }), - ).toBe(0); - - await page.locator(".chat-workspace-rail__collapse-toggle").click(); - await opener.waitFor({ timeout: 10_000 }); - expect(await page.locator(".chat-workspace-rail").count()).toBe(0); - - await opener.click(); - await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 }); - await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ - timeout: 10_000, - }); - expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); - - await page.setViewportSize({ height: 900, width: 760 }); - const workbench = page.locator(".chat-workbench"); - await expect - .poll(() => workbench.getAttribute("class")) - .toContain("chat-workbench--dock-bottom"); - const workspaceRail = page.locator(".chat-workspace-rail"); - await expect - .poll(async () => { - const box = await workspaceRail.boundingBox(); - return Boolean(box && box.width > 0 && box.height > 0); - }) - .toBe(true); - expect(await page.locator(".chat-workspace-rail__dock").count()).toBe(0); - expect(await page.locator(".chat-workspace-rail__grip").count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps long workspace file sections scrollable inside the rail", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 720, width: 1280 }, - }); - const page = await context.newPage(); - const browserEntries = Array.from({ length: 60 }, (_, index) => ({ - kind: "file" as const, - name: `file-${String(index + 1).padStart(2, "0")}.ts`, - path: `src/file-${String(index + 1).padStart(2, "0")}.ts`, - size: 2048 + index, - })); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.files.list": { - browser: { - entries: browserEntries, - path: "", - }, - files: [], - root: "/workspace", - sessionKey: "main", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.locator(".chat-workspace-toggle").click(); - await page.locator(".chat-workspace-rail__file-name", { hasText: "file-60.ts" }).waitFor({ - timeout: 10_000, - }); - expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); - - const browserSection = page.locator(".chat-workspace-rail__section", { - hasText: "Project files", - }); - await expect - .poll( - () => - browserSection.evaluate((section) => { - const element = section as HTMLElement; - const scroll = element.closest(".chat-workspace-rail__scroll") as HTMLElement | null; - if (!scroll) { - throw new Error("Expected workspace rail scroll container"); - } - const sectionRect = element.getBoundingClientRect(); - const scrollRect = scroll.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - bottomWithinRail: Math.ceil(sectionRect.bottom) <= Math.ceil(scrollRect.bottom), - clientHeight: element.clientHeight, - overflowY: style.overflowY, - scrollHeight: element.scrollHeight, - }; - }), - { timeout: 10_000 }, - ) - .toMatchObject({ - bottomWithinRail: true, - overflowY: "auto", - }); - const sectionMetrics = await browserSection.evaluate((section) => { - const element = section as HTMLElement; - return { - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - }; - }); - expect(sectionMetrics.scrollHeight).toBeGreaterThan(sectionMetrics.clientHeight); - } finally { - await closeBrowserContext(context); - } - }); - - it("renders stable markdown during a streaming chat turn and finalizes the tail", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - const prompt = "stream markdown through the GUI"; - await gateway.deferNext("chat.send"); - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - const streamingText = "## Streaming heading\n\nworking **tail"; - await gateway.emitGatewayEvent("chat", { - deltaText: streamingText, - message: { - content: [{ text: streamingText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - - await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({ - timeout: 10_000, - }); - await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({ - timeout: 10_000, - }); - expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); - - await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({ - timeout: 10_000, - }); - - await gateway.emitChatFinal({ - runId, - text: "## Streaming heading\n\nworking **tail**", - }); - - await page.locator(".chat-thread strong").getByText("tail").waitFor({ timeout: 10_000 }); - expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("normalizes Unicode line separators in streaming and final chat DOM", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - await gateway.deferNext("chat.send"); - await page - .locator(".agent-chat__composer-combobox textarea") - .fill("render Unicode separators"); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - const streamingText = "## Unicode stream\u2028\u2028working **tail"; - await gateway.emitGatewayEvent("chat", { - deltaText: streamingText, - message: { - content: [{ text: streamingText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - - await page.locator(".chat-thread h2").getByText("Unicode stream").waitFor({ - timeout: 10_000, - }); - await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({ - timeout: 10_000, - }); - expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); - - await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - await gateway.emitChatFinal({ - runId, - text: "## Unicode final\u2028\u2028- first\u2029- second", - }); - - await page.locator(".chat-thread h2").getByText("Unicode final").waitFor({ - timeout: 10_000, - }); - await expect - .poll(() => page.locator(".chat-thread li").allTextContents(), { timeout: 10_000 }) - .toEqual(["first", "second"]); - const finalChatText = await page.locator(".chat-thread .chat-text").last().textContent(); - expect(finalChatText).not.toContain("\u2028"); - expect(finalChatText).not.toContain("\u2029"); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps chat usable while sessions are still loading", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.list"], - historyMessages: [ - { - content: [{ text: "History renders before sessions finish.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - await page.getByText("History renders before sessions finish.").waitFor({ timeout: 10_000 }); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - - // The chat boot hydrates the sidebar session list; that request stays - // deferred here while the composer must remain fully usable. - await gateway.waitForRequest("sessions.list"); - - await composer.fill("draft while sessions load"); - expect(await composer.inputValue()).toBe("draft while sessions load"); - await composer.fill(""); - - // The background hydrate must not take the shared sessions loading - // flag, which would disable New thread for the whole request. - const newThread = page.getByRole("button", { name: "New thread" }).first(); - expect(await newThread.isEnabled()).toBe(true); - - await gateway.resolveDeferred("sessions.list"); - await expect.poll(() => newThread.isEnabled()).toBe(true); - } finally { - await closeBrowserContext(context); - } - }); - - it("opens a git-backed agent draft from the sidebar new-session action", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { workspaceGit: true }); - - try { - await page.goto(`${server.baseUrl}chat`); - const newSessionButton = page.locator("openclaw-app-sidebar .sidebar-brand__new-thread"); - await newSessionButton.waitFor({ state: "visible", timeout: 10_000 }); - await newSessionButton.click(); - - await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); - await expect.poll(() => new URL(page.url()).searchParams.get("agent")).toBe("main"); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("starts a model-suggested follow-up in a fresh worktree session", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const suggestion = { - id: "task_123", - title: "Remove stale adapter", - prompt: "Delete the stale adapter in src/example.ts and update tests.", - tldr: "The adapter is unreachable and adds maintenance cost.", - cwd: "/projects/example", - sessionKey: "main", - agentId: "main", - createdAt: Date.now(), - }; - const gateway = await installMockGateway(page, { - deferredMethods: ["taskSuggestions.list"], - featureMethods: [ - "chat.metadata", - "chat.startup", - "taskSuggestions.list", - "taskSuggestions.accept", - ], - methodResponses: { - "taskSuggestions.list": { suggestions: [suggestion] }, - "taskSuggestions.accept": { - taskId: "task_123", - key: "agent:main:dashboard:suggested", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.waitForRequest("taskSuggestions.list"); - await gateway.emitGatewayEvent("task.suggestion", { - action: "created", - suggestion, - }); - await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); - - const startButton = page.getByRole("button", { name: "Start in worktree" }); - await startButton.waitFor({ state: "visible", timeout: 10_000 }); - await page - .getByText("/projects/example", { exact: true }) - .waitFor({ state: "visible", timeout: 10_000 }); - await page - .getByText("Delete the stale adapter in src/example.ts and update tests.", { - exact: true, - }) - .waitFor({ state: "visible", timeout: 10_000 }); - await startButton.click(); - - const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept"); - expect(acceptRequest.params).toEqual({ taskId: "task_123" }); - } finally { - await closeBrowserContext(context); - } - }); - - it("clears model-suggested follow-ups while switching sessions", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], - methodResponses: { - "sessions.list": chatSessionListResponse(), - "taskSuggestions.list": { - suggestions: [ - { - id: "task_session_a", - title: "Follow up from session A", - prompt: "Complete the follow-up discovered in session A.", - tldr: "This suggestion belongs only to session A.", - cwd: "/projects/example", - sessionKey: "agent:main:session-a", - agentId: "main", - createdAt: Date.now(), - }, - ], - }, - }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const startButton = page.getByRole("button", { name: "Start in worktree" }); - await startButton.waitFor({ state: "visible", timeout: 10_000 }); - await gateway.deferNext("taskSuggestions.list"); - await page - .locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', - ) - .click(); - await waitForRequests(gateway, "taskSuggestions.list", 2); - - await expect.poll(() => startButton.count()).toBe(0); - await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps the composer visible when follow-up suggestions overflow", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 720, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], - methodResponses: { - "taskSuggestions.list": { - suggestions: Array.from({ length: 12 }, (_, index) => ({ - id: `task_overflow_${index}`, - title: `Follow-up ${index}`, - prompt: "Inspect the related implementation and tests. ".repeat(12), - tldr: "This follow-up remains useful but must not hide the composer.", - cwd: "/projects/example", - sessionKey: "main", - agentId: "main", - createdAt: Date.now() + index, - })), - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const tray = page.locator(".task-suggestions"); - await tray.waitFor({ state: "visible", timeout: 10_000 }); - expect(await tray.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe( - true, - ); - - const composer = page.locator(".agent-chat__composer-shell"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - const box = await composer.boundingBox(); - expect(box).not.toBeNull(); - expect((box?.y ?? 720) + (box?.height ?? 0)).toBeLessThanOrEqual(720); - } finally { - await closeBrowserContext(context); - } - }); - - it("sends the first chat turn while agents startup loading is still pending", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - defaultAgentId: "ops", - deferredMethods: ["chat.startup"], - historyMessages: [], - sessionKey: "global", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.waitForRequest("chat.startup"); - expect(await gateway.getRequests("agents.list")).toHaveLength(0); - // chat.startup owns the initial metadata load; the old parallel - // chat.metadata request was only a synchronization point for this test. - expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); - expect(await gateway.getRequests("commands.list")).toHaveLength(0); - expect(await gateway.getRequests("models.list")).toHaveLength(0); - - const prompt = "send before agents list completes"; - await page - .locator(".agent-chat__composer-combobox textarea") - .waitFor({ state: "visible", timeout: 10_000 }); - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - await expect - .poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), { - timeout: 10_000, - }) - .toBe(""); - const params = requireRecord(sendRequest.params); - expect(params.message).toBe(prompt); - expect(params.sessionKey).toBe("global"); - expect(params.agentId).toBe("ops"); - - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - await gateway.emitGatewayEvent("chat", { - deltaText: "First token visible.", - message: { - content: [{ text: "First token visible.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - agentId: "ops", - sessionKey: "global", - state: "delta", - }); - await page.getByText("First token visible.").waitFor({ timeout: 10_000 }); - await gateway.resolveDeferred("chat.startup", { - agentsList: { - agents: [{ id: "ops", name: "OpenClaw" }], - defaultId: "ops", - mainKey: "main", - scope: "agent", - }, - messages: [], - metadata: { - commands: [ - { - acceptsArgs: false, - description: "Loaded after startup completes", - name: "startup-ready", - scope: "text", - source: "native", - }, - ], - models: [ - { - available: true, - id: "startup-model", - name: "Startup Model", - provider: "openai", - }, - ], - }, - sessionId: "control-ui-e2e-session", - thinkingLevel: null, - }); - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - await page.getByText("First token visible.").waitFor({ timeout: 10_000 }); - await expect - .poll(() => page.locator('[data-chat-model-option="openai/startup-model"]').count()) - .toBe(1); - await gateway.emitChatFinal({ runId, text: "History race stayed visible." }); - await page - .locator(".chat-thread-inner") - .getByText("History race stayed visible.") - .waitFor({ timeout: 10_000 }); - await page.locator(".agent-chat__composer-combobox textarea").fill("/"); - await page.getByRole("option", { name: /\/startup-ready/ }).waitFor({ timeout: 10_000 }); - // Check after both controls render so no late fallback RPC supplied either catalog. - expect({ - commands: (await gateway.getRequests("commands.list")).length, - metadata: (await gateway.getRequests("chat.metadata")).length, - models: (await gateway.getRequests("models.list")).length, - }).toEqual({ commands: 0, metadata: 0, models: 0 }); - expect(await gateway.getRequests("agents.list")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it.each([ - { label: "desktop", viewport: { height: 900, width: 1280 } }, - { label: "mobile", viewport: { height: 844, width: 390 } }, - ])( - "keeps streamed text visible when a chat error terminates the turn on $label", - async ({ viewport }) => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - const prompt = "stream before terminal error"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - const partialText = "Partial answer before gateway error."; - await gateway.emitGatewayEvent("chat", { - deltaText: partialText, - message: { - content: [{ text: partialText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - await page - .locator(".chat-thread-inner") - .getByText(partialText) - .waitFor({ timeout: 10_000 }); - - const gatewayErrorText = - "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again."; - const errorText = gatewayErrorText.replace(/^⚠️\s*/u, ""); - await gateway.emitGatewayEvent("chat", { - errorMessage: gatewayErrorText, - message: { - content: [{ text: gatewayErrorText, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "error", - }); - - await page - .locator(".chat-thread-inner") - .getByText(partialText) - .waitFor({ timeout: 10_000 }); - const alert = page.locator(".chat-run-error"); - await alert.getByText(errorText).waitFor({ timeout: 10_000 }); - expect(await alert.locator("button").count()).toBe(0); - expect(await page.locator(".chat-thread-inner").getByText(errorText).count()).toBe(0); - expect( - await alert.evaluate((element) => - element.nextElementSibling?.classList.contains("agent-chat__composer-shell"), - ), - ).toBe(true); - const [alertBox, composerBox] = await Promise.all([ - alert.boundingBox(), - page.locator(".agent-chat__composer-shell").boundingBox(), - ]); - expect(alertBox).not.toBeNull(); - expect(composerBox).not.toBeNull(); - expect(Math.abs((alertBox?.x ?? 0) - (composerBox?.x ?? 0))).toBeLessThan(1); - expect(Math.abs((alertBox?.width ?? 0) - (composerBox?.width ?? 0))).toBeLessThan(1); - - await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error"); - await page.getByRole("button", { name: "Send message" }).click(); - await waitForRequests(gateway, "chat.send", 2); - await alert.waitFor({ state: "detached", timeout: 10_000 }); - } finally { - await closeBrowserContext(context); - } - }, - ); - - it("keeps the pending working row stable through acknowledgement and streaming", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.deferNext("chat.send"); - - const prompt = "hold this until the ack arrives"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - await expect - .poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), { - timeout: 10_000, - }) - .toBe(""); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - const indicator = page.locator(".chat-reading-indicator"); - await indicator.waitFor({ timeout: 10_000 }); - expect(await page.locator(".chat-queue").count()).toBe(0); - await page.locator(".chat-working-indicator").evaluate(async (element) => { - await Promise.all(element.getAnimations().map((animation) => animation.finished)); - }); - const pendingRow = await indicator - .locator( - "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' chat-virtual-row ')][1]", - ) - .elementHandle(); - if (!pendingRow) { - throw new Error("expected pending working indicator virtual row"); - } - const pendingLayout = await pendingRow.evaluate((row) => { - const rect = row.getBoundingClientRect(); - Reflect.set(window, "__openclawPendingWorkingRow", row); - return { - height: rect.height, - key: row.getAttribute("data-virtual-row-key"), - top: rect.top, - }; - }); - expect(pendingLayout.key).not.toBeNull(); - await page.evaluate(() => { - const samples: Array<{ - height: number | null; - key: string | null; - sameRow: boolean; - top: number | null; - }> = []; - Reflect.set(window, "__openclawWorkingRowSamples", samples); - let remaining = 20; - const sample = () => { - const originalRow = Reflect.get(window, "__openclawPendingWorkingRow"); - const currentRow = document - .querySelector(".chat-reading-indicator") - ?.closest(".chat-virtual-row"); - const rect = currentRow?.getBoundingClientRect(); - samples.push({ - height: rect?.height ?? null, - key: currentRow?.getAttribute("data-virtual-row-key") ?? null, - sameRow: currentRow === originalRow, - top: rect?.top ?? null, - }); - remaining -= 1; - if (remaining > 0) { - requestAnimationFrame(sample); - } - }; - sample(); - }); - - await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - await indicator.waitFor({ timeout: 10_000 }); - const samples = await page.evaluate( - () => - new Promise< - Array<{ - height: number | null; - key: string | null; - sameRow: boolean; - top: number | null; - }> - >((resolve) => { - const read = () => { - const current = Reflect.get(window, "__openclawWorkingRowSamples"); - if (Array.isArray(current) && current.length >= 20) { - resolve(current); - return; - } - requestAnimationFrame(read); - }; - read(); - }), - ); - const layouts = samples.filter( - (sample): sample is { height: number; key: string; sameRow: true; top: number } => - sample.sameRow && - typeof sample.height === "number" && - typeof sample.key === "string" && - typeof sample.top === "number", - ); - expect(layouts).toHaveLength(20); - expect(new Set(layouts.map((sample) => sample.key))).toEqual(new Set([pendingLayout.key])); - const tops = layouts.map((sample) => sample.top); - const heights = layouts.map((sample) => sample.height); - expect(Math.max(...tops) - Math.min(...tops)).toBeLessThan(1); - expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(1); - - const response = "The streamed response is now visible."; - await gateway.emitGatewayEvent("chat", { - deltaText: response, - message: { - content: [{ text: response, type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - - await page.getByText(response).waitFor({ timeout: 10_000 }); - await page.locator(".chat-reading-indicator").waitFor({ state: "detached", timeout: 10_000 }); - const streamingLayout = await pendingRow.evaluate( - (row, visibleResponse) => ({ - connected: row.isConnected, - hasResponse: row.textContent?.includes(visibleResponse) ?? false, - key: row.getAttribute("data-virtual-row-key"), - }), - response, - ); - expect(streamingLayout).toEqual({ - connected: true, - hasResponse: true, - key: pendingLayout.key, - }); - } finally { - await closeBrowserContext(context); - } - }); - - it("steers ordinary follow-ups when the server default is steer", async () => { - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - ...(artifactDir - ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } - : {}), - }); - const page = await context.newPage(); - const runtimeConfig = { - messages: { queue: { byChannel: { webchat: "steer" }, mode: "followup" } }, - }; - const gateway = await installMockGateway(page, { - methodResponses: { - "config.get": { - config: runtimeConfig, - hash: "queue-steer-config", - issues: [], - raw: JSON.stringify(runtimeConfig), - runtimeConfig, - valid: true, - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); - await page.getByRole("button", { name: "Send message" }).click(); - await gateway.waitForRequest("chat.send"); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const followUp = "tighten the active plan"; - await page.locator(".agent-chat__composer-combobox textarea").fill(followUp); - await page.getByRole("button", { name: "Steer into the active run" }).click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - expect(requireRecord(sends[1]?.params)).toMatchObject({ - deliver: false, - message: followUp, - sessionKey: "main", - }); - const queue = page.locator(".chat-queue"); - await queue.getByText("Steered").waitFor({ timeout: 10_000 }); - await queue.getByText(followUp).waitFor({ timeout: 10_000 }); - if (artifactDir) { - await page.screenshot({ - path: `${artifactDir}/steer-default.png`, - fullPage: true, - }); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("preserves a non-steer server default for active-run follow-ups", async () => { - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const runtimeConfig = { - messages: { queue: { byChannel: { webchat: "followup" }, mode: "steer" } }, - }; - const gateway = await installMockGateway(page, { - methodResponses: { - "config.get": { - config: runtimeConfig, - hash: "queue-followup-config", - issues: [], - raw: JSON.stringify(runtimeConfig), - runtimeConfig, - valid: true, - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}settings/appearance`); - const followUpSelect = page.locator("[data-settings-follow-up-mode]"); - await followUpSelect.waitFor({ state: "visible", timeout: 10_000 }); - expect(await followUpSelect.inputValue()).toBe("server"); - await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 }); - if (artifactDir) { - await page.screenshot({ - path: `${artifactDir}/server-followup-setting.png`, - fullPage: true, - }); - } - const configPatchCount = (await gateway.getRequests("config.patch")).length; - const configGetCount = (await gateway.getRequests("config.get")).length; - const overrideConfig = { - ...runtimeConfig, - ui: { prefs: { chatFollowUpMode: "steer" } }, - }; - await gateway.setMethodResponse("config.get", { - config: overrideConfig, - hash: "queue-followup-override-config", - issues: [], - raw: JSON.stringify(overrideConfig), - runtimeConfig: overrideConfig, - valid: true, - }); - await followUpSelect.selectOption("steer"); - await waitForRequests(gateway, "config.patch", configPatchCount + 1); - await waitForRequests(gateway, "config.get", configGetCount + 1); - await page.getByText("Overriding server default (followup)").waitFor({ timeout: 10_000 }); - await gateway.setMethodResponse("config.get", { - config: runtimeConfig, - hash: "queue-followup-reset-config", - issues: [], - raw: JSON.stringify(runtimeConfig), - runtimeConfig, - valid: true, - }); - if (artifactDir) { - await page.screenshot({ - path: `${artifactDir}/server-followup-override.png`, - fullPage: true, - }); - } - await page.getByRole("button", { name: "Reset to server default" }).click(); - await waitForRequests(gateway, "config.patch", configPatchCount + 2); - await waitForRequests(gateway, "config.get", configGetCount + 2); - await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 }); - expect(await followUpSelect.inputValue()).toBe("server"); - - await page.goto(`${server.baseUrl}chat`); - - const activePrompt = "keep this run active"; - await page.locator(".agent-chat__composer-combobox textarea").fill(activePrompt); - await page.getByRole("button", { name: "Send message" }).click(); - - await gateway.waitForRequest("chat.send"); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const queuedPrompt = "queue this on the server"; - await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt); - await page.getByRole("button", { name: "Queue message" }).click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - expect(requireRecord(sends[1]?.params)).toMatchObject({ - message: queuedPrompt, - queueMode: "followup", - sessionKey: "main", - }); - await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 }); - } finally { - await closeBrowserContext(context); - } - }); - - it("honors a session interrupt override ahead of the webchat config default", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "main"; - const runtimeConfig = { - messages: { queue: { byChannel: { webchat: "steer" }, mode: "steer" } }, - }; - const gateway = await installMockGateway(page, { - methodResponses: { - "config.get": { - config: runtimeConfig, - hash: "queue-session-override-config", - issues: [], - raw: JSON.stringify(runtimeConfig), - runtimeConfig, - valid: true, - }, - "sessions.list": chatSessionListResponse([ - { - effectiveQueueMode: "interrupt", - key: "agent:main:main", - kind: "direct", - label: "Main", - queueMode: "interrupt", - updatedAt: Date.now(), - }, - ]), - }, - sessionInfo: { - effectiveQueueMode: "interrupt", - hasActiveRun: false, - key: "agent:main:main", - queueMode: "interrupt", - status: "done", - }, - sessionKey, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); - await page.getByRole("button", { name: "Send message" }).click(); - await gateway.waitForRequest("chat.send"); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const followUp = "interrupt for this session override"; - await page.locator(".agent-chat__composer-combobox textarea").fill(followUp); - await page.getByRole("button", { name: "Send message" }).click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - expect(requireRecord(sends[1]?.params)).toMatchObject({ - message: followUp, - queueMode: "interrupt", - sessionKey, - }); - } finally { - await closeBrowserContext(context); - } - }); - - it("steers a restored queued message when only the session row reports the active run", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}settings/appearance`); - await page.locator("[data-settings-follow-up-mode]").selectOption("queue"); - await page.goto(`${server.baseUrl}chat`); - - await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); - await page.getByRole("button", { name: "Send message" }).click(); - await gateway.waitForRequest("chat.send"); - await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); - - const queuedPrompt = "steer this after restoring the queue"; - await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt); - await page.getByRole("button", { name: "Queue message" }).click(); - await page.locator(".chat-queue").getByText(queuedPrompt).waitFor({ timeout: 10_000 }); - - await gateway.setMethodResponse( - "sessions.list", - chatSessionListResponse([ - { - hasActiveRun: true, - key: "main", - kind: "direct", - label: "Main", - updatedAt: Date.now(), - }, - ]), - ); - await page.reload(); - - const queue = page.locator(".chat-queue"); - await queue.getByText(queuedPrompt).waitFor({ timeout: 10_000 }); - await queue.getByRole("button", { name: "Steer" }).click(); - - const steerRequest = await gateway.waitForRequest("chat.send"); - const steerParams = requireRecord(steerRequest.params); - expect(steerParams).toMatchObject({ - deliver: false, - message: queuedPrompt, - sessionKey: "main", - }); - await queue.getByText("Steered").waitFor({ timeout: 10_000 }); - await gateway.emitChatFinal({ - runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"), - text: "Restored steer completed.", - }); - await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 }); - } finally { - await closeBrowserContext(context); - } - }); - - it("scrolls a delayed pending send into view before the ACK resolves", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const baseTs = Date.now() - 100_000; - const historyMessages = Array.from({ length: 50 }, (_, index) => ({ - content: [ - { - text: `History message ${index}\n${"extra transcript line\n".repeat(4)}`, - type: "text", - }, - ], - role: index % 2 === 0 ? "assistant" : "user", - timestamp: baseTs + index, - })); - const gateway = await installMockGateway(page, { historyMessages }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("History message 49").waitFor({ timeout: 10_000 }); - await expect - .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) - .toBeLessThanOrEqual(4); - - await waitForChatScrollIdle(page); - await expect - .poll( - async () => { - await scrollChatThreadToTop(page); - return chatThreadDistanceFromBottom(page); - }, - { timeout: 10_000 }, - ) - .toBeGreaterThan(200); - - await gateway.deferNext("chat.send"); - - const prompt = `pending send should scroll before ack\n${"visible now\n".repeat(6)}`; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - - await page.locator(".chat-thread").getByText("pending send should scroll").waitFor({ - timeout: 10_000, - }); - await expect - .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) - .toBeLessThanOrEqual(4); - - await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - } finally { - await closeBrowserContext(context); - } - }); - - it("overlays the scroll-to-bottom affordance without shrinking the transcript", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const baseTs = Date.now() - 100_000; - const historyMessages = Array.from({ length: 50 }, (_, index) => ({ - content: [ - { - text: `Scrollable history ${index}\n${"extra transcript line\n".repeat(4)}`, - type: "text", - }, - ], - role: index % 2 === 0 ? "assistant" : "user", - timestamp: baseTs + index, - })); - await installMockGateway(page, { historyMessages }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Scrollable history 49").waitFor({ timeout: 10_000 }); - await waitForChatScrollIdle(page); - - const readLayout = () => - page.locator(".chat-main").evaluate((container) => { - const thread = container.querySelector(".chat-thread"); - const composer = container.querySelector(".agent-chat__composer-shell"); - const button = container.querySelector(".chat-scroll-to-bottom"); - if (!thread || !composer) { - throw new Error("expected chat thread and composer"); - } - const threadRect = thread.getBoundingClientRect(); - const composerRect = composer.getBoundingClientRect(); - const buttonRect = button?.getBoundingClientRect(); - return { - buttonBottom: buttonRect ? Math.round(buttonRect.bottom) : null, - composerTop: Math.round(composerRect.top), - threadBottom: Math.round(threadRect.bottom), - }; - }); - - const before = await readLayout(); - expect(before.buttonBottom).toBeNull(); - - await scrollChatThreadToTop(page); - await page.getByRole("button", { name: "Scroll to latest" }).waitFor({ timeout: 10_000 }); - const after = await readLayout(); - - expect(after.threadBottom).toBe(before.threadBottom); - expect(after.composerTop).toBe(before.composerTop); - expect(after.buttonBottom).not.toBeNull(); - expect(after.buttonBottom!).toBeLessThan(after.composerTop); - } finally { - await closeBrowserContext(context); - } - }); - - it("shows persisted user messages after opening History and scrolling mixed history", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const baseTs = Date.now() - 100_000; - const currentSessionMessages = [ - { - content: [{ text: "Current session placeholder", type: "text" }], - role: "assistant", - timestamp: baseTs - 1, - }, - ]; - const historyMessages = Array.from({ length: 70 }, (_, index) => ({ - content: [ - { - text: `${index % 2 === 0 ? "User history question" : "Assistant history answer"} ${index}\n${"history detail line\n".repeat(4)}`, - type: index % 2 === 0 ? "input_text" : "output_text", - }, - ], - role: index % 2 === 0 ? "user" : "assistant", - timestamp: baseTs + index, - })); - const gateway = await installMockGateway(page, { - historyMessages: currentSessionMessages, - methodResponses: { - "chat.startup": { - cases: [ - { - match: { sessionKey: "agent:main:session-b" }, - response: { - messages: historyMessages, - sessionId: "control-ui-e2e-history-session-b", - thinkingLevel: null, - }, - }, - { - match: { sessionKey: "agent:main:session-a" }, - response: { - messages: currentSessionMessages, - sessionId: "control-ui-e2e-history-session-a", - thinkingLevel: null, - }, - }, - ], - }, - "sessions.list": chatSessionListResponse(), - }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText("Current session placeholder").waitFor({ timeout: 10_000 }); - - const startupCountBeforeSwitch = (await gateway.getRequests("chat.startup")).length; - await page - .locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', - ) - .click(); - const startupRequests = await waitForRequests( - gateway, - "chat.startup", - startupCountBeforeSwitch + 1, - ); - const historyRequest = expectDefined(startupRequests.at(-1), "session B startup request"); - expect(requireRecord(historyRequest.params)).toMatchObject({ - sessionKey: "agent:main:session-b", - }); - await page.locator(".chat-thread").getByText("User history question 68").waitFor({ - timeout: 10_000, - }); - await page.locator(".chat-thread").getByText("Assistant history answer 69").waitFor({ - timeout: 10_000, - }); - await expect - .poll( - async () => { - const texts = await visibleChatBubbleTexts(page); - return ( - texts.some((text) => text.includes("User history question 68")) && - texts.some((text) => text.includes("Assistant history answer 69")) - ); - }, - { timeout: 10_000 }, - ) - .toBe(true); - - await waitForChatScrollIdle(page); - await scrollChatThreadToTop(page); - await page.locator(".chat-thread").getByText("User history question 10").waitFor({ - timeout: 10_000, - }); - await scrollChatThreadToTop(page); - await page.locator(".chat-thread").getByText("User history question 0").waitFor({ - timeout: 10_000, - }); - await scrollChatThreadToTop(page); - await expect - .poll( - async () => { - const texts = await visibleChatBubbleTexts(page); - return ( - texts.some((text) => text.includes("User history question 0")) && - texts.some((text) => text.includes("Assistant history answer 1")) - ); - }, - { timeout: 10_000 }, - ) - .toBe(true); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps retained paginated history stable when returning to a session", async () => { - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - const context = await newBrowserContext({ - locale: "en-US", - ...(artifactDir - ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } - : {}), - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const historyMessage = (seq: number, label: string) => ({ - __openclaw: { id: `history-${seq}`, seq }, - content: [ - { - text: `${label} ${seq}\n${"retained transcript detail\n".repeat(3)}`, - type: seq % 2 === 0 ? "output_text" : "input_text", - }, - ], - role: seq % 2 === 0 ? "assistant" : "user", - timestamp: 1_800_000_000_000 + seq, - }); - const shortMessages = [historyMessage(1, "short session"), historyMessage(2, "short session")]; - const recentMessages = Array.from({ length: 100 }, (_, index) => - historyMessage(index + 41, "recent retained message"), - ); - const olderMessages = Array.from({ length: 40 }, (_, index) => - historyMessage(index + 1, "older retained message"), - ); - const gateway = await installMockGateway(page, { - historyMessages: shortMessages, - methodResponses: { - "chat.history": { - cases: [ - { - match: { offset: 100, sessionKey: "agent:main:session-b" }, - response: { - hasMore: false, - messages: olderMessages, - sessionId: "retained-history-session", - thinkingLevel: null, - totalMessages: 140, - }, - }, - { - match: { sessionKey: "agent:main:session-b" }, - response: { - hasMore: true, - messages: recentMessages, - nextOffset: 100, - sessionId: "retained-history-session", - thinkingLevel: null, - totalMessages: 140, - }, - }, - { - match: { sessionKey: "agent:main:session-a" }, - response: { - hasMore: false, - messages: shortMessages, - sessionId: "short-history-session", - thinkingLevel: null, - totalMessages: 2, - }, - }, - ], - }, - "chat.startup": { - cases: [ - { - match: { sessionKey: "agent:main:session-b" }, - response: { - hasMore: true, - messages: recentMessages, - nextOffset: 100, - sessionId: "retained-history-session", - thinkingLevel: null, - totalMessages: 140, - }, - }, - { - match: {}, - response: { - hasMore: false, - messages: shortMessages, - sessionId: "short-history-session", - thinkingLevel: null, - totalMessages: 2, - }, - }, - ], - }, - "sessions.list": chatSessionListResponse(), - }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 }); - - const sessionB = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', - ); - const sessionA = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link', - ); - await sessionB.click(); - await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); - const thread = page.locator(".chat-thread"); - await thread.hover(); - await page.mouse.wheel(0, -1_000_000); - await expect - .poll(() => - page - .locator("openclaw-chat-pane") - .evaluate( - (element) => - (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages - .length, - ), - ) - .toBe(140); - // Prepending preserves the visible anchor. A renewed upward gesture - // reaches the newly loaded start instead of teleporting the reader. - await page.mouse.wheel(0, -1_000_000); - await page.getByText(/^older retained message 1\n/).waitFor({ timeout: 10_000 }); - - await sessionA.click(); - await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 }); - const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length; - await page.evaluate(() => { - type FrameSample = { - hiddenNotice: boolean; - loading: boolean; - messageCount: number; - minOpacity: number; - sessionKey: string; - }; - const samples: FrameSample[] = []; - ( - globalThis as typeof globalThis & { - chatSessionReturnSamples: FrameSample[]; - } - ).chatSessionReturnSamples = samples; - const deadline = performance.now() + 750; - const sample = () => { - const pane = document.querySelector("openclaw-chat-pane") as - | (HTMLElement & { - state?: { chatMessages?: unknown[]; sessionKey?: string }; - }) - | null; - const rows = Array.from(document.querySelectorAll("[data-chat-row-key]")); - samples.push({ - hiddenNotice: document.body.textContent?.includes("Showing last") ?? false, - loading: document.querySelector(".chat-history-loading") !== null, - messageCount: pane?.state?.chatMessages?.length ?? 0, - minOpacity: rows.reduce( - (minimum, row) => Math.min(minimum, Number.parseFloat(getComputedStyle(row).opacity)), - 1, - ), - sessionKey: pane?.state?.sessionKey ?? "", - }); - if (performance.now() < deadline) { - requestAnimationFrame(sample); - } - }; - requestAnimationFrame(sample); - }); - - await sessionB.click(); - await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); - await new Promise((resolve) => { - setTimeout(resolve, 800); - }); - const samples = await page.evaluate( - () => - ( - globalThis as typeof globalThis & { - chatSessionReturnSamples: Array<{ - hiddenNotice: boolean; - loading: boolean; - messageCount: number; - minOpacity: number; - sessionKey: string; - }>; - } - ).chatSessionReturnSamples, - ); - const returnedSamples = samples.filter( - (sample) => sample.sessionKey === "agent:main:session-b", - ); - const restoredIndex = returnedSamples.findIndex((sample) => sample.messageCount === 140); - expect(restoredIndex).toBeGreaterThanOrEqual(0); - expect( - returnedSamples.slice(restoredIndex).every((sample) => sample.messageCount === 140), - ).toBe(true); - expect(returnedSamples.every((sample) => sample.minOpacity === 1)).toBe(true); - expect(returnedSamples.every((sample) => !sample.hiddenNotice)).toBe(true); - expect(returnedSamples.every((sample) => !sample.loading)).toBe(true); - expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0); - await expectRequestCountStable(gateway, "chat.history", historyRequestsBeforeReturn + 1); - if (artifactDir) { - await page.screenshot({ - path: `${artifactDir}/retained-history-return.png`, - fullPage: true, - }); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps rejected pre-ACK sends visible and restores the draft", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.deferNext("chat.send"); - - const prompt = "policy should not eat this"; - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - await gateway.waitForRequest("chat.send"); - - await gateway.rejectDeferred("chat.send", { - code: "INVALID_REQUEST", - message: "send blocked by session policy", - }); - - await page.locator(".chat-queue").getByText("Failed").waitFor({ timeout: 10_000 }); - await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); - expect(await composer.inputValue()).toBe(prompt); - } finally { - await closeBrowserContext(context); - } - }); - - it("parks an ACK-lost send for review before a same-key manual retry", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "chat.history": { - messages: [], - sessionId: "control-ui-e2e-session", - sessionInfo: { hasActiveRun: false, status: "done" }, - thinkingLevel: null, - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.deferNext("chat.send"); - - const prompt = "retry with the same key"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const firstRequest = await gateway.waitForRequest("chat.send"); - const firstParams = requireRecord(firstRequest.params); - const runId = requireString(firstParams.idempotencyKey, "first idempotency key"); - - await gateway.closeLatest(1006, "lost ack"); - - const queue = page.locator(".chat-queue"); - await queue.getByText("Needs review").waitFor({ timeout: 10_000 }); - expect(await gateway.getRequests("chat.send")).toHaveLength(1); - await queue.locator(".chat-queue__retry").click(); - - const sends = await waitForRequests(gateway, "chat.send", 2); - const secondParams = requireRecord(sends[1]?.params); - expect(secondParams.idempotencyKey).toBe(runId); - expect(secondParams.message).toBe(prompt); - await queue.waitFor({ state: "detached", timeout: 10_000 }); - } finally { - await closeBrowserContext(context); - } - }); - - it("stores new input while offline and sends it after reconnect", async () => { - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - const context = await newBrowserContext({ - locale: "en-US", - ...(artifactDir - ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } - : {}), - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "chat.history": { - messages: [], - sessionId: "control-ui-e2e-session", - sessionInfo: { hasActiveRun: false, status: "done" }, - thinkingLevel: null, - }, - }, - }); - - try { - await page.goto(controlUiSessionUrl(server.baseUrl, "global")); - const composer = page.locator(".agent-chat__composer-combobox textarea"); - await composer.waitFor({ state: "visible", timeout: 10_000 }); - - await gateway.setOnline(false); - await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 }); - - const prompt = "send this when the Gateway returns"; - const attachmentName = "offline-proof.txt"; - const attachmentMimeType = "text/plain"; - const attachmentText = "offline attachment proof"; - const attachmentBase64 = Buffer.from(attachmentText).toString("base64"); - const attachmentDataUrl = `data:${attachmentMimeType};base64,${attachmentBase64}`; - const composerEnabled = await composer.isEnabled(); - expect(composerEnabled).toBe(true); - await composer.fill(prompt); - await page.locator(".agent-chat__file-input").setInputFiles({ - name: attachmentName, - mimeType: attachmentMimeType, - buffer: Buffer.from(attachmentText), - }); - await page.locator(".chat-attachment-file__name", { hasText: attachmentName }).waitFor({ - timeout: 10_000, - }); - const send = page.getByRole("button", { name: "Send message" }); - const sendEnabled = await send.isEnabled(); - expect(sendEnabled).toBe(true); - await send.click(); - - const queue = page.locator(".chat-queue"); - await queue.getByText("Waiting for reconnect").waitFor({ timeout: 10_000 }); - await queue.getByText(prompt).waitFor({ timeout: 10_000 }); - const requestsBeforeReconnect = await gateway.getRequests("chat.send"); - expect(requestsBeforeReconnect).toHaveLength(0); - const readStoredProof = () => - page.evaluate( - ({ expectedAttachmentName, expectedAttachmentDataUrl, expectedPrompt }) => { - const storedValues = Object.entries(sessionStorage) - .filter(([key]) => key.startsWith("openclaw.control.chatComposer.v2:")) - .map(([, value]) => value); - const stored = storedValues.join("\n"); - let runId: string | null = null; - for (const value of storedValues) { - try { - const parsed = JSON.parse(value) as { - sessions?: Record< - string, - { - queue?: Array<{ - attachments?: Array<{ dataUrl?: unknown; fileName?: unknown }>; - sendRunId?: unknown; - text?: unknown; - }>; - } - >; - }; - const item = Object.values(parsed.sessions ?? {}) - .flatMap((session) => session.queue ?? []) - .find((entry) => entry.text === expectedPrompt); - if (typeof item?.sendRunId === "string") { - runId = item.sendRunId; - const attachment = item.attachments?.find( - (entry) => entry.fileName === expectedAttachmentName, - ); - return { - attachment: attachment?.dataUrl === expectedAttachmentDataUrl, - prompt: true, - runId, - waitingReconnect: value.includes('"sendState":"waiting-reconnect"'), - }; - } - } catch { - // Ignore unrelated malformed session storage in this focused proof. - } - } - return { - attachment: false, - prompt: stored.includes(expectedPrompt), - runId, - waitingReconnect: stored.includes('"sendState":"waiting-reconnect"'), - }; - }, - { - expectedAttachmentDataUrl: attachmentDataUrl, - expectedAttachmentName: attachmentName, - expectedPrompt: prompt, - }, - ); - await expect.poll(readStoredProof).toEqual({ - attachment: true, - prompt: true, - runId: expect.any(String), - waitingReconnect: true, - }); - const storedProof = await readStoredProof(); - const storedRunId = requireString(storedProof.runId, "stored offline send idempotency key"); - if (artifactDir) { - await page.screenshot({ path: `${artifactDir}/01-offline-queued.png`, fullPage: true }); - } - - await page.reload(); - await expect.poll(readStoredProof).toEqual({ - attachment: true, - prompt: true, - runId: storedRunId, - waitingReconnect: true, - }); - // A cold reload waits for initial Gateway bootstrap before rebuilding the - // UI. Storage survival here plus replay below is the reload contract. - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - await gateway.setOnline(true); - await page.locator("openclaw-chat-pane").waitFor({ state: "attached", timeout: 10_000 }); - - const request = await gateway.waitForRequest("chat.send"); - const params = requireRecord(request.params); - const runId = requireString(params.idempotencyKey, "offline send idempotency key"); - expect(params.message).toBe(prompt); - expect(runId).toBe(storedRunId); - expect(params.attachments).toEqual([ - { - content: attachmentBase64, - fileName: attachmentName, - mimeType: attachmentMimeType, - type: "file", - }, - ]); - await queue.getByText("Needs review").waitFor({ timeout: 10_000 }); - await queue - .getByText("Delivery could not be confirmed after reconnect.", { exact: false }) - .waitFor({ timeout: 10_000 }); - if (artifactDir) { - await page.screenshot({ path: `${artifactDir}/02-reconnected-review.png`, fullPage: true }); - } - await expectRequestCountStable(gateway, "chat.send", 1); - const requestsAfterReconnect = await gateway.getRequests("chat.send"); - await gateway.setHistoryMessages([ - { - role: "user", - __openclaw: { idempotencyKey: `${runId}:user` }, - }, - ]); - await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." }); - await queue.waitFor({ state: "detached", timeout: 10_000 }); - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - await expect - .poll(async () => { - const proof = await readStoredProof(); - return proof.attachment || proof.prompt || proof.runId === runId; - }) - .toBe(false); - await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" }); - await expectRequestCountStable(gateway, "chat.send", 1); - if (artifactDir) { - await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true }); - } - if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { - process.stdout.write( - `${JSON.stringify({ - proof: "offline-chat-reconnect", - composerEnabled, - sendEnabled, - waitingStateVisible: true, - storedPrompt: storedProof.prompt, - storedWaitingState: storedProof.waitingReconnect, - requestsBeforeReconnect: requestsBeforeReconnect.length, - requestsAfterReconnect: requestsAfterReconnect.length, - idempotencyKeyPresent: runId.length > 0, - queueClearedAfterDelivery: true, - })}\n`, - ); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("routes runtime-aware model commands through the server directive path", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const command = "/model openai/gpt-5.6-luna --runtime codex continue with the selected model"; - await page.locator(".agent-chat__composer-combobox textarea").fill(command); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - expect(requireRecord(sendRequest.params)).toMatchObject({ - message: command, - sessionKey: "agent:main:main", - }); - expect(await gateway.getRequests("sessions.patch")).toHaveLength(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps a session model override selected after switching away and back", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": chatSessionListResponse(), - }, - models: [ - { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, - { id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" }, - ], - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const main = page.getByRole("main"); - const openModelSelect = async () => { - const trigger = main.locator('[data-chat-model-select="true"]').first(); - await trigger.waitFor({ state: "visible", timeout: 10_000 }); - return trigger; - }; - const selectModel = async (value: string) => { - await main.locator('[data-chat-model-select="true"]').click(); - const provider = value.split("/", 1)[0]; - await main.locator(`[data-chat-model-provider="${provider}"]`).click(); - const option = main.locator(`[data-chat-model-option="${value}"]`); - await option.waitFor({ state: "visible", timeout: 10_000 }); - await option.click(); - await page.keyboard.press("Escape"); - }; - - let modelSelect = await openModelSelect(); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); - - await selectModel("bedrock/claude-opus-4.5"); - const patchRequest = await gateway.waitForRequest("sessions.patch"); - expect(requireRecord(patchRequest.params)).toMatchObject({ - key: "agent:main:session-a", - model: "bedrock/claude-opus-4.5", - }); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe( - "bedrock/claude-opus-4.5", - ); - - await page - .locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', - ) - .click(); - await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({ - timeout: 10_000, - }); - modelSelect = await openModelSelect(); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); - - await page - .locator( - '.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link', - ) - .click(); - await page.locator(".sidebar-recent-session--active").getByText("Session A").waitFor({ - timeout: 10_000, - }); - - modelSelect = await openModelSelect(); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe( - "bedrock/claude-opus-4.5", - ); - } finally { - await closeBrowserContext(context); - } - }); - - it("restores the selected agent model after clearing a session override", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const agentsList = { - agents: [ - { - id: "ops", - model: { primary: "anthropic/claude-opus-4-5" }, - name: "Operations", - }, - ], - defaultId: "ops", - mainKey: "main", - scope: "agent", - }; - const sessionsList = { - count: 1, - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", - }, - path: "", - sessions: [ - { - key: "agent:ops:session-a", - kind: "direct", - label: "Operations", - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }; - const gateway = await installMockGateway(page, { - assistantAgentId: "ops", - defaultAgentId: "ops", - methodResponses: { - "agents.list": agentsList, - "chat.startup": { - agentsList, - messages: [], - metadata: { - models: [ - { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, - { - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - provider: "anthropic", - }, - ], - }, - sessionId: "control-ui-e2e-session", - thinkingLevel: null, - }, - "sessions.list": sessionsList, - }, - models: [ - { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, - { id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" }, - ], - sessionKey: "agent:ops:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const main = page.getByRole("main"); - const modelSelect = main.locator('[data-chat-model-select="true"]').first(); - await modelSelect.waitFor({ state: "visible", timeout: 10_000 }); - expect(await modelSelect.textContent()).toContain("Claude Opus 4.5"); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); - - await modelSelect.click(); - await main.locator('[data-chat-model-provider="openai"]').click(); - await main.locator('[data-chat-model-option="openai/gpt-5.5"]').click(); - const firstPatch = await gateway.waitForRequest("sessions.patch"); - expect(requireRecord(firstPatch.params)).toMatchObject({ - key: "agent:ops:session-a", - model: "openai/gpt-5.5", - }); - expect(await modelSelect.textContent()).toContain("GPT-5.5"); - - // The picker stays open after an immediate apply. Return to the default - // model's provider and select its real catalog row to clear the override. - await main.locator('[data-chat-model-provider="anthropic"]').click(); - const defaultModel = main.locator( - '[data-chat-model-option="anthropic/claude-opus-4-5"][data-chat-model-default="true"]', - ); - await defaultModel.waitFor({ state: "visible", timeout: 10_000 }); - expect(await defaultModel.textContent()).toContain("Default"); - expect(await main.locator('[data-chat-model-option=""]').count()).toBe(0); - await defaultModel.click(); - const patches = await waitForRequests(gateway, "sessions.patch", 2); - expect(requireRecord(patches[1]?.params)).toMatchObject({ - key: "agent:ops:session-a", - model: null, - }); - expect(await modelSelect.textContent()).toContain("Claude Opus 4.5"); - expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps every sidebar session stable while selecting sessions and supports sort modes", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const createdSessionKeys = Array.from( - { length: 11 }, - (_, index) => `agent:main:session-${String.fromCharCode(97 + index)}`, - ); - const pinnedSessionKey = "agent:main:session-pinned"; - const createdOrder = [pinnedSessionKey, ...createdSessionKeys]; - const updatedOrder = [pinnedSessionKey, ...createdSessionKeys.toReversed()]; - const sessions = { - count: createdSessionKeys.length + 1, - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", - }, - path: "", - sessions: [ - { - key: pinnedSessionKey, - kind: "direct", - label: "Pinned Session", - pinned: true, - pinnedAt: 1, - updatedAt: 50, - }, - ...createdSessionKeys.map((key, index) => ({ - key, - kind: "direct", - label: `Session ${key.slice(-1).toUpperCase()}`, - updatedAt: (index + 1) * 100, - })), - ], - ts: Date.now(), - }; - await installMockGateway(page, { - methodResponses: { "sessions.list": sessions }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await page - .locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]') - .waitFor({ - timeout: 10_000, - }); - await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11)); - await page.getByRole("button", { name: "Show more" }).click(); - await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); - - await page - .locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', - ) - .click(); - await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({ - timeout: 10_000, - }); - await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); - - const activeWeight = await page - .locator('.sidebar-recent-session[data-session-key="agent:main:session-b"]') - .locator(".sidebar-recent-session__name") - .evaluate((label) => getComputedStyle(label).fontWeight); - const inactiveWeight = await page - .locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]') - .locator(".sidebar-recent-session__name") - .evaluate((label) => getComputedStyle(label).fontWeight); - expect(activeWeight).toBe(inactiveWeight); - - const sortThreads = page.getByRole("button", { name: "Sort threads" }); - await sortThreads.locator("..").hover(); - await sortThreads.click(); - await page.getByRole("menuitemradio", { name: "Last updated" }).click(); - await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder); - - await sortThreads.locator("..").hover(); - await sortThreads.click(); - await page.getByRole("menuitemradio", { name: "Created" }).click(); - await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); - - await sortThreads.locator("..").hover(); - await sortThreads.click(); - await page.getByRole("main").click(); - await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps derived sidebar titles and accessible state after session patch refreshes", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const initialKey = "agent:main:session-a"; - const key = "agent:main:session-b"; - const readableTitle = "Readable planning title"; - const baseTime = Date.now(); - const sessionsWithDerivedTitle = chatSessionListResponse([ - { - key: initialKey, - kind: "direct", - label: initialKey, - displayName: initialKey, - derivedTitle: "Initial readable title", - updatedAt: baseTime, - }, - { - key, - kind: "direct", - label: key, - displayName: key, - derivedTitle: readableTitle, - updatedAt: baseTime - 60_000, - }, - ]); - const sessionsWithoutDerivedTitle = chatSessionListResponse([ - { - key: initialKey, - kind: "direct", - label: initialKey, - displayName: initialKey, - updatedAt: baseTime, - }, - { - key, - kind: "direct", - label: key, - displayName: key, - updatedAt: baseTime - 60_000, - }, - ]); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": { - cases: [ - { match: { includeDerivedTitles: true }, response: sessionsWithDerivedTitle }, - { match: {}, response: sessionsWithoutDerivedTitle }, - ], - }, - }, - sessionKey: initialKey, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const row = page.locator(`.sidebar-recent-session[data-session-key="${key}"]`); - await row.locator("a.sidebar-recent-session__link").click(); - await expect - .poll(async () => { - const requests = await gateway.getRequests("sessions.list"); - return requests.map((request) => request.params); - }) - .toContainEqual(expect.objectContaining({ includeDerivedTitles: true })); - const label = row.locator(".sidebar-recent-session__name"); - const link = row.locator("a.sidebar-recent-session__link"); - await expect.poll(() => label.textContent()).toBe(readableTitle); - expect(await row.getAttribute("role")).toBe("listitem"); - expect(await row.getAttribute("aria-label")).toBeNull(); - expect(await link.getAttribute("aria-label")).toBeNull(); - expect(await link.getAttribute("aria-current")).toBe("page"); - expect(await link.getAttribute("aria-describedby")).toBeNull(); - expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`); - await captureSessionAccessibilityProof(page, "after-derived-title"); - - const listCountBeforePatch = (await gateway.getRequests("sessions.list")).length; - await row.hover(); - await row.getByRole("button", { name: "Pin thread" }).click(); - - const patchRequest = await gateway.waitForRequest("sessions.patch"); - expect(requireRecord(patchRequest.params)).toMatchObject({ - key, - pinned: true, - }); - await expect - .poll(async () => { - const requests = await gateway.getRequests("sessions.list"); - return requests.slice(listCountBeforePatch).map((request) => request.params); - }) - .toContainEqual(expect.objectContaining({ includeDerivedTitles: true })); - await expect.poll(() => label.textContent()).toBe(readableTitle); - expect(await link.getAttribute("aria-current")).toBe("page"); - expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`); - await captureSessionAccessibilityProof(page, "after-patch-refresh"); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps long sidebar labels clipped after a session switch", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.clock.install(); - const sessions = chatSessionListResponse(); - const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture"); - const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture"); - firstSession.label = "Short"; - secondSession.label = - "Review and repair the intentionally overlong sidebar session title before navigation ".repeat( - 4, - ); - await installMockGateway(page, { - methodResponses: { "sessions.list": sessions }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const recentRow = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"]', - ); - const recentLabel = recentRow.locator(".sidebar-recent-session__name"); - await recentLabel.waitFor({ state: "visible", timeout: 10_000 }); - const layout = await recentLabel.evaluate((label) => ({ - clientWidth: label.clientWidth, - linkWidth: label.parentElement?.clientWidth ?? 0, - rowWidth: label.closest(".sidebar-recent-session")?.clientWidth ?? 0, - scrollWidth: label.scrollWidth, - text: label.textContent, - })); - expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth); - - await recentRow.dispatchEvent("mouseenter"); - await page.clock.runFor(250); - expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( - "hover-marquee--scrolling", - ); - await recentRow.dispatchEvent("mouseleave"); - await page.clock.runFor(300); - expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( - "hover-marquee--scrolling", - ); - await recentRow.dispatchEvent("mouseenter"); - await expect - .poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 }) - .toContain("hover-marquee--scrolling"); - await recentRow.dispatchEvent("mouseleave"); - await expect - .poll( - () => - recentLabel.evaluate((label) => ({ - textIndent: getComputedStyle(label).textIndent, - textOverflow: getComputedStyle(label).textOverflow, - })), - { timeout: 1_500 }, - ) - .toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); - - await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", { - button: 0, - }); - await page.locator(".sidebar-recent-session--active").getByText(secondSession.label).waitFor({ - timeout: 10_000, - }); - - const activeRow = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-b"]', - ); - expect( - await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({ - textIndent: getComputedStyle(label).textIndent, - textOverflow: getComputedStyle(label).textOverflow, - })), - ).toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); - } finally { - await closeBrowserContext(context); - } - }); - - it("renders the visible authenticated assistant avatar after switching sessions", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const avatarBody = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nPcAAAAASUVORK5CYII=", - "base64", - ); - let avatarRequestCount = 0; - await page.route(/\/avatar\/main\?meta=1$/, (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ avatarUrl: "/avatar/main", avatarStatus: "local" }), - }), - ); - await page.route(/\/avatar\/main$/, (route) => { - avatarRequestCount += 1; - return route.fulfill({ contentType: "image/png", body: avatarBody }); - }); - await installMockGateway(page, { - methodResponses: { "sessions.list": chatSessionListResponse() }, - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const documentMarker = await page.evaluate(() => { - const marker = crypto.randomUUID(); - (window as Window & { __openclawAvatarTestDocument?: string })[ - "__openclawAvatarTestDocument" - ] = marker; - return marker; - }); - const avatar = page.locator("img.agent-chat__welcome-avatar"); - await avatar.waitFor({ state: "visible" }); - await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); - const initialAvatarSrc = await avatar.getAttribute("src"); - const initialRequestCount = avatarRequestCount; - - const sessionRow = (sessionKey: string) => - page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`); - const sessionB = sessionRow("agent:main:session-b"); - await sessionB.locator("a.sidebar-recent-session__link").click(); - await expect - .poll(() => sessionB.getAttribute("class")) - .toContain("sidebar-recent-session--active"); - await expect.poll(() => avatarRequestCount).toBeGreaterThan(initialRequestCount); - await expect.poll(() => avatar.getAttribute("src")).not.toBe(initialAvatarSrc); - await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); - await expect.poll(() => avatar.isVisible()).toBe(true); - const sessionBAvatarSrc = await avatar.getAttribute("src"); - const sessionBRequestCount = avatarRequestCount; - - const sessionA = sessionRow("agent:main:session-a"); - await sessionA.locator("a.sidebar-recent-session__link").click(); - await expect - .poll(() => sessionA.getAttribute("class")) - .toContain("sidebar-recent-session--active"); - - await expect.poll(() => avatarRequestCount).toBeGreaterThan(sessionBRequestCount); - await expect.poll(() => avatar.getAttribute("src")).not.toBe(sessionBAvatarSrc); - await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); - await expect.poll(() => avatar.isVisible()).toBe(true); - expect( - await page.evaluate( - () => - (window as Window & { __openclawAvatarTestDocument?: string })[ - "__openclawAvatarTestDocument" - ], - ), - ).toBe(documentMarker); - } finally { - await closeBrowserContext(context); - } - }); - - it("shows a pending send while a model override update is still pending", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.patch"], - methodResponses: { - "sessions.list": chatSessionListResponse(), - }, - models: [ - { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, - { id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" }, - ], - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const main = page.getByRole("main"); - await main.locator('[data-chat-model-select="true"]').click(); - await main.locator('[data-chat-model-provider="bedrock"]').click(); - await main.locator('[data-chat-model-option="bedrock/claude-opus-4.5"]').click(); - await page.keyboard.press("Escape"); - await gateway.waitForRequest("sessions.patch"); - - const prompt = "send while the model save is pending"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({ - timeout: 10_000, - }); - await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - await gateway.resolveDeferred("sessions.patch", {}); - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - expect(params.message).toBe(prompt); - expect(params.sessionKey).toBe("agent:main:session-a"); - } finally { - await closeBrowserContext(context); - } - }); - - it("applies provider-specific reasoning after the selected model is saved", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:session-a"; - const fableSession = { - key: sessionKey, - kind: "direct", - label: "Session A", - model: "claude-fable-5", - modelProvider: "anthropic", - thinkingDefault: "high", - thinkingLevel: "high", - thinkingLevels: [ - { id: "off", label: "off" }, - { id: "adaptive", label: "adaptive" }, - { id: "high", label: "high" }, - { id: "xhigh", label: "xhigh" }, - { id: "max", label: "max" }, - ], - updatedAt: 2, - }; - const solSession = { - ...fableSession, - model: "gpt-5.6-sol", - modelProvider: "openai", - thinkingLevels: [ - { id: "off", label: "off" }, - { id: "low", label: "low" }, - { id: "medium", label: "medium" }, - { id: "high", label: "high" }, - { id: "ultra", label: "ultra" }, - ], - }; - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.patch"], - methodResponses: { - "sessions.list": chatSessionListResponse([fableSession]), - }, - models: [ - { id: "claude-fable-5", name: "Claude Fable 5", provider: "anthropic" }, - { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" }, - ], - sessionKey, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const main = page.getByRole("main"); - await main.locator('[data-chat-model-select="true"]').click(); - await main.locator('[data-chat-model-provider="openai"]').click(); - await main.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').click(); - - const modelPatch = await gateway.waitForRequest("sessions.patch"); - expect(requireRecord(modelPatch.params)).toMatchObject({ - key: sessionKey, - model: "openai/gpt-5.6-sol", - }); - const thinkingSlider = main.locator('[data-chat-thinking-slider="true"]'); - await expect.poll(() => thinkingSlider.isDisabled()).toBe(true); - await thinkingSlider.evaluate((input) => { - const slider = input as HTMLInputElement; - slider.value = slider.max; - slider.dispatchEvent(new Event("change", { bubbles: true })); - }); - await expectRequestCountStable(gateway, "sessions.patch", 1); - - await gateway.setMethodResponse("sessions.list", chatSessionListResponse([solSession])); - await gateway.resolveDeferred("sessions.patch"); - await expect - .poll(() => thinkingSlider.getAttribute("data-chat-thinking-values")) - .toContain("ultra"); - await expect.poll(() => thinkingSlider.isEnabled()).toBe(true); - - await thinkingSlider.evaluate((input) => { - const slider = input as HTMLInputElement; - const values = (slider.dataset.chatThinkingValues ?? "").split(","); - slider.value = String(values.indexOf("ultra")); - slider.dispatchEvent(new Event("input", { bubbles: true })); - slider.dispatchEvent(new Event("change", { bubbles: true })); - }); - const patches = await waitForRequests(gateway, "sessions.patch", 2); - expect(requireRecord(patches[1]?.params)).toMatchObject({ - key: sessionKey, - thinkingLevel: "ultra", - }); - await expect.poll(() => page.getByText(/not supported for/u).count()).toBe(0); - - const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); - if (artifactDir) { - await page.screenshot({ - path: `${artifactDir}/model-thinking-sync.png`, - fullPage: true, - }); - } - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps send pending until reasoning and speed patches finish", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": { - ...chatSessionListResponse([ - { - effectiveFastMode: false, - fastMode: false, - key: "agent:main:session-a", - kind: "direct", - label: "Session A", - model: "gpt-5.5", - modelProvider: "openai", - thinkingLevel: "high", - updatedAt: 2, - }, - ]), - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", - thinkingDefault: "high", - thinkingLevels: [ - { id: "off", label: "off" }, - { id: "low", label: "low" }, - { id: "medium", label: "medium" }, - { id: "high", label: "high" }, - ], - }, - }, - }, - models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }], - sessionKey: "agent:main:session-a", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const main = page.getByRole("main"); - await main.locator('[data-chat-model-select="true"]').click(); - await gateway.deferNext("sessions.patch"); - await main.locator('[data-chat-thinking-slider="true"]').press("ArrowLeft"); - const firstPatch = await gateway.waitForRequest("sessions.patch"); - expect(requireRecord(firstPatch.params).thinkingLevel).toBe("medium"); - - await gateway.deferNext("sessions.patch"); - await main.locator('[data-chat-speed-toggle="on"]').click(); - await expectRequestCountStable(gateway, "sessions.patch", 1); - await page.keyboard.press("Escape"); - - const prompt = "send with the new reasoning and speed"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({ - timeout: 10_000, - }); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - const sessionListCount = (await gateway.getRequests("sessions.list")).length; - await gateway.resolveDeferred("sessions.patch", {}); - const patches = await waitForRequests(gateway, "sessions.patch", 2); - expect(requireRecord(patches[1]?.params).fastMode).toBe(true); - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length) - .toBeGreaterThan(sessionListCount); - expect(await gateway.getRequests("chat.send")).toHaveLength(0); - - await gateway.resolveDeferred("sessions.patch", {}); - const sendRequest = await gateway.waitForRequest("chat.send"); - expect(requireRecord(sendRequest.params)).toMatchObject({ - message: prompt, - sessionKey: "agent:main:session-a", - }); - } finally { - await closeBrowserContext(context); - } - }); - - it("refreshes history after a tool-call window disconnects and reconnects", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - const prompt = "use a tool then reconnect"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - - await gateway.emitGatewayEvent("agent", { - data: { - args: { query: "status" }, - name: "status", - phase: "start", - toolCallId: "tool-1", - }, - runId, - seq: 1, - sessionKey: "main", - stream: "tool", - ts: Date.now(), - }); - await gateway.setHistoryMessages([ - { - __openclaw: { idempotencyKey: `${runId}:user` }, - content: [{ text: prompt, type: "text" }], - role: "user", - timestamp: Date.now(), - }, - { - content: [{ text: "Recovered from refreshed history.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - ]); - - await gateway.closeLatest(1006, "lost during tool call"); - - await page - .locator(".chat-thread-inner") - .getByText("Recovered from refreshed history.") - .waitFor({ timeout: 15_000 }); - expect(await page.locator(".chat-queue").count()).toBe(0); - } finally { - await closeBrowserContext(context); - } - }); - - it("keeps live assistant stream text before the matching tool card", async () => { - const context = await newBrowserContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - - try { - await page.goto(`${server.baseUrl}chat`); - - const prompt = "stream before tool"; - await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); - await page.getByRole("button", { name: "Send message" }).click(); - - const sendRequest = await gateway.waitForRequest("chat.send"); - const params = requireRecord(sendRequest.params); - const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - - await gateway.emitGatewayEvent("chat", { - deltaText: "I will inspect the file.", - message: { - content: [{ text: "I will inspect the file.", type: "text" }], - role: "assistant", - timestamp: Date.now(), - }, - runId, - sessionKey: "main", - state: "delta", - }); - await page.getByText("I will inspect the file.").waitFor({ timeout: 10_000 }); - - await gateway.emitGatewayEvent("agent", { - data: { - name: "read", - phase: "result", - result: "file contents", - toolCallId: "call-read", - }, - runId, - seq: 1, - sessionKey: "main", - stream: "tool", - ts: Date.now() - 10_000, - }); - const toolBubble = page.locator('[data-message-id^="tool:assistant:call-read"]'); - await toolBubble.waitFor({ timeout: 10_000 }); - - const visibleOrder = await page.locator(".chat-thread").evaluate((thread: Element) => { - return Array.from(thread.querySelectorAll(".chat-group")).flatMap((group: Element) => { - const text = group.textContent ?? ""; - if (text.includes("I will inspect the file.")) { - return ["assistant stream"]; - } - if (group.querySelector('[data-message-id^="tool:assistant:call-read"]')) { - return ["tool card"]; - } - return []; - }); - }); - - expect(visibleOrder).toEqual(["assistant stream", "tool card"]); - } finally { - await closeBrowserContext(context); - } - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts new file mode 100644 index 000000000000..5fd1a4140923 --- /dev/null +++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts @@ -0,0 +1,583 @@ +import { expect, it } from "vitest"; +import { + chatSessionListResponse, + createChatFlowE2eSuite, + installMockGateway, + requireRecord, + requireString, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("opens a git-backed agent draft from the sidebar new-session action", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { workspaceGit: true }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const newSessionButton = page.locator("openclaw-app-sidebar .sidebar-brand__new-thread"); + await newSessionButton.waitFor({ state: "visible", timeout: 10_000 }); + await newSessionButton.click(); + + await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); + await expect.poll(() => new URL(page.url()).searchParams.get("agent")).toBe("main"); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("starts a model-suggested follow-up in a fresh worktree session", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const suggestion = { + id: "task_123", + title: "Remove stale adapter", + prompt: "Delete the stale adapter in src/example.ts and update tests.", + tldr: "The adapter is unreachable and adds maintenance cost.", + cwd: "/projects/example", + sessionKey: "main", + agentId: "main", + createdAt: Date.now(), + }; + const gateway = await installMockGateway(page, { + deferredMethods: ["taskSuggestions.list"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "taskSuggestions.list", + "taskSuggestions.accept", + ], + methodResponses: { + "taskSuggestions.list": { suggestions: [suggestion] }, + "taskSuggestions.accept": { + taskId: "task_123", + key: "agent:main:dashboard:suggested", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("taskSuggestions.list"); + await gateway.emitGatewayEvent("task.suggestion", { + action: "created", + suggestion, + }); + await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); + + const startButton = page.getByRole("button", { name: "Start in worktree" }); + await startButton.waitFor({ state: "visible", timeout: 10_000 }); + await page + .getByText("/projects/example", { exact: true }) + .waitFor({ state: "visible", timeout: 10_000 }); + await page + .getByText("Delete the stale adapter in src/example.ts and update tests.", { + exact: true, + }) + .waitFor({ state: "visible", timeout: 10_000 }); + await startButton.click(); + + const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept"); + expect(acceptRequest.params).toEqual({ taskId: "task_123" }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("clears model-suggested follow-ups while switching sessions", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + methodResponses: { + "sessions.list": chatSessionListResponse(), + "taskSuggestions.list": { + suggestions: [ + { + id: "task_session_a", + title: "Follow up from session A", + prompt: "Complete the follow-up discovered in session A.", + tldr: "This suggestion belongs only to session A.", + cwd: "/projects/example", + sessionKey: "agent:main:session-a", + agentId: "main", + createdAt: Date.now(), + }, + ], + }, + }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const startButton = page.getByRole("button", { name: "Start in worktree" }); + await startButton.waitFor({ state: "visible", timeout: 10_000 }); + await gateway.deferNext("taskSuggestions.list"); + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ) + .click(); + await waitForRequests(gateway, "taskSuggestions.list", 2); + + await expect.poll(() => startButton.count()).toBe(0); + await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps the composer visible when follow-up suggestions overflow", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 720, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + methodResponses: { + "taskSuggestions.list": { + suggestions: Array.from({ length: 12 }, (_, index) => ({ + id: `task_overflow_${index}`, + title: `Follow-up ${index}`, + prompt: "Inspect the related implementation and tests. ".repeat(12), + tldr: "This follow-up remains useful but must not hide the composer.", + cwd: "/projects/example", + sessionKey: "main", + agentId: "main", + createdAt: Date.now() + index, + })), + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const tray = page.locator(".task-suggestions"); + await tray.waitFor({ state: "visible", timeout: 10_000 }); + expect(await tray.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe( + true, + ); + + const composer = page.locator(".agent-chat__composer-shell"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + const box = await composer.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.y ?? 720) + (box?.height ?? 0)).toBeLessThanOrEqual(720); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("sends the first chat turn while agents startup loading is still pending", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + defaultAgentId: "ops", + deferredMethods: ["chat.startup"], + historyMessages: [], + sessionKey: "global", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + expect(await gateway.getRequests("agents.list")).toHaveLength(0); + // chat.startup owns the initial metadata load; the old parallel + // chat.metadata request was only a synchronization point for this test. + expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); + expect(await gateway.getRequests("commands.list")).toHaveLength(0); + expect(await gateway.getRequests("models.list")).toHaveLength(0); + + const prompt = "send before agents list completes"; + await page + .locator(".agent-chat__composer-combobox textarea") + .waitFor({ state: "visible", timeout: 10_000 }); + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + await expect + .poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), { + timeout: 10_000, + }) + .toBe(""); + const params = requireRecord(sendRequest.params); + expect(params.message).toBe(prompt); + expect(params.sessionKey).toBe("global"); + expect(params.agentId).toBe("ops"); + + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await gateway.emitGatewayEvent("chat", { + deltaText: "First token visible.", + message: { + content: [{ text: "First token visible.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + agentId: "ops", + sessionKey: "global", + state: "delta", + }); + await page.getByText("First token visible.").waitFor({ timeout: 10_000 }); + await gateway.resolveDeferred("chat.startup", { + agentsList: { + agents: [{ id: "ops", name: "OpenClaw" }], + defaultId: "ops", + mainKey: "main", + scope: "agent", + }, + messages: [], + metadata: { + commands: [ + { + acceptsArgs: false, + description: "Loaded after startup completes", + name: "startup-ready", + scope: "text", + source: "native", + }, + ], + models: [ + { + available: true, + id: "startup-model", + name: "Startup Model", + provider: "openai", + }, + ], + }, + sessionId: "control-ui-e2e-session", + thinkingLevel: null, + }); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await page.getByText("First token visible.").waitFor({ timeout: 10_000 }); + await expect + .poll(() => page.locator('[data-chat-model-option="openai/startup-model"]').count()) + .toBe(1); + await gateway.emitChatFinal({ runId, text: "History race stayed visible." }); + await page + .locator(".chat-thread-inner") + .getByText("History race stayed visible.") + .waitFor({ timeout: 10_000 }); + await page.locator(".agent-chat__composer-combobox textarea").fill("/"); + await page.getByRole("option", { name: /\/startup-ready/ }).waitFor({ timeout: 10_000 }); + // Check after both controls render so no late fallback RPC supplied either catalog. + expect({ + commands: (await gateway.getRequests("commands.list")).length, + metadata: (await gateway.getRequests("chat.metadata")).length, + models: (await gateway.getRequests("models.list")).length, + }).toEqual({ commands: 0, metadata: 0, models: 0 }); + expect(await gateway.getRequests("agents.list")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("steers ordinary follow-ups when the server default is steer", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } + : {}), + }); + const page = await context.newPage(); + const runtimeConfig = { + messages: { queue: { byChannel: { webchat: "steer" }, mode: "followup" } }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + config: runtimeConfig, + hash: "queue-steer-config", + issues: [], + raw: JSON.stringify(runtimeConfig), + runtimeConfig, + valid: true, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + const followUp = "tighten the active plan"; + await page.locator(".agent-chat__composer-combobox textarea").fill(followUp); + await page.getByRole("button", { name: "Steer into the active run" }).click(); + + const sends = await waitForRequests(gateway, "chat.send", 2); + expect(requireRecord(sends[1]?.params)).toMatchObject({ + deliver: false, + message: followUp, + sessionKey: "main", + }); + const queue = page.locator(".chat-queue"); + await queue.getByText("Steered").waitFor({ timeout: 10_000 }); + await queue.getByText(followUp).waitFor({ timeout: 10_000 }); + if (artifactDir) { + await page.screenshot({ + path: `${artifactDir}/steer-default.png`, + fullPage: true, + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("preserves a non-steer server default for active-run follow-ups", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const runtimeConfig = { + messages: { queue: { byChannel: { webchat: "followup" }, mode: "steer" } }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + config: runtimeConfig, + hash: "queue-followup-config", + issues: [], + raw: JSON.stringify(runtimeConfig), + runtimeConfig, + valid: true, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}settings/appearance`); + const followUpSelect = page.locator("[data-settings-follow-up-mode]"); + await followUpSelect.waitFor({ state: "visible", timeout: 10_000 }); + expect(await followUpSelect.inputValue()).toBe("server"); + await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 }); + if (artifactDir) { + await page.screenshot({ + path: `${artifactDir}/server-followup-setting.png`, + fullPage: true, + }); + } + const configPatchCount = (await gateway.getRequests("config.patch")).length; + const configGetCount = (await gateway.getRequests("config.get")).length; + const overrideConfig = { + ...runtimeConfig, + ui: { prefs: { chatFollowUpMode: "steer" } }, + }; + await gateway.setMethodResponse("config.get", { + config: overrideConfig, + hash: "queue-followup-override-config", + issues: [], + raw: JSON.stringify(overrideConfig), + runtimeConfig: overrideConfig, + valid: true, + }); + await followUpSelect.selectOption("steer"); + await waitForRequests(gateway, "config.patch", configPatchCount + 1); + await waitForRequests(gateway, "config.get", configGetCount + 1); + await page.getByText("Overriding server default (followup)").waitFor({ timeout: 10_000 }); + await gateway.setMethodResponse("config.get", { + config: runtimeConfig, + hash: "queue-followup-reset-config", + issues: [], + raw: JSON.stringify(runtimeConfig), + runtimeConfig, + valid: true, + }); + if (artifactDir) { + await page.screenshot({ + path: `${artifactDir}/server-followup-override.png`, + fullPage: true, + }); + } + await page.getByRole("button", { name: "Reset to server default" }).click(); + await waitForRequests(gateway, "config.patch", configPatchCount + 2); + await waitForRequests(gateway, "config.get", configGetCount + 2); + await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 }); + expect(await followUpSelect.inputValue()).toBe("server"); + + await page.goto(`${suite.server.baseUrl}chat`); + + const activePrompt = "keep this run active"; + await page.locator(".agent-chat__composer-combobox textarea").fill(activePrompt); + await page.getByRole("button", { name: "Send message" }).click(); + + await gateway.waitForRequest("chat.send"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + const queuedPrompt = "queue this on the server"; + await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt); + await page.getByRole("button", { name: "Queue message" }).click(); + + const sends = await waitForRequests(gateway, "chat.send", 2); + expect(requireRecord(sends[1]?.params)).toMatchObject({ + message: queuedPrompt, + queueMode: "followup", + sessionKey: "main", + }); + await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("honors a session interrupt override ahead of the webchat config default", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "main"; + const runtimeConfig = { + messages: { queue: { byChannel: { webchat: "steer" }, mode: "steer" } }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": { + config: runtimeConfig, + hash: "queue-session-override-config", + issues: [], + raw: JSON.stringify(runtimeConfig), + runtimeConfig, + valid: true, + }, + "sessions.list": chatSessionListResponse([ + { + effectiveQueueMode: "interrupt", + key: "agent:main:main", + kind: "direct", + label: "Main", + queueMode: "interrupt", + updatedAt: Date.now(), + }, + ]), + }, + sessionInfo: { + effectiveQueueMode: "interrupt", + hasActiveRun: false, + key: "agent:main:main", + queueMode: "interrupt", + status: "done", + }, + sessionKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + const followUp = "interrupt for this session override"; + await page.locator(".agent-chat__composer-combobox textarea").fill(followUp); + await page.getByRole("button", { name: "Send message" }).click(); + + const sends = await waitForRequests(gateway, "chat.send", 2); + expect(requireRecord(sends[1]?.params)).toMatchObject({ + message: followUp, + queueMode: "interrupt", + sessionKey, + }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("steers a restored queued message when only the session row reports the active run", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}settings/appearance`); + await page.locator("[data-settings-follow-up-mode]").selectOption("queue"); + await page.goto(`${suite.server.baseUrl}chat`); + + await page.locator(".agent-chat__composer-combobox textarea").fill("keep this run active"); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + const queuedPrompt = "steer this after restoring the queue"; + await page.locator(".agent-chat__composer-combobox textarea").fill(queuedPrompt); + await page.getByRole("button", { name: "Queue message" }).click(); + await page.locator(".chat-queue").getByText(queuedPrompt).waitFor({ timeout: 10_000 }); + + await gateway.setMethodResponse( + "sessions.list", + chatSessionListResponse([ + { + hasActiveRun: true, + key: "main", + kind: "direct", + label: "Main", + updatedAt: Date.now(), + }, + ]), + ); + await page.reload(); + + const queue = page.locator(".chat-queue"); + await queue.getByText(queuedPrompt).waitFor({ timeout: 10_000 }); + await queue.getByRole("button", { name: "Steer" }).click(); + + const steerRequest = await gateway.waitForRequest("chat.send"); + const steerParams = requireRecord(steerRequest.params); + expect(steerParams).toMatchObject({ + deliver: false, + message: queuedPrompt, + sessionKey: "main", + }); + await queue.getByText("Steered").waitFor({ timeout: 10_000 }); + await gateway.emitChatFinal({ + runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"), + text: "Restored steer completed.", + }); + await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts new file mode 100644 index 000000000000..78fb47d113af --- /dev/null +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -0,0 +1,629 @@ +import { expect, it } from "vitest"; +import { + chatSessionListResponse, + controlUiSessionUrl, + createChatFlowE2eSuite, + expectDefined, + expectRequestCountStable, + installMockGateway, + requireRecord, + requireString, + scrollChatThreadToTop, + visibleChatBubbleTexts, + waitForChatScrollIdle, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("shows persisted user messages after opening History and scrolling mixed history", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const baseTs = Date.now() - 100_000; + const currentSessionMessages = [ + { + content: [{ text: "Current session placeholder", type: "text" }], + role: "assistant", + timestamp: baseTs - 1, + }, + ]; + const historyMessages = Array.from({ length: 70 }, (_, index) => ({ + content: [ + { + text: `${index % 2 === 0 ? "User history question" : "Assistant history answer"} ${index}\n${"history detail line\n".repeat(4)}`, + type: index % 2 === 0 ? "input_text" : "output_text", + }, + ], + role: index % 2 === 0 ? "user" : "assistant", + timestamp: baseTs + index, + })); + const gateway = await installMockGateway(page, { + historyMessages: currentSessionMessages, + methodResponses: { + "chat.startup": { + cases: [ + { + match: { sessionKey: "agent:main:session-b" }, + response: { + messages: historyMessages, + sessionId: "control-ui-e2e-history-session-b", + thinkingLevel: null, + }, + }, + { + match: { sessionKey: "agent:main:session-a" }, + response: { + messages: currentSessionMessages, + sessionId: "control-ui-e2e-history-session-a", + thinkingLevel: null, + }, + }, + ], + }, + "sessions.list": chatSessionListResponse(), + }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Current session placeholder").waitFor({ timeout: 10_000 }); + + const startupCountBeforeSwitch = (await gateway.getRequests("chat.startup")).length; + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ) + .click(); + const startupRequests = await waitForRequests( + gateway, + "chat.startup", + startupCountBeforeSwitch + 1, + ); + const historyRequest = expectDefined(startupRequests.at(-1), "session B startup request"); + expect(requireRecord(historyRequest.params)).toMatchObject({ + sessionKey: "agent:main:session-b", + }); + await page.locator(".chat-thread").getByText("User history question 68").waitFor({ + timeout: 10_000, + }); + await page.locator(".chat-thread").getByText("Assistant history answer 69").waitFor({ + timeout: 10_000, + }); + await expect + .poll( + async () => { + const texts = await visibleChatBubbleTexts(page); + return ( + texts.some((text) => text.includes("User history question 68")) && + texts.some((text) => text.includes("Assistant history answer 69")) + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); + + await waitForChatScrollIdle(page); + await scrollChatThreadToTop(page); + await page.locator(".chat-thread").getByText("User history question 10").waitFor({ + timeout: 10_000, + }); + await scrollChatThreadToTop(page); + await page.locator(".chat-thread").getByText("User history question 0").waitFor({ + timeout: 10_000, + }); + await scrollChatThreadToTop(page); + await expect + .poll( + async () => { + const texts = await visibleChatBubbleTexts(page); + return ( + texts.some((text) => text.includes("User history question 0")) && + texts.some((text) => text.includes("Assistant history answer 1")) + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps retained paginated history stable when returning to a session", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } + : {}), + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const historyMessage = (seq: number, label: string) => ({ + __openclaw: { id: `history-${seq}`, seq }, + content: [ + { + text: `${label} ${seq}\n${"retained transcript detail\n".repeat(3)}`, + type: seq % 2 === 0 ? "output_text" : "input_text", + }, + ], + role: seq % 2 === 0 ? "assistant" : "user", + timestamp: 1_800_000_000_000 + seq, + }); + const shortMessages = [historyMessage(1, "short session"), historyMessage(2, "short session")]; + const recentMessages = Array.from({ length: 100 }, (_, index) => + historyMessage(index + 41, "recent retained message"), + ); + const olderMessages = Array.from({ length: 40 }, (_, index) => + historyMessage(index + 1, "older retained message"), + ); + const gateway = await installMockGateway(page, { + historyMessages: shortMessages, + methodResponses: { + "chat.history": { + cases: [ + { + match: { offset: 100, sessionKey: "agent:main:session-b" }, + response: { + hasMore: false, + messages: olderMessages, + sessionId: "retained-history-session", + thinkingLevel: null, + totalMessages: 140, + }, + }, + { + match: { sessionKey: "agent:main:session-b" }, + response: { + hasMore: true, + messages: recentMessages, + nextOffset: 100, + sessionId: "retained-history-session", + thinkingLevel: null, + totalMessages: 140, + }, + }, + { + match: { sessionKey: "agent:main:session-a" }, + response: { + hasMore: false, + messages: shortMessages, + sessionId: "short-history-session", + thinkingLevel: null, + totalMessages: 2, + }, + }, + ], + }, + "chat.startup": { + cases: [ + { + match: { sessionKey: "agent:main:session-b" }, + response: { + hasMore: true, + messages: recentMessages, + nextOffset: 100, + sessionId: "retained-history-session", + thinkingLevel: null, + totalMessages: 140, + }, + }, + { + match: {}, + response: { + hasMore: false, + messages: shortMessages, + sessionId: "short-history-session", + thinkingLevel: null, + totalMessages: 2, + }, + }, + ], + }, + "sessions.list": chatSessionListResponse(), + }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 }); + + const sessionB = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ); + const sessionA = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link', + ); + await sessionB.click(); + await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); + const thread = page.locator(".chat-thread"); + await thread.hover(); + await page.mouse.wheel(0, -1_000_000); + await expect + .poll(() => + page + .locator("openclaw-chat-pane") + .evaluate( + (element) => + (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages + .length, + ), + ) + .toBe(140); + // Prepending preserves the visible anchor. A renewed upward gesture + // reaches the newly loaded start instead of teleporting the reader. + await page.mouse.wheel(0, -1_000_000); + await page.getByText(/^older retained message 1\n/).waitFor({ timeout: 10_000 }); + + await sessionA.click(); + await page.getByText(/^short session 2\n/).waitFor({ timeout: 10_000 }); + const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length; + await page.evaluate(() => { + type FrameSample = { + hiddenNotice: boolean; + loading: boolean; + messageCount: number; + minOpacity: number; + sessionKey: string; + }; + const samples: FrameSample[] = []; + ( + globalThis as typeof globalThis & { + chatSessionReturnSamples: FrameSample[]; + } + ).chatSessionReturnSamples = samples; + const deadline = performance.now() + 750; + const sample = () => { + const pane = document.querySelector("openclaw-chat-pane") as + | (HTMLElement & { + state?: { chatMessages?: unknown[]; sessionKey?: string }; + }) + | null; + const rows = Array.from(document.querySelectorAll("[data-chat-row-key]")); + samples.push({ + hiddenNotice: document.body.textContent?.includes("Showing last") ?? false, + loading: document.querySelector(".chat-history-loading") !== null, + messageCount: pane?.state?.chatMessages?.length ?? 0, + minOpacity: rows.reduce( + (minimum, row) => Math.min(minimum, Number.parseFloat(getComputedStyle(row).opacity)), + 1, + ), + sessionKey: pane?.state?.sessionKey ?? "", + }); + if (performance.now() < deadline) { + requestAnimationFrame(sample); + } + }; + requestAnimationFrame(sample); + }); + + await sessionB.click(); + await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); + await new Promise((resolve) => { + setTimeout(resolve, 800); + }); + const samples = await page.evaluate( + () => + ( + globalThis as typeof globalThis & { + chatSessionReturnSamples: Array<{ + hiddenNotice: boolean; + loading: boolean; + messageCount: number; + minOpacity: number; + sessionKey: string; + }>; + } + ).chatSessionReturnSamples, + ); + const returnedSamples = samples.filter( + (sample) => sample.sessionKey === "agent:main:session-b", + ); + const restoredIndex = returnedSamples.findIndex((sample) => sample.messageCount === 140); + expect(restoredIndex).toBeGreaterThanOrEqual(0); + expect( + returnedSamples.slice(restoredIndex).every((sample) => sample.messageCount === 140), + ).toBe(true); + expect(returnedSamples.every((sample) => sample.minOpacity === 1)).toBe(true); + expect(returnedSamples.every((sample) => !sample.hiddenNotice)).toBe(true); + expect(returnedSamples.every((sample) => !sample.loading)).toBe(true); + expect(await page.getByRole("button", { name: "Load older" }).count()).toBe(0); + await expectRequestCountStable(gateway, "chat.history", historyRequestsBeforeReturn + 1); + if (artifactDir) { + await page.screenshot({ + path: `${artifactDir}/retained-history-return.png`, + fullPage: true, + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps rejected pre-ACK sends visible and restores the draft", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "policy should not eat this"; + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + + await gateway.rejectDeferred("chat.send", { + code: "INVALID_REQUEST", + message: "send blocked by session policy", + }); + + await page.locator(".chat-queue").getByText("Failed").waitFor({ timeout: 10_000 }); + await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); + expect(await composer.inputValue()).toBe(prompt); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("parks an ACK-lost send for review before a same-key manual retry", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "chat.history": { + messages: [], + sessionId: "control-ui-e2e-session", + sessionInfo: { hasActiveRun: false, status: "done" }, + thinkingLevel: null, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "retry with the same key"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const firstRequest = await gateway.waitForRequest("chat.send"); + const firstParams = requireRecord(firstRequest.params); + const runId = requireString(firstParams.idempotencyKey, "first idempotency key"); + + await gateway.closeLatest(1006, "lost ack"); + + const queue = page.locator(".chat-queue"); + await queue.getByText("Needs review").waitFor({ timeout: 10_000 }); + expect(await gateway.getRequests("chat.send")).toHaveLength(1); + await queue.locator(".chat-queue__retry").click(); + + const sends = await waitForRequests(gateway, "chat.send", 2); + const secondParams = requireRecord(sends[1]?.params); + expect(secondParams.idempotencyKey).toBe(runId); + expect(secondParams.message).toBe(prompt); + await queue.waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("stores new input while offline and sends it after reconnect", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } + : {}), + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "chat.history": { + messages: [], + sessionId: "control-ui-e2e-session", + sessionInfo: { hasActiveRun: false, status: "done" }, + thinkingLevel: null, + }, + }, + }); + + try { + await page.goto(controlUiSessionUrl(suite.server.baseUrl, "global")); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + + await gateway.setOnline(false); + await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 }); + + const prompt = "send this when the Gateway returns"; + const attachmentName = "offline-proof.txt"; + const attachmentMimeType = "text/plain"; + const attachmentText = "offline attachment proof"; + const attachmentBase64 = Buffer.from(attachmentText).toString("base64"); + const attachmentDataUrl = `data:${attachmentMimeType};base64,${attachmentBase64}`; + const composerEnabled = await composer.isEnabled(); + expect(composerEnabled).toBe(true); + await composer.fill(prompt); + await page.locator(".agent-chat__file-input").setInputFiles({ + name: attachmentName, + mimeType: attachmentMimeType, + buffer: Buffer.from(attachmentText), + }); + await page.locator(".chat-attachment-file__name", { hasText: attachmentName }).waitFor({ + timeout: 10_000, + }); + const send = page.getByRole("button", { name: "Send message" }); + const sendEnabled = await send.isEnabled(); + expect(sendEnabled).toBe(true); + await send.click(); + + const queue = page.locator(".chat-queue"); + await queue.getByText("Waiting for reconnect").waitFor({ timeout: 10_000 }); + await queue.getByText(prompt).waitFor({ timeout: 10_000 }); + const requestsBeforeReconnect = await gateway.getRequests("chat.send"); + expect(requestsBeforeReconnect).toHaveLength(0); + const readStoredProof = () => + page.evaluate( + ({ expectedAttachmentName, expectedAttachmentDataUrl, expectedPrompt }) => { + const storedValues = Object.entries(sessionStorage) + .filter(([key]) => key.startsWith("openclaw.control.chatComposer.v2:")) + .map(([, value]) => value); + const stored = storedValues.join("\n"); + let runId: string | null = null; + for (const value of storedValues) { + try { + const parsed = JSON.parse(value) as { + sessions?: Record< + string, + { + queue?: Array<{ + attachments?: Array<{ dataUrl?: unknown; fileName?: unknown }>; + sendRunId?: unknown; + text?: unknown; + }>; + } + >; + }; + const item = Object.values(parsed.sessions ?? {}) + .flatMap((session) => session.queue ?? []) + .find((entry) => entry.text === expectedPrompt); + if (typeof item?.sendRunId === "string") { + runId = item.sendRunId; + const attachment = item.attachments?.find( + (entry) => entry.fileName === expectedAttachmentName, + ); + return { + attachment: attachment?.dataUrl === expectedAttachmentDataUrl, + prompt: true, + runId, + waitingReconnect: value.includes('"sendState":"waiting-reconnect"'), + }; + } + } catch { + // Ignore unrelated malformed session storage in this focused proof. + } + } + return { + attachment: false, + prompt: stored.includes(expectedPrompt), + runId, + waitingReconnect: stored.includes('"sendState":"waiting-reconnect"'), + }; + }, + { + expectedAttachmentDataUrl: attachmentDataUrl, + expectedAttachmentName: attachmentName, + expectedPrompt: prompt, + }, + ); + await expect.poll(readStoredProof).toEqual({ + attachment: true, + prompt: true, + runId: expect.any(String), + waitingReconnect: true, + }); + const storedProof = await readStoredProof(); + const storedRunId = requireString(storedProof.runId, "stored offline send idempotency key"); + if (artifactDir) { + await page.screenshot({ path: `${artifactDir}/01-offline-queued.png`, fullPage: true }); + } + + await page.reload(); + await expect.poll(readStoredProof).toEqual({ + attachment: true, + prompt: true, + runId: storedRunId, + waitingReconnect: true, + }); + // A cold reload waits for initial Gateway bootstrap before rebuilding the + // UI. Storage survival here plus replay below is the reload contract. + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + await gateway.setOnline(true); + await page.locator("openclaw-chat-pane").waitFor({ state: "attached", timeout: 10_000 }); + + const request = await gateway.waitForRequest("chat.send"); + const params = requireRecord(request.params); + const runId = requireString(params.idempotencyKey, "offline send idempotency key"); + expect(params.message).toBe(prompt); + expect(runId).toBe(storedRunId); + expect(params.attachments).toEqual([ + { + content: attachmentBase64, + fileName: attachmentName, + mimeType: attachmentMimeType, + type: "file", + }, + ]); + await queue.getByText("Needs review").waitFor({ timeout: 10_000 }); + await queue + .getByText("Delivery could not be confirmed after reconnect.", { exact: false }) + .waitFor({ timeout: 10_000 }); + if (artifactDir) { + await page.screenshot({ path: `${artifactDir}/02-reconnected-review.png`, fullPage: true }); + } + await expectRequestCountStable(gateway, "chat.send", 1); + const requestsAfterReconnect = await gateway.getRequests("chat.send"); + await gateway.setHistoryMessages([ + { + role: "user", + __openclaw: { idempotencyKey: `${runId}:user` }, + }, + ]); + await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." }); + await queue.waitFor({ state: "detached", timeout: 10_000 }); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await expect + .poll(async () => { + const proof = await readStoredProof(); + return proof.attachment || proof.prompt || proof.runId === runId; + }) + .toBe(false); + await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" }); + await expectRequestCountStable(gateway, "chat.send", 1); + if (artifactDir) { + await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true }); + } + if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { + process.stdout.write( + `${JSON.stringify({ + proof: "offline-chat-reconnect", + composerEnabled, + sendEnabled, + waitingStateVisible: true, + storedPrompt: storedProof.prompt, + storedWaitingState: storedProof.waitingReconnect, + requestsBeforeReconnect: requestsBeforeReconnect.length, + requestsAfterReconnect: requestsAfterReconnect.length, + idempotencyKeyPresent: runId.length > 0, + queueClearedAfterDelivery: true, + })}\n`, + ); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.media-files.e2e.test.ts b/ui/src/e2e/chat-flow.media-files.e2e.test.ts new file mode 100644 index 000000000000..072562cf41e8 --- /dev/null +++ b/ui/src/e2e/chat-flow.media-files.e2e.test.ts @@ -0,0 +1,716 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { + captureUiProofEnabled, + copiedViaExec, + createChatFlowE2eSuite, + expectDefined, + installMockGateway, + installPlainHttpClipboardCapture, + managedImageCacheProofDir, + waitForChatScrollIdle, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("downloads an assistant document with the server-provided Unicode filename", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const source = "/tmp/openclaw/测试 report.pdf"; + const mediaUrl = `/__openclaw__/assistant-media?source=${encodeURIComponent(source)}&mediaTicket=ticket-download`; + const requestedUrls: URL[] = []; + // The document opens in a new tab, so intercept at the context boundary. + await context.route("**/__openclaw__/assistant-media?**", async (route) => { + const url = new URL(route.request().url()); + requestedUrls.push(url); + await route.fulfill({ + body: "%PDF-1.4\n", + contentType: "application/pdf", + headers: { + "Content-Disposition": `attachment; filename="__ report.pdf"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%20report.pdf`, + }, + }); + }); + await installMockGateway(page, { + historyMessages: [ + { + role: "assistant", + content: [ + { type: "text", text: "Your report is ready." }, + { + type: "attachment", + attachment: { + kind: "document", + label: "测试 report.pdf", + mimeType: "application/pdf", + url: mediaUrl, + }, + }, + ], + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const link = page.getByRole("link", { name: "测试 report.pdf" }); + await link.waitFor({ state: "visible", timeout: 10_000 }); + const [download] = await Promise.all([page.waitForEvent("download"), link.click()]); + + expect(download.suggestedFilename()).toBe("测试 report.pdf"); + expect(requestedUrls).toHaveLength(1); + expect(requestedUrls[0]?.searchParams.get("source")).toBe(source); + expect(requestedUrls[0]?.searchParams.get("mediaTicket")).toBe("ticket-download"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("renders a direct tool-result image from Gateway history", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const imageData = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X3q8AAAAAElFTkSuQmCC"; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [ + { alt: "Tool result preview", data: imageData, mimeType: "image/png", type: "image" }, + ], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const image = page.getByAltText("Tool result preview"); + await image.waitFor({ state: "visible", timeout: 10_000 }); + expect(await image.getAttribute("src")).toBe(`data:image/png;base64,${imageData}`); + await gateway.waitForRequest("chat.startup"); + await expect + .poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth)) + .toBe(1); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("renders a canonical inbound image through the ticketed media route", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const requestedMediaUrls: URL[] = []; + await page.route("**/__openclaw__/assistant-media?**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + requestedMediaUrls.push(url); + expect(url.searchParams.get("source")).toBe("media://inbound/telegram-photo.png"); + if (url.searchParams.get("meta") === "1") { + expect(request.headers().authorization).toBe("Bearer e2e-device-token"); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + available: true, + mediaTicket: "ticket-inbound", + mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }), + }); + return; + } + expect(url.searchParams.get("mediaTicket")).toBe("ticket-inbound"); + await route.fulfill({ + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=", + "base64", + ), + }); + }); + await installMockGateway(page, { + historyMessages: [ + { + id: "user-inbound-media-ref", + role: "user", + content: [{ type: "text", text: "🖼️ Attached image" }], + __openclaw: { + media: [{ path: "media://inbound/telegram-photo.png", contentType: "image/png" }], + }, + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(2); + const image = page.locator("img.chat-message-image"); + await image.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => + image.evaluate((element) => + element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0, + ), + ) + .toBe(1); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + fullPage: true, + path: `${artifactDir}/canonical-inbound-image.png`, + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("evicts and refetches managed image Blob URLs after the cache reaches capacity", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + const originalCreateObjectURL = URL.createObjectURL.bind(URL); + const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL); + const proof = { created: [] as string[], revoked: [] as string[] }; + Object.defineProperty(globalThis, "managedImageCacheProof", { + configurable: true, + value: proof, + }); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: (blob: Blob) => { + const blobUrl = originalCreateObjectURL(blob); + proof.created.push(blobUrl); + return blobUrl; + }, + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: (blobUrl: string) => { + proof.revoked.push(blobUrl); + originalRevokeObjectURL(blobUrl); + }, + }); + }); + + const imageUrls = Array.from({ length: 65 }, (_, index) => { + const id = String(index + 1).padStart(12, "0"); + return `/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-${id}/full`; + }); + const fetchedMedia: Array<{ + authorization: string | undefined; + pathname: string; + requesterSessionKey: string | undefined; + }> = []; + await page.route("**/api/chat/media/outgoing/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + fetchedMedia.push({ + authorization: request.headers().authorization, + pathname: url.pathname, + requesterSessionKey: request.headers()["x-openclaw-requester-session-key"], + }); + await route.fulfill({ + body: 'managed preview', + contentType: "image/svg+xml", + }); + }); + + const historyFor = (indexes: number[], labelPrefix: string) => [ + { + content: indexes.map((index) => ({ + alt: `${labelPrefix} ${index + 1}`, + type: "image", + url: imageUrls[index], + })), + role: "assistant", + timestamp: Date.now(), + }, + ]; + const gateway = await installMockGateway(page, { + historyMessages: historyFor( + Array.from({ length: 64 }, (_, index) => index), + "Initial managed image", + ), + }); + const readBlobProof = () => + page.evaluate(() => { + const proof = ( + globalThis as typeof globalThis & { + managedImageCacheProof: { created: string[]; revoked: string[] }; + } + ).managedImageCacheProof; + return { created: [...proof.created], revoked: [...proof.revoked] }; + }); + let proofMessageSequence = 100; + const replaceHistory = async (messages: unknown[], visibleAlt: string) => { + const historyRequestsBefore = (await gateway.getRequests("chat.history")).length; + await gateway.setHistoryMessages(messages); + proofMessageSequence += 1; + await gateway.emitGatewayEvent("session.message", { + activeRunIds: [], + hasActiveRun: false, + message: messages[0], + messageId: `managed-image-cache-proof-${proofMessageSequence}`, + messageSeq: proofMessageSequence, + session: { + activeRunIds: [], + hasActiveRun: false, + key: "main", + kind: "direct", + status: "done", + updatedAt: Date.now(), + }, + sessionKey: "main", + }); + await expect + .poll(async () => (await gateway.getRequests("chat.history")).length, { + timeout: 15_000, + }) + .toBeGreaterThan(historyRequestsBefore); + await page.getByAltText(visibleAlt).waitFor({ state: "visible", timeout: 10_000 }); + }; + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + await expect.poll(async () => (await readBlobProof()).created.length).toBe(64); + await expect + .poll(() => + page + .locator("img.chat-message-image") + .evaluateAll( + (images) => + images.filter( + (image) => + image instanceof HTMLImageElement && + image.complete && + image.naturalWidth === 160, + ).length, + ), + ) + .toBe(64); + + await replaceHistory( + historyFor([0], "Recently viewed managed image"), + "Recently viewed managed image 1", + ); + expect((await readBlobProof()).created).toHaveLength(64); + + await replaceHistory(historyFor([64], "Overflow managed image"), "Overflow managed image 65"); + await expect.poll(async () => (await readBlobProof()).created.length).toBe(65); + const overflowProof = await readBlobProof(); + const retainedRecentBlobUrl = expectDefined( + overflowProof.created[0], + "recent managed image Blob URL", + ); + const evictedBlobUrl = expectDefined( + overflowProof.created[1], + "evicted managed image Blob URL", + ); + expect(overflowProof.revoked).toContain(evictedBlobUrl); + expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl); + + const evictedPath = new URL( + expectDefined(imageUrls[1], "evicted managed image URL"), + suite.server.baseUrl, + ).pathname; + const fetchesBeforeRevisit = fetchedMedia.filter( + (request) => request.pathname === evictedPath, + ).length; + await replaceHistory(historyFor([1], "Refetched managed image"), "Refetched managed image 2"); + const revisitedImage = page.getByAltText("Refetched managed image 2"); + await expect + .poll(() => + revisitedImage.evaluate((image) => + image instanceof HTMLImageElement && image.complete ? image.naturalWidth : 0, + ), + ) + .toBe(160); + await expect.poll(async () => (await readBlobProof()).created.length).toBe(66); + const finalProof = await readBlobProof(); + const evictedImageFetches = fetchedMedia.filter( + (request) => request.pathname === evictedPath, + ).length; + expect(evictedImageFetches).toBe(fetchesBeforeRevisit + 1); + expect(fetchedMedia).not.toHaveLength(0); + expect( + fetchedMedia.every((request) => request.authorization === "Bearer e2e-device-token"), + ).toBe(true); + expect( + fetchedMedia.every((request) => request.requesterSessionKey === "agent:main:main"), + ).toBe(true); + + const proofSummary = { + cacheCapacity: 64, + createdBlobUrls: finalProof.created.length, + evictedBlobIndex: 1, + evictedImageFetches, + refetchedImageNaturalWidth: await revisitedImage.evaluate( + (image) => (image as HTMLImageElement).naturalWidth, + ), + retainedRecentBlobRevoked: finalProof.revoked.includes(retainedRecentBlobUrl), + revokedBlobUrls: finalProof.revoked.length, + }; + if (captureUiProofEnabled) { + await mkdir(managedImageCacheProofDir, { recursive: true }); + await page.evaluate((summary) => { + const panel = document.createElement("pre"); + panel.setAttribute("data-managed-image-cache-proof", "true"); + panel.style.cssText = + "position:fixed;right:16px;bottom:16px;z-index:99999;max-width:460px;padding:16px;border:2px solid #5eead4;border-radius:10px;background:#0f172a;color:#ccfbf1;font:14px/1.45 monospace;white-space:pre-wrap"; + panel.textContent = `Managed image cache browser proof\n${JSON.stringify(summary, null, 2)}`; + document.body.append(panel); + }, proofSummary); + await page.screenshot({ + fullPage: true, + path: path.join(managedImageCacheProofDir, "after-refetch.png"), + }); + await writeFile( + path.join(managedImageCacheProofDir, "after-refetch.json"), + `${JSON.stringify(proofSummary, null, 2)}\n`, + "utf8", + ); + } + if (process.env.OPENCLAW_BEHAVIOR_PROOF === "1") { + process.stdout.write( + `${JSON.stringify({ proof: "managed-image-cache", ...proofSummary })}\n`, + ); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("copies a code block over a non-secure context via the execCommand fallback", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + // Simulate a plain-HTTP deployment where navigator.clipboard is unavailable. + await installPlainHttpClipboardCapture(page); + const code = "const hello = 1;"; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [ + { + text: `${"long response line\n\n".repeat(80)}\`\`\`js\n${code}\n\`\`\``, + type: "text", + }, + ], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const copyButton = page.locator(".code-block-copy").first(); + await copyButton.waitFor({ timeout: 10_000 }); + await waitForChatScrollIdle(page); + await copyButton.evaluate((element) => element.scrollIntoView({ block: "center" })); + const thread = page.locator(".chat-thread"); + const scrollTopBefore = await thread.evaluate((element) => + Math.round((element as HTMLElement).scrollTop), + ); + // The copied class clears after 1500ms, so click and read it in one browser step. + const copied = await copyButton.evaluate(async (element) => { + const button = element as HTMLButtonElement; + const owner = element.closest("openclaw-chat-pane") as + | (HTMLElement & { + updateComplete: Promise; + }) + | null; + if (!owner) { + throw new Error("Chat pane owner is unavailable"); + } + let copyObserver: MutationObserver | undefined; + const copySettled = new Promise((resolve) => { + copyObserver = new MutationObserver(() => { + if (button.classList.contains("copied")) { + copyObserver?.disconnect(); + resolve(); + } + }); + copyObserver.observe(button, { attributeFilter: ["class"], attributes: true }); + }); + button.click(); + await owner.updateComplete; + if (!button.classList.contains("copied")) { + await copySettled; + } + copyObserver?.disconnect(); + return button.classList.contains("copied"); + }); + expect(copied).toBe(true); + expect(await copiedViaExec(page)).toContain(code); + await expect + .poll(() => thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop))) + .toBe(scrollTopBefore); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("copies a workspace file path over a non-secure context via the execCommand fallback", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installPlainHttpClipboardCapture(page); + const gateway = await installMockGateway(page, { + methodResponses: { + "artifacts.list": { artifacts: [] }, + "sessions.files.list": { + browser: { entries: [], path: "" }, + files: [ + { + kind: "modified", + missing: false, + name: "AGENTS.md", + path: "/workspace/AGENTS.md", + size: 2048, + }, + ], + root: "/workspace", + sessionKey: "main", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.locator(".chat-workspace-toggle").click(); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); + + await page.getByRole("button", { name: "Copy path" }).click(); + + expect(await copiedViaExec(page)).toContain("/workspace/AGENTS.md"); + expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("starts the workspace files panel collapsed and toggles it open", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "artifacts.list": { + artifacts: [ + { + download: { mode: "bytes" }, + id: "artifact-1", + mimeType: "image/png", + sizeBytes: 128, + title: "preview.png", + type: "image", + }, + ], + }, + "sessions.files.list": { + browser: { + entries: [ + { + kind: "directory", + name: "src", + path: "src", + sessionKind: "modified", + }, + { + kind: "file", + name: "package.json", + path: "package.json", + size: 4096, + }, + ], + path: "", + }, + files: [ + { + kind: "modified", + missing: false, + name: "AGENTS.md", + path: "/workspace/AGENTS.md", + size: 2048, + }, + ], + root: "/workspace", + sessionKey: "main", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + // Collapsed rails render nothing; the title-bar toggle carries the + // changed-file badge. + const opener = page.locator(".chat-workspace-toggle"); + await opener.waitFor({ timeout: 10_000 }); + expect(await gateway.getRequests("sessions.files.list")).toHaveLength(0); + expect(await page.locator(".chat-workspace-rail").count()).toBe(0); + + await opener.click(); + await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 }); + await expect.poll(() => opener.getAttribute("aria-expanded")).toBe("true"); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); + await page + .locator(".chat-workspace-rail__file-name", { hasText: "preview.png" }) + .waitFor({ timeout: 10_000 }); + await page.getByText("Project files").waitFor({ timeout: 10_000 }); + await page.locator(".chat-workspace-rail__file-name", { hasText: "package.json" }).waitFor({ + timeout: 10_000, + }); + expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); + expect(await gateway.getRequests("artifacts.list")).toHaveLength(1); + // The rail docks flush to the window edge (no content gutter). + expect( + await page.locator(".chat-workspace-rail").evaluate((element) => { + return window.innerWidth - element.getBoundingClientRect().right; + }), + ).toBe(0); + + await page.locator(".chat-workspace-rail__collapse-toggle").click(); + await opener.waitFor({ timeout: 10_000 }); + expect(await page.locator(".chat-workspace-rail").count()).toBe(0); + + await opener.click(); + await page.locator(".chat-workspace-rail__collapse-toggle").waitFor({ timeout: 10_000 }); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); + expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); + + await page.setViewportSize({ height: 900, width: 760 }); + const workbench = page.locator(".chat-workbench"); + await expect + .poll(() => workbench.getAttribute("class")) + .toContain("chat-workbench--dock-bottom"); + const workspaceRail = page.locator(".chat-workspace-rail"); + await expect + .poll(async () => { + const box = await workspaceRail.boundingBox(); + return Boolean(box && box.width > 0 && box.height > 0); + }) + .toBe(true); + expect(await page.locator(".chat-workspace-rail__dock").count()).toBe(0); + expect(await page.locator(".chat-workspace-rail__grip").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps long workspace file sections scrollable inside the rail", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 720, width: 1280 }, + }); + const page = await context.newPage(); + const browserEntries = Array.from({ length: 60 }, (_, index) => ({ + kind: "file" as const, + name: `file-${String(index + 1).padStart(2, "0")}.ts`, + path: `src/file-${String(index + 1).padStart(2, "0")}.ts`, + size: 2048 + index, + })); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.files.list": { + browser: { + entries: browserEntries, + path: "", + }, + files: [], + root: "/workspace", + sessionKey: "main", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.locator(".chat-workspace-toggle").click(); + await page.locator(".chat-workspace-rail__file-name", { hasText: "file-60.ts" }).waitFor({ + timeout: 10_000, + }); + expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); + + const browserSection = page.locator(".chat-workspace-rail__section", { + hasText: "Project files", + }); + await expect + .poll( + () => + browserSection.evaluate((section) => { + const element = section as HTMLElement; + const scroll = element.closest(".chat-workspace-rail__scroll") as HTMLElement | null; + if (!scroll) { + throw new Error("Expected workspace rail scroll container"); + } + const sectionRect = element.getBoundingClientRect(); + const scrollRect = scroll.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + bottomWithinRail: Math.ceil(sectionRect.bottom) <= Math.ceil(scrollRect.bottom), + clientHeight: element.clientHeight, + overflowY: style.overflowY, + scrollHeight: element.scrollHeight, + }; + }), + { timeout: 10_000 }, + ) + .toMatchObject({ + bottomWithinRail: true, + overflowY: "auto", + }); + const sectionMetrics = await browserSection.evaluate((section) => { + const element = section as HTMLElement; + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }); + expect(sectionMetrics.scrollHeight).toBeGreaterThan(sectionMetrics.clientHeight); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.messaging.e2e.test.ts b/ui/src/e2e/chat-flow.messaging.e2e.test.ts new file mode 100644 index 000000000000..b30625cd6e42 --- /dev/null +++ b/ui/src/e2e/chat-flow.messaging.e2e.test.ts @@ -0,0 +1,594 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { + captureUiProofEnabled, + channelStopProofDir, + chatSessionListResponse, + controlUiSessionUrl, + createChatFlowE2eSuite, + expectRequestCountStable, + installMockGateway, + requireRecord, + requireString, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("sends a chat turn through the GUI and renders the final Gateway event", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Ready for an end-to-end GUI check.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Ready for an end-to-end GUI check.").waitFor({ timeout: 10_000 }); + + const prompt = "verify the control UI e2e harness"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + expect(params.sessionKey).toBe("main"); + expect(params.message).toBe(prompt); + expect(params.deliver).toBe(false); + + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + await gateway.emitChatFinal({ runId, text: "Harness verified." }); + + await page + .locator(".chat-thread-inner") + .getByText("Harness verified.") + .waitFor({ timeout: 10_000 }); + + const spacedPairCommand = "/ pair qr"; + await page.locator(".agent-chat__composer-combobox textarea").fill(spacedPairCommand); + await page.getByRole("button", { name: "Send message" }).click(); + + const commandRequests = await waitForRequests(gateway, "chat.send", 2); + const commandParams = requireRecord(commandRequests[1]?.params); + expect(commandParams.message).toBe(spacedPairCommand); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps a browser-local prompt before a clock-skewed Gateway reply", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { historyMessages: [] }); + const prompt = "verify clock-skewed chat order"; + const partial = "The Gateway is replying from an earlier clock."; + const reply = "The Gateway reply stayed below its prompt."; + const appearsBefore = (lowerSelector: string, lowerText: string) => + page.locator(".chat-thread-inner").evaluate( + (thread: Element, texts: { lowerSelector: string; lowerText: string; prompt: string }) => { + const findByText = (selector: string, text: string) => + Array.from(thread.querySelectorAll(selector)).find((row) => + (row.textContent ?? "").includes(text), + ); + const promptRow = findByText(".chat-group.user", texts.prompt); + const lowerRow = findByText(texts.lowerSelector, texts.lowerText); + if (!promptRow || !lowerRow) { + return false; + } + return promptRow.getBoundingClientRect().top < lowerRow.getBoundingClientRect().top; + }, + { lowerSelector, lowerText, prompt }, + ); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const runId = requireString( + requireRecord(sendRequest.params).idempotencyKey, + "chat send idempotency key", + ); + const browserTimestamp = await page.evaluate(() => Date.now()); + const gatewayTimestamp = browserTimestamp - 60_000; + await gateway.emitGatewayEvent("chat", { + deltaText: partial, + message: { + content: [{ text: partial, type: "text" }], + role: "assistant", + timestamp: gatewayTimestamp, + }, + runId, + sessionKey: "main", + state: "delta", + }); + await page.locator(".chat-bubble.streaming", { hasText: partial }).waitFor({ + timeout: 10_000, + }); + expect(await appearsBefore(".chat-bubble.streaming", partial)).toBe(true); + + await gateway.emitGatewayEvent("chat", { + message: { + content: [{ text: reply, type: "text" }], + role: "assistant", + timestamp: gatewayTimestamp, + }, + runId, + sessionKey: "main", + state: "final", + }); + await page.locator(".chat-group.assistant .chat-text", { hasText: reply }).waitFor({ + timeout: 10_000, + }); + expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + expect(await appearsBefore(".chat-group.assistant", reply)).toBe(true); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("reconciles authoritative history before a trailing final by run identity", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { historyMessages: [] }); + try { + await page.goto(`${suite.server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill("reconcile the terminal event ordering"); + await page.getByRole("button", { name: "Send message" }).click(); + const send = await gateway.waitForRequest("chat.send"); + const runId = requireString( + requireRecord(send.params).idempotencyKey, + "chat send idempotency key", + ); + const finalText = "One authoritative final response."; + const messageId = "assistant-authoritative-final"; + const authoritative = { + __openclaw: { id: messageId, seq: 2 }, + content: [{ text: finalText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }; + await gateway.emitGatewayEvent("chat", { + deltaText: finalText, + message: { + content: [{ text: finalText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + await page.locator(".chat-bubble.streaming", { hasText: finalText }).waitFor(); + await gateway.setHistoryMessages([ + { + __openclaw: { id: "user-reconcile", seq: 1 }, + content: [{ text: "reconcile the terminal event ordering", type: "text" }], + role: "user", + timestamp: Date.now() - 1, + }, + authoritative, + ]); + const historyRequestsBefore = (await gateway.getRequests("chat.history")).length; + await gateway.emitGatewayEvent("session.message", { + activeRunIds: [], + clientRunId: runId, + hasActiveRun: false, + message: authoritative, + messageId, + messageSeq: 2, + session: { + activeRunIds: [], + hasActiveRun: false, + key: "main", + kind: "direct", + status: "done", + updatedAt: Date.now(), + }, + sessionKey: "main", + }); + await expect + .poll(async () => (await gateway.getRequests("chat.history")).length) + .toBeGreaterThan(historyRequestsBefore); + await page.locator(".chat-thread-inner").getByText(finalText, { exact: true }).waitFor(); + await expect + .poll(() => + page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), + ) + .toBe(1); + + await gateway.emitChatFinal({ runId, text: finalText }); + await expect + .poll(() => page.locator(".chat-group.assistant .chat-duplicate-count").count()) + .toBe(0); + await expect + .poll(() => + page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), + ) + .toBe(1); + + await gateway.emitChatFinal({ runId: "a-different-legitimate-run", text: finalText }); + await expect + .poll(() => + page.locator(".chat-group.assistant .chat-text", { hasText: finalText }).count(), + ) + .toBe(2); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("restores the selected session transcript after a hard reload", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const historyText = "Transcript survives a hard reload."; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: historyText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + sessionKey: "agent:main:main", + }); + + try { + await page.goto(controlUiSessionUrl(suite.server.baseUrl, "main")); + await page.getByText(historyText).waitFor({ timeout: 10_000 }); + await gateway.waitForRequest("chat.startup"); + + await page.reload(); + + await page.getByText(historyText).waitFor({ timeout: 10_000 }); + await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(2); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("sends idle stop aliases as ordinary chat messages", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + await composer.fill("wait"); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(sendRequest.params).message).toBe("wait"); + expect(await gateway.getRequests("chat.abort")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("sends /stop to the exact selected channel session and clears its working indicator", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const channelSessionKey = "agent:main:openclaw-weixin:direct:wechat-user"; + const gateway = await installMockGateway(page, { + sessionKey: channelSessionKey, + methodResponses: { + "sessions.abort": { abortedRunId: null, ok: true, status: "aborted" }, + "sessions.list": chatSessionListResponse([ + { + hasActiveRun: true, + key: channelSessionKey, + kind: "direct", + label: "WeChat user", + status: "running", + updatedAt: Date.now(), + }, + ]), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + await gateway.waitForRequest("sessions.list"); + const workingIndicator = page.locator(".chat-working-indicator"); + await workingIndicator.waitFor({ state: "visible", timeout: 10_000 }); + + await composer.fill("/stop"); + await page.getByRole("option", { name: /\/stop/ }).waitFor(); + await composer.press("Enter"); + + const abortRequest = await gateway.waitForRequest("sessions.abort"); + expect(requireRecord(abortRequest.params)).toEqual({ + key: channelSessionKey, + clearQueued: true, + }); + await gateway.setMethodResponse( + "sessions.list", + chatSessionListResponse([ + { + activeRunIds: [], + hasActiveRun: false, + key: channelSessionKey, + kind: "direct", + label: "WeChat user", + status: "running", + updatedAt: Date.now(), + }, + ]), + ); + await gateway.emitGatewayEvent("sessions.changed", { + activeRunIds: [], + hasActiveRun: false, + reason: "abort", + sessionKey: channelSessionKey, + status: "running", + updatedAt: Date.now(), + }); + await workingIndicator.waitFor({ state: "detached", timeout: 10_000 }); + await expectRequestCountStable(gateway, "chat.abort", 0); + await expectRequestCountStable(gateway, "chat.send", 0); + await expect.poll(() => page.getByRole("listbox").count()).toBe(0); + expect(await composer.inputValue()).toBe(""); + if (captureUiProofEnabled) { + await mkdir(channelStopProofDir, { recursive: true }); + await page.screenshot({ + path: path.join(channelStopProofDir, "stopped.png"), + fullPage: true, + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("persists the chat send shortcut and keeps multiline and IME input safe", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + + await composer.fill("default enter send"); + await composer.press("Enter"); + const defaultRequest = await gateway.waitForRequest("chat.send"); + const defaultParams = requireRecord(defaultRequest.params); + expect(defaultParams.message).toBe("default enter send"); + await gateway.emitChatFinal({ + runId: requireString(defaultParams.idempotencyKey, "default send idempotency key"), + text: "Default shortcut received.", + }); + await page + .locator(".chat-thread-inner") + .getByText("Default shortcut received.") + .waitFor({ timeout: 10_000 }); + + // The send shortcut moved to the Settings appearance page; picking it + // there must apply to the chat composer after navigating back. + await page.goto(`${suite.server.baseUrl}settings/appearance`); + const shortcutSelect = page.locator("[data-settings-send-shortcut]"); + await shortcutSelect.selectOption("modifier-enter"); + expect(await shortcutSelect.inputValue()).toBe("modifier-enter"); + + await page.goto(`${suite.server.baseUrl}chat`); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + expect(await composer.getAttribute("aria-keyshortcuts")).toBe("Control+Enter Meta+Enter"); + + await composer.fill("plain enter stays in the draft"); + await composer.press("Enter"); + expect(await composer.inputValue()).toContain("\n"); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + await composer.fill("composition must not send"); + await composer.dispatchEvent("compositionstart"); + await composer.press("Control+Enter"); + await composer.dispatchEvent("compositionend"); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + await composer.fill("modifier send"); + await composer.press("Meta+Enter"); + const modifierRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(modifierRequest.params).message).toBe("modifier send"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("steers an active run when the session row only reports hasActiveRun", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "main"; + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Active run is waiting for steering.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + methodResponses: { + "sessions.list": chatSessionListResponse([ + { + hasActiveRun: true, + key: "agent:main:main", + kind: "direct", + label: "Main", + updatedAt: Date.now(), + }, + ]), + }, + sessionKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Active run is waiting for steering.").waitFor({ timeout: 10_000 }); + await gateway.waitForRequest("sessions.list"); + await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 }); + + await page + .locator(".agent-chat__composer-combobox textarea") + .fill("/steer use the smaller fix"); + await page.getByRole("button", { name: "Send message" }).click(); + + const steerRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(steerRequest.params); + expect(params.sessionKey).toBe(sessionKey); + expect(params.message).toBe("use the smaller fix"); + expect(params.deliver).toBe(false); + + await page.getByText("Steered.", { exact: true }).waitFor({ timeout: 10_000 }); + expect(await page.getByText("No active run").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps a targetless message-tool source reply beside the automatic final reply", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const prompt = "send progress through the message tool and then finish"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + expect(params).toMatchObject({ sessionKey: "main", message: prompt, deliver: false }); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + + await gateway.emitChatFinal({ + runId, + text: "Visible progress from the targetless message tool.", + }); + await page + .locator(".chat-thread-inner") + .getByText("Visible progress from the targetless message tool.") + .waitFor({ timeout: 10_000 }); + + await gateway.emitChatFinal({ runId, text: "Visible automatic final reply." }); + await page + .locator(".chat-thread-inner") + .getByText("Visible automatic final reply.") + .waitFor({ timeout: 10_000 }); + const bubbleTexts = await page.locator(".chat-thread .chat-bubble").allTextContents(); + for (const expectedText of [ + prompt, + "Visible progress from the targetless message tool.", + "Visible automatic final reply.", + ]) { + expect(bubbleTexts.some((text) => text.includes(expectedText))).toBe(true); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps the composer clear when a stale native input replay arrives after send", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Ready for stale replay check.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 }); + + const prompt = "submitted message"; + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await gateway.waitForRequest("chat.send"); + expect(await composer.inputValue()).toBe(""); + + const afterReplay = await composer.evaluate((element, submitted) => { + const textarea = element as HTMLTextAreaElement; + textarea.value = submitted; + textarea.dispatchEvent( + new InputEvent("input", { + bubbles: true, + data: submitted, + inputType: "insertText", + }), + ); + return textarea.value; + }, prompt); + + expect(afterReplay).toBe(""); + expect(await composer.inputValue()).toBe(""); + + await composer.pressSequentially(prompt); + expect(await composer.inputValue()).toBe(prompt); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts new file mode 100644 index 000000000000..d5af9869e4be --- /dev/null +++ b/ui/src/e2e/chat-flow.models-reasoning.e2e.test.ts @@ -0,0 +1,465 @@ +import { expect, it } from "vitest"; +import { + chatSessionListResponse, + createChatFlowE2eSuite, + expectRequestCountStable, + installMockGateway, + requireRecord, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("routes runtime-aware model commands through the server directive path", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const command = "/model openai/gpt-5.6-luna --runtime codex continue with the selected model"; + await page.locator(".agent-chat__composer-combobox textarea").fill(command); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(sendRequest.params)).toMatchObject({ + message: command, + sessionKey: "agent:main:main", + }); + expect(await gateway.getRequests("sessions.patch")).toHaveLength(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps a session model override selected after switching away and back", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": chatSessionListResponse(), + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" }, + ], + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const main = page.getByRole("main"); + const openModelSelect = async () => { + const trigger = main.locator('[data-chat-model-select="true"]').first(); + await trigger.waitFor({ state: "visible", timeout: 10_000 }); + return trigger; + }; + const selectModel = async (value: string) => { + await main.locator('[data-chat-model-select="true"]').click(); + const provider = value.split("/", 1)[0]; + await main.locator(`[data-chat-model-provider="${provider}"]`).click(); + const option = main.locator(`[data-chat-model-option="${value}"]`); + await option.waitFor({ state: "visible", timeout: 10_000 }); + await option.click(); + await page.keyboard.press("Escape"); + }; + + let modelSelect = await openModelSelect(); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); + + await selectModel("bedrock/claude-opus-4.5"); + const patchRequest = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(patchRequest.params)).toMatchObject({ + key: "agent:main:session-a", + model: "bedrock/claude-opus-4.5", + }); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe( + "bedrock/claude-opus-4.5", + ); + + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ) + .click(); + await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({ + timeout: 10_000, + }); + modelSelect = await openModelSelect(); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); + + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-a"] a.sidebar-recent-session__link', + ) + .click(); + await page.locator(".sidebar-recent-session--active").getByText("Session A").waitFor({ + timeout: 10_000, + }); + + modelSelect = await openModelSelect(); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe( + "bedrock/claude-opus-4.5", + ); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("restores the selected agent model after clearing a session override", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const agentsList = { + agents: [ + { + id: "ops", + model: { primary: "anthropic/claude-opus-4-5" }, + name: "Operations", + }, + ], + defaultId: "ops", + mainKey: "main", + scope: "agent", + }; + const sessionsList = { + count: 1, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + key: "agent:ops:session-a", + kind: "direct", + label: "Operations", + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; + const gateway = await installMockGateway(page, { + assistantAgentId: "ops", + defaultAgentId: "ops", + methodResponses: { + "agents.list": agentsList, + "chat.startup": { + agentsList, + messages: [], + metadata: { + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "anthropic", + }, + ], + }, + sessionId: "control-ui-e2e-session", + thinkingLevel: null, + }, + "sessions.list": sessionsList, + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" }, + ], + sessionKey: "agent:ops:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const main = page.getByRole("main"); + const modelSelect = main.locator('[data-chat-model-select="true"]').first(); + await modelSelect.waitFor({ state: "visible", timeout: 10_000 }); + expect(await modelSelect.textContent()).toContain("Claude Opus 4.5"); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); + + await modelSelect.click(); + await main.locator('[data-chat-model-provider="openai"]').click(); + await main.locator('[data-chat-model-option="openai/gpt-5.5"]').click(); + const firstPatch = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(firstPatch.params)).toMatchObject({ + key: "agent:ops:session-a", + model: "openai/gpt-5.5", + }); + expect(await modelSelect.textContent()).toContain("GPT-5.5"); + + // The picker stays open after an immediate apply. Return to the default + // model's provider and select its real catalog row to clear the override. + await main.locator('[data-chat-model-provider="anthropic"]').click(); + const defaultModel = main.locator( + '[data-chat-model-option="anthropic/claude-opus-4-5"][data-chat-model-default="true"]', + ); + await defaultModel.waitFor({ state: "visible", timeout: 10_000 }); + expect(await defaultModel.textContent()).toContain("Default"); + expect(await main.locator('[data-chat-model-option=""]').count()).toBe(0); + await defaultModel.click(); + const patches = await waitForRequests(gateway, "sessions.patch", 2); + expect(requireRecord(patches[1]?.params)).toMatchObject({ + key: "agent:ops:session-a", + model: null, + }); + expect(await modelSelect.textContent()).toContain("Claude Opus 4.5"); + expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(""); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("shows a pending send while a model override update is still pending", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.patch"], + methodResponses: { + "sessions.list": chatSessionListResponse(), + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5", provider: "bedrock" }, + ], + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const main = page.getByRole("main"); + await main.locator('[data-chat-model-select="true"]').click(); + await main.locator('[data-chat-model-provider="bedrock"]').click(); + await main.locator('[data-chat-model-option="bedrock/claude-opus-4.5"]').click(); + await page.keyboard.press("Escape"); + await gateway.waitForRequest("sessions.patch"); + + const prompt = "send while the model save is pending"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({ + timeout: 10_000, + }); + await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + await gateway.resolveDeferred("sessions.patch", {}); + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + expect(params.message).toBe(prompt); + expect(params.sessionKey).toBe("agent:main:session-a"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("applies provider-specific reasoning after the selected model is saved", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:session-a"; + const fableSession = { + key: sessionKey, + kind: "direct", + label: "Session A", + model: "claude-fable-5", + modelProvider: "anthropic", + thinkingDefault: "high", + thinkingLevel: "high", + thinkingLevels: [ + { id: "off", label: "off" }, + { id: "adaptive", label: "adaptive" }, + { id: "high", label: "high" }, + { id: "xhigh", label: "xhigh" }, + { id: "max", label: "max" }, + ], + updatedAt: 2, + }; + const solSession = { + ...fableSession, + model: "gpt-5.6-sol", + modelProvider: "openai", + thinkingLevels: [ + { id: "off", label: "off" }, + { id: "low", label: "low" }, + { id: "medium", label: "medium" }, + { id: "high", label: "high" }, + { id: "ultra", label: "ultra" }, + ], + }; + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.patch"], + methodResponses: { + "sessions.list": chatSessionListResponse([fableSession]), + }, + models: [ + { id: "claude-fable-5", name: "Claude Fable 5", provider: "anthropic" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" }, + ], + sessionKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const main = page.getByRole("main"); + await main.locator('[data-chat-model-select="true"]').click(); + await main.locator('[data-chat-model-provider="openai"]').click(); + await main.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').click(); + + const modelPatch = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(modelPatch.params)).toMatchObject({ + key: sessionKey, + model: "openai/gpt-5.6-sol", + }); + const thinkingSlider = main.locator('[data-chat-thinking-slider="true"]'); + await expect.poll(() => thinkingSlider.isDisabled()).toBe(true); + await thinkingSlider.evaluate((input) => { + const slider = input as HTMLInputElement; + slider.value = slider.max; + slider.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expectRequestCountStable(gateway, "sessions.patch", 1); + + await gateway.setMethodResponse("sessions.list", chatSessionListResponse([solSession])); + await gateway.resolveDeferred("sessions.patch"); + await expect + .poll(() => thinkingSlider.getAttribute("data-chat-thinking-values")) + .toContain("ultra"); + await expect.poll(() => thinkingSlider.isEnabled()).toBe(true); + + await thinkingSlider.evaluate((input) => { + const slider = input as HTMLInputElement; + const values = (slider.dataset.chatThinkingValues ?? "").split(","); + slider.value = String(values.indexOf("ultra")); + slider.dispatchEvent(new Event("input", { bubbles: true })); + slider.dispatchEvent(new Event("change", { bubbles: true })); + }); + const patches = await waitForRequests(gateway, "sessions.patch", 2); + expect(requireRecord(patches[1]?.params)).toMatchObject({ + key: sessionKey, + thinkingLevel: "ultra", + }); + await expect.poll(() => page.getByText(/not supported for/u).count()).toBe(0); + + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await page.screenshot({ + path: `${artifactDir}/model-thinking-sync.png`, + fullPage: true, + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps send pending until reasoning and speed patches finish", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": { + ...chatSessionListResponse([ + { + effectiveFastMode: false, + fastMode: false, + key: "agent:main:session-a", + kind: "direct", + label: "Session A", + model: "gpt-5.5", + modelProvider: "openai", + thinkingLevel: "high", + updatedAt: 2, + }, + ]), + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + thinkingDefault: "high", + thinkingLevels: [ + { id: "off", label: "off" }, + { id: "low", label: "low" }, + { id: "medium", label: "medium" }, + { id: "high", label: "high" }, + ], + }, + }, + }, + models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }], + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const main = page.getByRole("main"); + await main.locator('[data-chat-model-select="true"]').click(); + await gateway.deferNext("sessions.patch"); + await main.locator('[data-chat-thinking-slider="true"]').press("ArrowLeft"); + const firstPatch = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(firstPatch.params).thinkingLevel).toBe("medium"); + + await gateway.deferNext("sessions.patch"); + await main.locator('[data-chat-speed-toggle="on"]').click(); + await expectRequestCountStable(gateway, "sessions.patch", 1); + await page.keyboard.press("Escape"); + + const prompt = "send with the new reasoning and speed"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + await page.locator(".chat-queue").getByText("Applying chat settings").waitFor({ + timeout: 10_000, + }); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + const sessionListCount = (await gateway.getRequests("sessions.list")).length; + await gateway.resolveDeferred("sessions.patch", {}); + const patches = await waitForRequests(gateway, "sessions.patch", 2); + expect(requireRecord(patches[1]?.params).fastMode).toBe(true); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length) + .toBeGreaterThan(sessionListCount); + expect(await gateway.getRequests("chat.send")).toHaveLength(0); + + await gateway.resolveDeferred("sessions.patch", {}); + const sendRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(sendRequest.params)).toMatchObject({ + message: prompt, + sessionKey: "agent:main:session-a", + }); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts new file mode 100644 index 000000000000..48fd05859eef --- /dev/null +++ b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts @@ -0,0 +1,774 @@ +import { expect, it } from "vitest"; +import { + SESSION_DRAG_MIME, + captureSessionAccessibilityProof, + chatSessionListResponse, + controlUiSessionPath, + createChatFlowE2eSuite, + expectDefined, + installMockGateway, + requireRecord, + sidebarSessionOrder, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("renders always-on pane headers without desktop topbar chrome", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + historyMessages: [ + { + content: [{ type: "text", text: "Split toolbar proof." }], + role: "assistant", + timestamp: Date.now(), + }, + ], + methodResponses: { "sessions.list": chatSessionListResponse() }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Split toolbar proof.").waitFor({ timeout: 10_000 }); + + // Desktop renders no topbar row: the sidebar owns navigation. + await expect.poll(() => page.locator(".topbar").isVisible()).toBe(false); + + const splitEntry = page.getByRole("button", { name: "Open split view" }); + await expect.poll(() => splitEntry.isVisible()).toBe(true); + await expect.poll(() => page.locator(".chat-pane__header").count()).toBe(1); + await page.evaluate(() => { + document.documentElement.classList.add("openclaw-native-macos"); + document.querySelector(".shell")?.classList.add("shell--nav-collapsed"); + }); + await expect + .poll(() => + page + .locator(".chat-pane__header") + .evaluate((header) => getComputedStyle(header).paddingLeft), + ) + .toBe("90px"); + await page.evaluate(() => { + document.documentElement.classList.remove("openclaw-native-macos"); + document.querySelector(".shell")?.classList.remove("shell--nav-collapsed"); + }); + await page.setViewportSize({ height: 900, width: 1100 }); + await expect.poll(() => splitEntry.isVisible()).toBe(true); + await page.setViewportSize({ height: 900, width: 1440 }); + await expect + .poll(() => + splitEntry.evaluate((node) => node.closest(".agent-chat__composer-shell") == null), + ) + .toBe(true); + await page.locator("openclaw-chat-pane").evaluate((pane) => { + ( + globalThis as typeof globalThis & { + classicChatPane?: Element; + } + ).classicChatPane = pane; + }); + const startupRequestsBeforeSplit = (await gateway.getRequests("chat.startup")).length; + await gateway.deferNext("chat.startup"); + await splitEntry.click(); + await expect + .poll(async () => (await gateway.getRequests("chat.startup")).length) + .toBeGreaterThan(startupRequestsBeforeSplit); + + // Each pane owns the same in-flow header in classic and split layouts. + const panes = page.locator("openclaw-chat-pane.chat-split-view__pane"); + const headers = page.locator(".chat-pane__header"); + await expect.poll(() => panes.count()).toBe(2); + await panes.last().getByText("Split toolbar proof.").waitFor(); + await expect.poll(() => panes.last().locator(".chat-loading-skeleton").count()).toBe(0); + await gateway.resolveDeferred("chat.startup"); + await expect + .poll(() => + panes.first().evaluate( + (pane) => + ( + globalThis as typeof globalThis & { + classicChatPane?: Element; + } + ).classicChatPane === pane, + ), + ) + .toBe(true); + await expect.poll(() => headers.count()).toBe(2); + await expect + .poll(async () => { + const visible = await Promise.all((await headers.all()).map((pane) => pane.isVisible())); + return visible.every(Boolean); + }) + .toBe(true); + await expect.poll(() => splitEntry.count()).toBe(0); + // The pane header hosts the session workspace toggle (the old collapsed + // rail strip is gone). + await expect.poll(() => headers.first().locator(".chat-workspace-toggle").count()).toBe(1); + await expect.poll(() => page.locator(".chat-workspace-rail").count()).toBe(0); + + // Keyboard focus on a header action marks the pane active. + await headers.first().getByRole("button", { name: "Split down" }).focus(); + const cells = page.locator(".chat-split-view__cell"); + await expect.poll(() => cells.first().getAttribute("class")).toContain("--active"); + + const lastPane = page.locator(".chat-split-view__pane").last(); + await lastPane.click({ position: { x: 20, y: 80 } }); + await expect.poll(() => cells.last().getAttribute("class")).toContain("--active"); + const targetHeader = headers.first(); + await expect + .poll(() => + targetHeader.evaluate((header) => { + const owner = header.closest("openclaw-chat-pane"); + return ( + owner === header.parentElement && owner?.classList.contains("chat-split-view__pane") + ); + }), + ) + .toBe(true); + + const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); + await dataTransfer.evaluate( + (transfer, data) => { + transfer.setData(data.mime, data.sessionKey); + }, + { mime: SESSION_DRAG_MIME, sessionKey: "agent:main:session-b" }, + ); + const unrelatedTarget = page.locator(".chat-split-view"); + const unrelatedDrag = { + bubbles: true, + clientX: 0, + clientY: 0, + dataTransfer, + }; + await unrelatedTarget.dispatchEvent("dragenter", unrelatedDrag); + await unrelatedTarget.dispatchEvent("dragover", unrelatedDrag); + await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(0); + await unrelatedTarget.dispatchEvent("drop", unrelatedDrag); + await expect.poll(() => panes.count()).toBe(2); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath("agent:main:session-a")); + + // Start with no retained pane preview and target the visible header. + const targetBox = await targetHeader.boundingBox(); + if (!targetBox) { + throw new Error("expected the pane header to have a layout box"); + } + const directHeaderDrag = { + bubbles: true, + clientX: targetBox.x + targetBox.width / 2, + clientY: targetBox.y + targetBox.height / 2, + dataTransfer, + }; + await targetHeader.dispatchEvent("dragenter", directHeaderDrag); + await targetHeader.dispatchEvent("dragover", directHeaderDrag); + await expect.poll(() => page.locator(".chat-split-view__drop-indicator").count()).toBe(1); + await targetHeader.dispatchEvent("drop", directHeaderDrag); + await dataTransfer.dispose(); + + await expect.poll(() => panes.count()).toBe(3); + await expect + .poll(async () => + (await page.locator(".chat-pane__session-title").allTextContents()).map((title) => + title.trim(), + ), + ) + .toContain("Session B"); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath("agent:main:session-b")); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("opens current context and latest-run usage from the composer ring", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + historyMessages: [ + { role: "user", content: "Show current usage", timestamp: Date.now() - 1_000 }, + { + role: "assistant", + content: "Usage ready.", + cost: { + input: 0.003456, + output: 0.018, + cacheRead: 0.0015, + cacheWrite: 0.0005, + total: 0.023456, + }, + model: "gpt-5.5", + provider: "openai", + timestamp: Date.now(), + }, + ], + methodResponses: { + "sessions.list": { + count: 1, + defaults: { + contextTokens: 200_000, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + contextTokens: 200_000, + estimatedCostUsd: 0.023456, + inputTokens: 757_300, + key: "main", + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + outputTokens: 42_300, + totalTokens: 46_000, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const trigger = page.locator("summary.context-ring"); + await trigger.waitFor({ timeout: 10_000 }); + await trigger.click(); + + const popover = page.locator(".context-usage__popover"); + await expect.poll(() => popover.isVisible()).toBe(true); + await expect.poll(() => popover.textContent()).toContain("46k / 200k · 23%"); + await expect.poll(() => popover.textContent()).toContain("757.3k"); + await expect.poll(() => popover.textContent()).toContain("42.3k"); + await expect.poll(() => popover.textContent()).toContain("Est. cost"); + await expect.poll(() => popover.textContent()).toContain("$0.023"); + await expect.poll(() => popover.textContent()).toContain("Cost by Type"); + await expect.poll(() => popover.textContent()).toContain("$0.0035"); + await expect.poll(() => popover.textContent()).toContain("$0.018"); + await expect.poll(() => popover.textContent()).toContain("$0.0015"); + await expect.poll(() => popover.textContent()).toContain("$0.0005"); + await expect.poll(() => popover.textContent()).toContain("openai"); + await expect.poll(() => popover.textContent()).toContain("gpt-5.5"); + + await page.keyboard.press("Escape"); + await expect.poll(() => popover.isHidden()).toBe(true); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("routes page typing to the active composer without stealing text input focus", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Type whenever you are ready.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Type whenever you are ready.").click(); + + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await expect + .poll(() => composer.evaluate((element) => element === document.activeElement)) + .toBe(false); + + await page.keyboard.type("first character preserved"); + expect(await composer.inputValue()).toBe("first character preserved"); + await expect + .poll(() => composer.evaluate((element) => element === document.activeElement)) + .toBe(true); + + await page.getByRole("button", { name: "Open command palette" }).click(); + const paletteInput = page.locator(".cmd-palette__input"); + await paletteInput.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => paletteInput.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.type("session search"); + + expect(await paletteInput.inputValue()).toBe("session search"); + expect(await composer.inputValue()).toBe("first character preserved"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps stale context visible as approximate without warning or compaction", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "sessions.list": { + count: 1, + defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions: [ + { + contextTokens: 200_000, + key: "main", + kind: "direct", + totalTokens: 190_000, + totalTokensFresh: false, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const trigger = page.locator("summary.context-ring"); + await trigger.waitFor({ timeout: 10_000 }); + expect((await trigger.textContent())?.trim()).toBe("~95%"); + expect(await trigger.getAttribute("aria-label")).toBe( + "Thread context usage: ~190k of 200k (~95%)", + ); + expect( + await trigger.evaluate((element) => element.classList.contains("context-ring--warning")), + ).toBe(false); + + await trigger.click(); + await expect + .poll(() => page.locator(".context-usage__popover").textContent()) + .toContain("~190k / 200k · ~95%"); + expect(await page.locator(".context-ring__action").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps chat usable while sessions are still loading", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.list"], + historyMessages: [ + { + content: [{ text: "History renders before sessions finish.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + await page.getByText("History renders before sessions finish.").waitFor({ timeout: 10_000 }); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + + // The chat boot hydrates the sidebar session list; that request stays + // deferred here while the composer must remain fully usable. + await gateway.waitForRequest("sessions.list"); + + await composer.fill("draft while sessions load"); + expect(await composer.inputValue()).toBe("draft while sessions load"); + await composer.fill(""); + + // The background hydrate must not take the shared sessions loading + // flag, which would disable New thread for the whole request. + const newThread = page.getByRole("button", { name: "New thread" }).first(); + expect(await newThread.isEnabled()).toBe(true); + + await gateway.resolveDeferred("sessions.list"); + await expect.poll(() => newThread.isEnabled()).toBe(true); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps every sidebar session stable while selecting sessions and supports sort modes", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const createdSessionKeys = Array.from( + { length: 11 }, + (_, index) => `agent:main:session-${String.fromCharCode(97 + index)}`, + ); + const pinnedSessionKey = "agent:main:session-pinned"; + const createdOrder = [pinnedSessionKey, ...createdSessionKeys]; + const updatedOrder = [pinnedSessionKey, ...createdSessionKeys.toReversed()]; + const sessions = { + count: createdSessionKeys.length + 1, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [ + { + key: pinnedSessionKey, + kind: "direct", + label: "Pinned Session", + pinned: true, + pinnedAt: 1, + updatedAt: 50, + }, + ...createdSessionKeys.map((key, index) => ({ + key, + kind: "direct", + label: `Session ${key.slice(-1).toUpperCase()}`, + updatedAt: (index + 1) * 100, + })), + ], + ts: Date.now(), + }; + await installMockGateway(page, { + methodResponses: { "sessions.list": sessions }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page + .locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]') + .waitFor({ + timeout: 10_000, + }); + await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11)); + await page.getByRole("button", { name: "Show more" }).click(); + await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); + + await page + .locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link', + ) + .click(); + await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({ + timeout: 10_000, + }); + await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); + + const activeWeight = await page + .locator('.sidebar-recent-session[data-session-key="agent:main:session-b"]') + .locator(".sidebar-recent-session__name") + .evaluate((label) => getComputedStyle(label).fontWeight); + const inactiveWeight = await page + .locator('.sidebar-recent-session[data-session-key="agent:main:session-a"]') + .locator(".sidebar-recent-session__name") + .evaluate((label) => getComputedStyle(label).fontWeight); + expect(activeWeight).toBe(inactiveWeight); + + const sortThreads = page.getByRole("button", { name: "Sort threads" }); + await sortThreads.locator("..").hover(); + await sortThreads.click(); + await page.getByRole("menuitemradio", { name: "Last updated" }).click(); + await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder); + + await sortThreads.locator("..").hover(); + await sortThreads.click(); + await page.getByRole("menuitemradio", { name: "Created" }).click(); + await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); + + await sortThreads.locator("..").hover(); + await sortThreads.click(); + await page.getByRole("main").click(); + await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps derived sidebar titles and accessible state after session patch refreshes", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const initialKey = "agent:main:session-a"; + const key = "agent:main:session-b"; + const readableTitle = "Readable planning title"; + const baseTime = Date.now(); + const sessionsWithDerivedTitle = chatSessionListResponse([ + { + key: initialKey, + kind: "direct", + label: initialKey, + displayName: initialKey, + derivedTitle: "Initial readable title", + updatedAt: baseTime, + }, + { + key, + kind: "direct", + label: key, + displayName: key, + derivedTitle: readableTitle, + updatedAt: baseTime - 60_000, + }, + ]); + const sessionsWithoutDerivedTitle = chatSessionListResponse([ + { + key: initialKey, + kind: "direct", + label: initialKey, + displayName: initialKey, + updatedAt: baseTime, + }, + { + key, + kind: "direct", + label: key, + displayName: key, + updatedAt: baseTime - 60_000, + }, + ]); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": { + cases: [ + { match: { includeDerivedTitles: true }, response: sessionsWithDerivedTitle }, + { match: {}, response: sessionsWithoutDerivedTitle }, + ], + }, + }, + sessionKey: initialKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const row = page.locator(`.sidebar-recent-session[data-session-key="${key}"]`); + await row.locator("a.sidebar-recent-session__link").click(); + await expect + .poll(async () => { + const requests = await gateway.getRequests("sessions.list"); + return requests.map((request) => request.params); + }) + .toContainEqual(expect.objectContaining({ includeDerivedTitles: true })); + const label = row.locator(".sidebar-recent-session__name"); + const link = row.locator("a.sidebar-recent-session__link"); + await expect.poll(() => label.textContent()).toBe(readableTitle); + expect(await row.getAttribute("role")).toBe("listitem"); + expect(await row.getAttribute("aria-label")).toBeNull(); + expect(await link.getAttribute("aria-label")).toBeNull(); + expect(await link.getAttribute("aria-current")).toBe("page"); + expect(await link.getAttribute("aria-describedby")).toBeNull(); + expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`); + await captureSessionAccessibilityProof(page, "after-derived-title"); + + const listCountBeforePatch = (await gateway.getRequests("sessions.list")).length; + await row.hover(); + await row.getByRole("button", { name: "Pin thread" }).click(); + + const patchRequest = await gateway.waitForRequest("sessions.patch"); + expect(requireRecord(patchRequest.params)).toMatchObject({ + key, + pinned: true, + }); + await expect + .poll(async () => { + const requests = await gateway.getRequests("sessions.list"); + return requests.slice(listCountBeforePatch).map((request) => request.params); + }) + .toContainEqual(expect.objectContaining({ includeDerivedTitles: true })); + await expect.poll(() => label.textContent()).toBe(readableTitle); + expect(await link.getAttribute("aria-current")).toBe("page"); + expect(await link.ariaSnapshot()).toContain(`link "${readableTitle}"`); + await captureSessionAccessibilityProof(page, "after-patch-refresh"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps long sidebar labels clipped after a session switch", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.clock.install(); + const sessions = chatSessionListResponse(); + const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture"); + const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture"); + firstSession.label = "Short"; + secondSession.label = + "Review and repair the intentionally overlong sidebar session title before navigation ".repeat( + 4, + ); + await installMockGateway(page, { + methodResponses: { "sessions.list": sessions }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const recentRow = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"]', + ); + const recentLabel = recentRow.locator(".sidebar-recent-session__name"); + await recentLabel.waitFor({ state: "visible", timeout: 10_000 }); + const layout = await recentLabel.evaluate((label) => ({ + clientWidth: label.clientWidth, + linkWidth: label.parentElement?.clientWidth ?? 0, + rowWidth: label.closest(".sidebar-recent-session")?.clientWidth ?? 0, + scrollWidth: label.scrollWidth, + text: label.textContent, + })); + expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth); + + await recentRow.dispatchEvent("mouseenter"); + await page.clock.runFor(250); + expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( + "hover-marquee--scrolling", + ); + await recentRow.dispatchEvent("mouseleave"); + await page.clock.runFor(300); + expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( + "hover-marquee--scrolling", + ); + await recentRow.dispatchEvent("mouseenter"); + await expect + .poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 }) + .toContain("hover-marquee--scrolling"); + await recentRow.dispatchEvent("mouseleave"); + await expect + .poll( + () => + recentLabel.evaluate((label) => ({ + textIndent: getComputedStyle(label).textIndent, + textOverflow: getComputedStyle(label).textOverflow, + })), + { timeout: 1_500 }, + ) + .toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); + + await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", { + button: 0, + }); + await page.locator(".sidebar-recent-session--active").getByText(secondSession.label).waitFor({ + timeout: 10_000, + }); + + const activeRow = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-b"]', + ); + expect( + await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({ + textIndent: getComputedStyle(label).textIndent, + textOverflow: getComputedStyle(label).textOverflow, + })), + ).toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("renders the visible authenticated assistant avatar after switching sessions", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const avatarBody = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nPcAAAAASUVORK5CYII=", + "base64", + ); + let avatarRequestCount = 0; + await page.route(/\/avatar\/main\?meta=1$/, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ avatarUrl: "/avatar/main", avatarStatus: "local" }), + }), + ); + await page.route(/\/avatar\/main$/, (route) => { + avatarRequestCount += 1; + return route.fulfill({ contentType: "image/png", body: avatarBody }); + }); + await installMockGateway(page, { + methodResponses: { "sessions.list": chatSessionListResponse() }, + sessionKey: "agent:main:session-a", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const documentMarker = await page.evaluate(() => { + const marker = crypto.randomUUID(); + (window as Window & { __openclawAvatarTestDocument?: string })[ + "__openclawAvatarTestDocument" + ] = marker; + return marker; + }); + const avatar = page.locator("img.agent-chat__welcome-avatar"); + await avatar.waitFor({ state: "visible" }); + await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); + const initialAvatarSrc = await avatar.getAttribute("src"); + const initialRequestCount = avatarRequestCount; + + const sessionRow = (sessionKey: string) => + page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`); + const sessionB = sessionRow("agent:main:session-b"); + await sessionB.locator("a.sidebar-recent-session__link").click(); + await expect + .poll(() => sessionB.getAttribute("class")) + .toContain("sidebar-recent-session--active"); + await expect.poll(() => avatarRequestCount).toBeGreaterThan(initialRequestCount); + await expect.poll(() => avatar.getAttribute("src")).not.toBe(initialAvatarSrc); + await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); + await expect.poll(() => avatar.isVisible()).toBe(true); + const sessionBAvatarSrc = await avatar.getAttribute("src"); + const sessionBRequestCount = avatarRequestCount; + + const sessionA = sessionRow("agent:main:session-a"); + await sessionA.locator("a.sidebar-recent-session__link").click(); + await expect + .poll(() => sessionA.getAttribute("class")) + .toContain("sidebar-recent-session--active"); + + await expect.poll(() => avatarRequestCount).toBeGreaterThan(sessionBRequestCount); + await expect.poll(() => avatar.getAttribute("src")).not.toBe(sessionBAvatarSrc); + await expect.poll(() => avatar.getAttribute("src")).toMatch(/^blob:/); + await expect.poll(() => avatar.isVisible()).toBe(true); + expect( + await page.evaluate( + () => + (window as Window & { __openclawAvatarTestDocument?: string })[ + "__openclawAvatarTestDocument" + ], + ), + ).toBe(documentMarker); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.streaming.e2e.test.ts b/ui/src/e2e/chat-flow.streaming.e2e.test.ts new file mode 100644 index 000000000000..285f780d197d --- /dev/null +++ b/ui/src/e2e/chat-flow.streaming.e2e.test.ts @@ -0,0 +1,623 @@ +import { expect, it } from "vitest"; +import { + chatThreadDistanceFromBottom, + createChatFlowE2eSuite, + installMockGateway, + requireRecord, + requireString, + scrollChatThreadToTop, + waitForChatScrollIdle, + waitForRequests, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("renders stable markdown during a streaming chat turn and finalizes the tail", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const prompt = "stream markdown through the GUI"; + await gateway.deferNext("chat.send"); + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + const streamingText = "## Streaming heading\n\nworking **tail"; + await gateway.emitGatewayEvent("chat", { + deltaText: streamingText, + message: { + content: [{ text: streamingText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + + await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({ + timeout: 10_000, + }); + await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({ + timeout: 10_000, + }); + expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); + + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({ + timeout: 10_000, + }); + + await gateway.emitChatFinal({ + runId, + text: "## Streaming heading\n\nworking **tail**", + }); + + await page.locator(".chat-thread strong").getByText("tail").waitFor({ timeout: 10_000 }); + expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("normalizes Unicode line separators in streaming and final chat DOM", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + await gateway.deferNext("chat.send"); + await page + .locator(".agent-chat__composer-combobox textarea") + .fill("render Unicode separators"); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + const streamingText = "## Unicode stream\u2028\u2028working **tail"; + await gateway.emitGatewayEvent("chat", { + deltaText: streamingText, + message: { + content: [{ text: streamingText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + + await page.locator(".chat-thread h2").getByText("Unicode stream").waitFor({ + timeout: 10_000, + }); + await page.locator(".chat-bubble.streaming strong").getByText("tail").waitFor({ + timeout: 10_000, + }); + expect(await page.locator(".markdown-plain-text-fallback").count()).toBe(0); + + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + await gateway.emitChatFinal({ + runId, + text: "## Unicode final\u2028\u2028- first\u2029- second", + }); + + await page.locator(".chat-thread h2").getByText("Unicode final").waitFor({ + timeout: 10_000, + }); + await expect + .poll(() => page.locator(".chat-thread li").allTextContents(), { timeout: 10_000 }) + .toEqual(["first", "second"]); + const finalChatText = await page.locator(".chat-thread .chat-text").last().textContent(); + expect(finalChatText).not.toContain("\u2028"); + expect(finalChatText).not.toContain("\u2029"); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it.each([ + { label: "desktop", viewport: { height: 900, width: 1280 } }, + { label: "mobile", viewport: { height: 844, width: 390 } }, + ])( + "keeps streamed text visible when a chat error terminates the turn on $label", + async ({ viewport }) => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const prompt = "stream before terminal error"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + const partialText = "Partial answer before gateway error."; + await gateway.emitGatewayEvent("chat", { + deltaText: partialText, + message: { + content: [{ text: partialText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + await page + .locator(".chat-thread-inner") + .getByText(partialText) + .waitFor({ timeout: 10_000 }); + + const gatewayErrorText = + "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again."; + const errorText = gatewayErrorText.replace(/^⚠️\s*/u, ""); + await gateway.emitGatewayEvent("chat", { + errorMessage: gatewayErrorText, + message: { + content: [{ text: gatewayErrorText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "error", + }); + + await page + .locator(".chat-thread-inner") + .getByText(partialText) + .waitFor({ timeout: 10_000 }); + const alert = page.locator(".chat-run-error"); + await alert.getByText(errorText).waitFor({ timeout: 10_000 }); + expect(await alert.locator("button").count()).toBe(0); + expect(await page.locator(".chat-thread-inner").getByText(errorText).count()).toBe(0); + expect( + await alert.evaluate((element) => + element.nextElementSibling?.classList.contains("agent-chat__composer-shell"), + ), + ).toBe(true); + const [alertBox, composerBox] = await Promise.all([ + alert.boundingBox(), + page.locator(".agent-chat__composer-shell").boundingBox(), + ]); + expect(alertBox).not.toBeNull(); + expect(composerBox).not.toBeNull(); + expect(Math.abs((alertBox?.x ?? 0) - (composerBox?.x ?? 0))).toBeLessThan(1); + expect(Math.abs((alertBox?.width ?? 0) - (composerBox?.width ?? 0))).toBeLessThan(1); + + await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error"); + await page.getByRole("button", { name: "Send message" }).click(); + await waitForRequests(gateway, "chat.send", 2); + await alert.waitFor({ state: "detached", timeout: 10_000 }); + } finally { + await suite.closeBrowserContext(context); + } + }, + ); + + it("keeps the pending working row stable through acknowledgement and streaming", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.deferNext("chat.send"); + + const prompt = "hold this until the ack arrives"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + await expect + .poll(() => page.locator(".agent-chat__composer-combobox textarea").inputValue(), { + timeout: 10_000, + }) + .toBe(""); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + const indicator = page.locator(".chat-reading-indicator"); + await indicator.waitFor({ timeout: 10_000 }); + expect(await page.locator(".chat-queue").count()).toBe(0); + await page.locator(".chat-working-indicator").evaluate(async (element) => { + await Promise.all(element.getAnimations().map((animation) => animation.finished)); + }); + const pendingRow = await indicator + .locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' chat-virtual-row ')][1]", + ) + .elementHandle(); + if (!pendingRow) { + throw new Error("expected pending working indicator virtual row"); + } + const pendingLayout = await pendingRow.evaluate((row) => { + const rect = row.getBoundingClientRect(); + Reflect.set(window, "__openclawPendingWorkingRow", row); + return { + height: rect.height, + key: row.getAttribute("data-virtual-row-key"), + top: rect.top, + }; + }); + expect(pendingLayout.key).not.toBeNull(); + await page.evaluate(() => { + const samples: Array<{ + height: number | null; + key: string | null; + sameRow: boolean; + top: number | null; + }> = []; + Reflect.set(window, "__openclawWorkingRowSamples", samples); + let remaining = 20; + const sample = () => { + const originalRow = Reflect.get(window, "__openclawPendingWorkingRow"); + const currentRow = document + .querySelector(".chat-reading-indicator") + ?.closest(".chat-virtual-row"); + const rect = currentRow?.getBoundingClientRect(); + samples.push({ + height: rect?.height ?? null, + key: currentRow?.getAttribute("data-virtual-row-key") ?? null, + sameRow: currentRow === originalRow, + top: rect?.top ?? null, + }); + remaining -= 1; + if (remaining > 0) { + requestAnimationFrame(sample); + } + }; + sample(); + }); + + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await indicator.waitFor({ timeout: 10_000 }); + const samples = await page.evaluate( + () => + new Promise< + Array<{ + height: number | null; + key: string | null; + sameRow: boolean; + top: number | null; + }> + >((resolve) => { + const read = () => { + const current = Reflect.get(window, "__openclawWorkingRowSamples"); + if (Array.isArray(current) && current.length >= 20) { + resolve(current); + return; + } + requestAnimationFrame(read); + }; + read(); + }), + ); + const layouts = samples.filter( + (sample): sample is { height: number; key: string; sameRow: true; top: number } => + sample.sameRow && + typeof sample.height === "number" && + typeof sample.key === "string" && + typeof sample.top === "number", + ); + expect(layouts).toHaveLength(20); + expect(new Set(layouts.map((sample) => sample.key))).toEqual(new Set([pendingLayout.key])); + const tops = layouts.map((sample) => sample.top); + const heights = layouts.map((sample) => sample.height); + expect(Math.max(...tops) - Math.min(...tops)).toBeLessThan(1); + expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(1); + + const response = "The streamed response is now visible."; + await gateway.emitGatewayEvent("chat", { + deltaText: response, + message: { + content: [{ text: response, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + + await page.getByText(response).waitFor({ timeout: 10_000 }); + await page.locator(".chat-reading-indicator").waitFor({ state: "detached", timeout: 10_000 }); + const streamingLayout = await pendingRow.evaluate( + (row, visibleResponse) => ({ + connected: row.isConnected, + hasResponse: row.textContent?.includes(visibleResponse) ?? false, + key: row.getAttribute("data-virtual-row-key"), + }), + response, + ); + expect(streamingLayout).toEqual({ + connected: true, + hasResponse: true, + key: pendingLayout.key, + }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("scrolls a delayed pending send into view before the ACK resolves", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const baseTs = Date.now() - 100_000; + const historyMessages = Array.from({ length: 50 }, (_, index) => ({ + content: [ + { + text: `History message ${index}\n${"extra transcript line\n".repeat(4)}`, + type: "text", + }, + ], + role: index % 2 === 0 ? "assistant" : "user", + timestamp: baseTs + index, + })); + const gateway = await installMockGateway(page, { historyMessages }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("History message 49").waitFor({ timeout: 10_000 }); + await expect + .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) + .toBeLessThanOrEqual(4); + + await waitForChatScrollIdle(page); + await expect + .poll( + async () => { + await scrollChatThreadToTop(page); + return chatThreadDistanceFromBottom(page); + }, + { timeout: 10_000 }, + ) + .toBeGreaterThan(200); + + await gateway.deferNext("chat.send"); + + const prompt = `pending send should scroll before ack\n${"visible now\n".repeat(6)}`; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + + await page.locator(".chat-thread").getByText("pending send should scroll").waitFor({ + timeout: 10_000, + }); + await expect + .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) + .toBeLessThanOrEqual(4); + + await gateway.resolveDeferred("chat.send", { runId, status: "started" }); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("overlays the scroll-to-bottom affordance without shrinking the transcript", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const baseTs = Date.now() - 100_000; + const historyMessages = Array.from({ length: 50 }, (_, index) => ({ + content: [ + { + text: `Scrollable history ${index}\n${"extra transcript line\n".repeat(4)}`, + type: "text", + }, + ], + role: index % 2 === 0 ? "assistant" : "user", + timestamp: baseTs + index, + })); + await installMockGateway(page, { historyMessages }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Scrollable history 49").waitFor({ timeout: 10_000 }); + await waitForChatScrollIdle(page); + + const readLayout = () => + page.locator(".chat-main").evaluate((container) => { + const thread = container.querySelector(".chat-thread"); + const composer = container.querySelector(".agent-chat__composer-shell"); + const button = container.querySelector(".chat-scroll-to-bottom"); + if (!thread || !composer) { + throw new Error("expected chat thread and composer"); + } + const threadRect = thread.getBoundingClientRect(); + const composerRect = composer.getBoundingClientRect(); + const buttonRect = button?.getBoundingClientRect(); + return { + buttonBottom: buttonRect ? Math.round(buttonRect.bottom) : null, + composerTop: Math.round(composerRect.top), + threadBottom: Math.round(threadRect.bottom), + }; + }); + + const before = await readLayout(); + expect(before.buttonBottom).toBeNull(); + + await scrollChatThreadToTop(page); + await page.getByRole("button", { name: "Scroll to latest" }).waitFor({ timeout: 10_000 }); + const after = await readLayout(); + + expect(after.threadBottom).toBe(before.threadBottom); + expect(after.composerTop).toBe(before.composerTop); + expect(after.buttonBottom).not.toBeNull(); + expect(after.buttonBottom!).toBeLessThan(after.composerTop); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("refreshes history after a tool-call window disconnects and reconnects", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const prompt = "use a tool then reconnect"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + + await gateway.emitGatewayEvent("agent", { + data: { + args: { query: "status" }, + name: "status", + phase: "start", + toolCallId: "tool-1", + }, + runId, + seq: 1, + sessionKey: "main", + stream: "tool", + ts: Date.now(), + }); + await gateway.setHistoryMessages([ + { + __openclaw: { idempotencyKey: `${runId}:user` }, + content: [{ text: prompt, type: "text" }], + role: "user", + timestamp: Date.now(), + }, + { + content: [{ text: "Recovered from refreshed history.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ]); + + await gateway.closeLatest(1006, "lost during tool call"); + + await page + .locator(".chat-thread-inner") + .getByText("Recovered from refreshed history.") + .waitFor({ timeout: 15_000 }); + expect(await page.locator(".chat-queue").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + + it("keeps live assistant stream text before the matching tool card", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const prompt = "stream before tool"; + await page.locator(".agent-chat__composer-combobox textarea").fill(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + const params = requireRecord(sendRequest.params); + const runId = requireString(params.idempotencyKey, "chat send idempotency key"); + + await gateway.emitGatewayEvent("chat", { + deltaText: "I will inspect the file.", + message: { + content: [{ text: "I will inspect the file.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + await page.getByText("I will inspect the file.").waitFor({ timeout: 10_000 }); + + await gateway.emitGatewayEvent("agent", { + data: { + name: "read", + phase: "result", + result: "file contents", + toolCallId: "call-read", + }, + runId, + seq: 1, + sessionKey: "main", + stream: "tool", + ts: Date.now() - 10_000, + }); + const toolBubble = page.locator('[data-message-id^="tool:assistant:call-read"]'); + await toolBubble.waitFor({ timeout: 10_000 }); + + const visibleOrder = await page.locator(".chat-thread").evaluate((thread: Element) => { + return Array.from(thread.querySelectorAll(".chat-group")).flatMap((group: Element) => { + const text = group.textContent ?? ""; + if (text.includes("I will inspect the file.")) { + return ["assistant stream"]; + } + if (group.querySelector('[data-message-id^="tool:assistant:call-read"]')) { + return ["tool card"]; + } + return []; + }); + }); + + expect(visibleOrder).toEqual(["assistant stream", "tool card"]); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/e2e/chat-flow.test-support.ts b/ui/src/e2e/chat-flow.test-support.ts new file mode 100644 index 000000000000..a6210bb28007 --- /dev/null +++ b/ui/src/e2e/chat-flow.test-support.ts @@ -0,0 +1,233 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expectDefined } from "@openclaw/normalization-core"; +import type { Page } from "playwright"; +import { expect } from "vitest"; +import { SESSION_DRAG_MIME } from "../lib/sessions/drag.ts"; +import { + controlUiSessionPath, + controlUiSessionUrl, + installMockGateway, + type MockGatewayRequest, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +export { + expectDefined, + SESSION_DRAG_MIME, + controlUiSessionPath, + controlUiSessionUrl, + installMockGateway, +}; + +export const managedImageCacheProofDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "managed-image-cache", +); +export const channelStopProofDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "channel-stop", +); +export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const sessionAccessibilityProofDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "session-accessibility", +); + +export function createChatFlowE2eSuite() { + return createControlUiE2eSuite({ + name: "Control UI mocked Gateway E2E", + trackBrowserContexts: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + }); +} + +export function requireRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +export function requireString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Expected non-empty ${label}`); + } + return value; +} + +export async function waitForRequests( + gateway: Awaited>, + method: string, + count: number, +): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const requests = await gateway.getRequests(method); + if (requests.length >= count) { + return requests; + } + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + throw new Error(`Timed out waiting for ${count} ${method} requests`); +} + +export async function expectRequestCountStable( + gateway: Awaited>, + method: string, + count: number, + durationMs = 500, +): Promise { + const deadline = Date.now() + durationMs; + do { + expect(await gateway.getRequests(method)).toHaveLength(count); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } while (Date.now() < deadline); +} + +export async function installPlainHttpClipboardCapture(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec = []; + document.execCommand = ((command: string) => { + if (command !== "copy") { + return false; + } + const active = document.activeElement as HTMLTextAreaElement | null; + (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec.push( + active?.value ?? "", + ); + return true; + }) as typeof document.execCommand; + }); +} + +export async function copiedViaExec(page: Page): Promise { + return page.evaluate(() => (globalThis as unknown as { copiedViaExec: string[] }).copiedViaExec); +} + +export async function chatThreadDistanceFromBottom(page: Page): Promise { + return page.locator(".chat-thread").evaluate((element) => { + const thread = element as HTMLElement; + return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight); + }); +} + +export async function waitForChatScrollIdle(page: Page): Promise { + await expect + .poll( + () => + page.locator(".chat-thread").evaluate(async (element) => { + const thread = element as HTMLElement; + const readGeometry = () => ({ + clientHeight: thread.clientHeight, + scrollHeight: thread.scrollHeight, + scrollTop: Math.round(thread.scrollTop), + }); + const before = readGeometry(); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 180); + }); + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + const after = readGeometry(); + return ( + before.clientHeight === after.clientHeight && + before.scrollHeight === after.scrollHeight && + before.scrollTop === after.scrollTop + ); + }), + { timeout: 10_000 }, + ) + .toBe(true); +} + +export async function scrollChatThreadToTop(page: Page): Promise { + await page.locator(".chat-thread").evaluate((element) => { + const thread = element as HTMLElement; + thread.scrollTop = 0; + thread.dispatchEvent(new Event("scroll", { bubbles: true })); + }); +} + +export async function captureSessionAccessibilityProof(page: Page, name: string): Promise { + if (!captureUiProofEnabled) { + return; + } + await mkdir(sessionAccessibilityProofDir, { recursive: true }); + const sidebar = page.locator("openclaw-app-sidebar"); + await page.screenshot({ + fullPage: true, + path: path.join(sessionAccessibilityProofDir, `${name}.png`), + }); + await writeFile( + path.join(sessionAccessibilityProofDir, `${name}.yml`), + await sidebar.ariaSnapshot(), + "utf8", + ); +} + +export async function visibleChatBubbleTexts(page: Page): Promise { + return page.locator(".chat-thread").evaluate((element) => { + const thread = element as HTMLElement; + const viewport = thread.getBoundingClientRect(); + return Array.from(thread.querySelectorAll(".chat-bubble")) + .filter((candidate) => { + const rect = candidate.getBoundingClientRect(); + return ( + rect.height > 0 && + rect.width > 0 && + rect.bottom > viewport.top && + rect.top < viewport.bottom + ); + }) + .map((candidate) => candidate.textContent?.trim() ?? "") + .filter(Boolean); + }); +} + +export function chatSessionListResponse( + sessions: Array< + Record & { + key: string; + kind: string; + label: string; + updatedAt: number; + } + > = [ + { key: "agent:main:session-a", kind: "direct", label: "Session A", updatedAt: 2 }, + { key: "agent:main:session-b", kind: "direct", label: "Session B", updatedAt: 1 }, + ], +) { + return { + count: sessions.length, + defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions, + ts: Date.now(), + }; +} + +export async function sidebarSessionOrder(page: Page): Promise { + return page + .locator(".sidebar-recent-session") + .evaluateAll((rows) => + rows + .map((row) => row.getAttribute("data-session-key") ?? "") + .filter((key) => key.startsWith("agent:main:session-")), + ); +} diff --git a/ui/src/e2e/control-ui-e2e-suite.test-support.ts b/ui/src/e2e/control-ui-e2e-suite.test-support.ts new file mode 100644 index 000000000000..b23c06c40356 --- /dev/null +++ b/ui/src/e2e/control-ui-e2e-suite.test-support.ts @@ -0,0 +1,93 @@ +import { chromium, type Browser, type BrowserContext } from "playwright"; +import { afterAll, afterEach, beforeAll, describe } from "vitest"; +import { + canRunPlaywrightChromium, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +type ControlUiE2eSuiteOptions = { + name: string; + unavailableMessage: (executablePath: string) => string; + trackBrowserContexts?: boolean; +}; + +type ControlUiE2eSuite = { + readonly browser: Browser; + readonly server: ControlUiE2eServer; + closeBrowserContext: (context: BrowserContext) => Promise; + define: (defineTests: () => void) => void; + newBrowserContext: (options: Parameters[0]) => Promise; +}; + +export function createControlUiE2eSuite(options: ControlUiE2eSuiteOptions): ControlUiE2eSuite { + const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); + const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); + const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; + const describeControlUiE2e = + chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + const openBrowserContexts = new Set(); + let browser: Browser | undefined; + let server: ControlUiE2eServer | undefined; + + const closeBrowserContext = async (context: BrowserContext): Promise => { + openBrowserContexts.delete(context); + await context.close().catch(() => {}); + }; + const closeOpenBrowserContexts = async (): Promise => { + await Promise.all([...openBrowserContexts].map((context) => closeBrowserContext(context))); + }; + + return { + get browser() { + if (!browser) { + throw new Error("Control UI E2E browser accessed before suite setup"); + } + return browser; + }, + get server() { + if (!server) { + throw new Error("Control UI E2E server accessed before suite setup"); + } + return server; + }, + closeBrowserContext, + define(defineTests) { + describeControlUiE2e(options.name, () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error(options.unavailableMessage(chromiumExecutablePath)); + } + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + try { + server = await startControlUiE2eServer(); + } catch (error) { + await browser.close(); + throw error; + } + }); + + afterAll(async () => { + await closeOpenBrowserContexts(); + await browser?.close(); + await server?.close(); + }); + + if (options.trackBrowserContexts) { + afterEach(closeOpenBrowserContexts); + } + + defineTests(); + }); + }, + async newBrowserContext(contextOptions) { + if (!browser) { + throw new Error("Control UI E2E browser accessed before suite setup"); + } + const context = await browser.newContext(contextOptions); + openBrowserContexts.add(context); + return context; + }, + }; +} diff --git a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts new file mode 100644 index 000000000000..10889ee87091 --- /dev/null +++ b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts @@ -0,0 +1,427 @@ +import { expect, it } from "vitest"; +import { + REFRESHED_RESEARCH_WORKSPACE, + SESSION_LIST_DEFAULTS, + WORKSPACE, + controlUiSessionPath, + createNewSessionPageE2eSuite, + installMockGateway, + pollLocatorText, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("creates a catalog-targeted draft with its advertised model", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.catalog.list": { + catalogs: [ + { + id: "claude", + label: "Claude Code", + capabilities: { + continueSession: true, + archive: false, + createSession: { model: "anthropic/claude-opus-4-8" }, + }, + hosts: [], + }, + ], + }, + "sessions.create": { key: "agent:main:claude-draft" }, + }, + }); + + try { + const model = "anthropic/claude-opus-4-8"; + await page.goto( + `${suite.server.baseUrl}new?agent=Research&catalog=claude&model=${encodeURIComponent("openai/gpt-5")}&label=Spoofed`, + ); + + const catalogRequest = await gateway.waitForRequest("sessions.catalog.list"); + expect(catalogRequest.params).toMatchObject({ + agentId: "research", + catalogId: "claude", + }); + const runtime = page.locator(".new-session-page__runtime"); + await pollLocatorText(runtime).toContain("Claude Code"); + expect(await runtime.getAttribute("title")).toBe(model); + expect(await page.locator('.new-session-page__trigger[title="Agent"]').count()).toBe(0); + expect(await page.locator('[data-chat-model-select="true"]').count()).toBe(0); + + await page.locator(".new-session-page__message").fill("use Claude Code"); + await page.getByRole("button", { name: "Start thread" }).click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "research", + message: "use Claude Code", + catalogId: "claude", + }); + expect(create.params).not.toHaveProperty("model"); + } finally { + await context.close(); + } + }); + + it("navigates to a created session while canonical session refresh is pending", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:refresh-overlap-e2e"; + const listResponse = { + count: 0, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [], + ts: Date.now(), + }; + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "sessions.create": { key: sessionKey }, + "sessions.list": listResponse, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const message = page.locator(".new-session-page__message"); + await message.waitFor({ state: "visible", timeout: 10_000 }); + const listCalls = (await gateway.getRequests("sessions.list")).length; + + await gateway.deferNext("sessions.list"); + await gateway.emitGatewayEvent("sessions.changed", { + key: "agent:main:other-client", + kind: "direct", + reason: "update", + sessionKey: "agent:main:other-client", + updatedAt: Date.now(), + }); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length) + .toBe(listCalls + 1); + + await message.fill("create during refresh"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + message: "create during refresh", + }); + await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionKey)); + + await gateway.resolveDeferred("sessions.list", listResponse); + } finally { + await context.close(); + } + }); + + it("resolves a pending catalog target after reconnect without clearing the draft", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.catalog.list": { + catalogs: [ + { + id: "claude", + label: "Claude Code", + capabilities: { + continueSession: true, + archive: false, + createSession: { model: "anthropic/claude-opus-4-8" }, + }, + hosts: [], + }, + ], + }, + "sessions.create": { key: "agent:research:claude-reconnect" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new?agent=research`); + await page.getByRole("heading", { name: "Research" }).waitFor(); + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + + await page.evaluate(() => { + history.pushState(null, "", "new?agent=research&catalog=claude"); + dispatchEvent(new PopStateEvent("popstate")); + }); + + const message = page.locator(".new-session-page__message"); + await message.fill("keep this reconnect draft"); + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("claude"); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isEnabled()) + .toBe(false); + expect(await gateway.getRequests("sessions.catalog.list")).toHaveLength(0); + + await gateway.deferNext("sessions.catalog.list"); + await gateway.setOnline(true); + await gateway.waitForRequest("sessions.catalog.list"); + await gateway.deferNext("sessions.catalog.list"); + await gateway.rejectDeferred("sessions.catalog.list", { + code: "UNAVAILABLE", + message: "catalog warming up", + retryable: true, + }); + await expect + .poll(async () => (await gateway.getRequests("sessions.catalog.list")).length) + .toBe(2); + await gateway.resolveDeferred("sessions.catalog.list", { catalogs: [] }); + await expect + .poll(async () => (await gateway.getRequests("sessions.catalog.list")).length, { + timeout: 10_000, + }) + .toBe(3); + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); + await expect.poll(() => message.inputValue()).toBe("keep this reconnect draft"); + await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); + + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "research", + message: "keep this reconnect draft", + catalogId: "claude", + }); + expect(create.params).not.toHaveProperty("model"); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); + + it("preserves a manually selected agent across a same-client reconnect", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: "agent:research:manual-reconnect" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.getByRole("heading", { name: "Main" }).waitFor(); + await gateway.waitForRequest("worktrees.branches"); + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await agentPicker.locator(".agent-select__trigger").click(); + await agentPicker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: "Research" }) + .click(); + await page.getByRole("heading", { name: "Research" }).waitFor(); + + const message = page.locator(".new-session-page__message"); + await message.fill("keep my selected agent"); + const agentRequestsBefore = (await gateway.getRequests("agents.list")).length; + const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length; + + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setMethodResponse("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: REFRESHED_RESEARCH_WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + await gateway.setOnline(true); + + await expect + .poll(async () => (await gateway.getRequests("agents.list")).length) + .toBe(agentRequestsBefore + 1); + await expect.poll(() => message.inputValue()).toBe("keep my selected agent"); + await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); + await pollLocatorText( + page.locator("#new-session-place-trigger .new-session-page__trigger-label"), + ).toBe("research-next"); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequestsBefore + 1); + expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({ + repoRoot: REFRESHED_RESEARCH_WORKSPACE, + includeRepositoryStatus: true, + }); + + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + await placeTrigger.click(); + const worktreeItem = placeSelect.getByRole("button", { name: "Worktree" }); + await worktreeItem.click(); + const baseInput = page.getByLabel("Base branch"); + await expect.poll(() => baseInput.inputValue()).toBe("main"); + await page.keyboard.press("Escape"); + + await gateway.deferNext("worktrees.branches"); + const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches")) + .length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchesBeforeSameWorkspaceReconnect + 1); + expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({ + repoRoot: REFRESHED_RESEARCH_WORKSPACE, + includeRepositoryStatus: true, + }); + expect(await baseInput.inputValue()).toBe(""); + expect(await baseInput.getAttribute("placeholder")).toBe("Loading…"); + await placeTrigger.click(); + await baseInput.fill("feature-choice"); + await gateway.resolveDeferred("worktrees.branches", { + branches: [{ kind: "local", name: "beta" }], + defaultBranch: "beta", + repositoryStatus: "git", + }); + await expect.poll(() => baseInput.inputValue()).toBe("feature-choice"); + + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "research", + message: "keep my selected agent", + worktree: true, + worktreeBaseRef: "feature-choice", + }); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts new file mode 100644 index 000000000000..a4859d34c699 --- /dev/null +++ b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts @@ -0,0 +1,944 @@ +import { expect, it } from "vitest"; +import { + ONE_PIXEL_PNG_B64, + SESSION_LIST_DEFAULTS, + TARGET_REPO, + WORKSPACE, + captureUiProof, + controlUiSessionPath, + controlUiSessionUrl, + createNewSessionPageE2eSuite, + createdSessionListResult, + installMockGateway, + pastePng, + pollLocatorText, + replaceGatewayClient, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("dispatches a cloud target before sending its first turn and shows placement", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:cloud:cloud-e2e"; + const gateway = await installMockGateway(page, { + defaultAgentId: "cloud", + deferredMethods: ["sessions.dispatch"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.reclaim"], + workspaceGit: true, + sessionKey: "agent:cloud:neutral-e2e", + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: sessionKey }, + "sessions.list": createdSessionListResult(sessionKey), + "sessions.dispatch": { + ok: true, + key: sessionKey, + sessionId: "session-cloud-e2e", + placement: { + state: "active", + generation: 1, + createdAtMs: 1, + updatedAtMs: 2, + stateChangedAtMs: 2, + environmentId: "worker-1", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "manifest-1", + remoteWorkspaceDir: "/workspace", + }, + }, + "sessions.describe": { session: {} }, + "sessions.delete": { ok: true, deleted: true }, + "sessions.reclaim": { ok: true }, + "sessions.send": { runId: "run-cloud-e2e", status: "started" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + expect( + await page.evaluate(() => ({ + hasSubtleCrypto: Boolean(globalThis.crypto.subtle), + isSecureContext: globalThis.isSecureContext, + })), + ).toEqual({ hasSubtleCrypto: true, isSecureContext: true }); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByRole("button", { name: "Cloud · aws" }).click(); + const trigger = page.locator("#new-session-place-trigger"); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("main"); + + const modelSelect = page.locator( + '.new-session-page__composer [data-chat-model-select="true"]', + ); + await modelSelect.click(); + await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-select")).toBe("true"); + const thinkingSlider = page.locator( + '.new-session-page__composer [data-chat-thinking-slider="true"]', + ); + await expect + .poll(() => thinkingSlider.getAttribute("data-chat-thinking-values")) + .toBe("off,minimal,low,medium,high"); + await expect + .poll(() => page.locator(".new-session-page__composer [data-chat-speed-toggle]").count()) + .toBe(0); + await thinkingSlider.press("End"); + await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); + await captureUiProof(page, "01-cloud-thinking-level.png"); + await modelSelect.click(); + await expect + .poll(() => modelSelect.evaluate((element) => element.closest("details")?.open ?? false)) + .toBe(false); + + // Picking a Gateway repo keeps the cloud selection: that folder is what + // the managed worktree checks out and dispatch syncs to the worker. + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await trigger.click(); + await pollLocatorText(place.locator(".new-session-page__menu-note")).toContain( + "Syncs target-repo to the cloud worker", + ); + await captureUiProof(page, "01-cloud-worker-target.png"); + await page.keyboard.press("Escape"); + + const message = "fix the cloud-only failure"; + const composer = page.locator(".new-session-page__message"); + await composer.fill(message); + await pastePng(composer); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + const startButton = page.getByRole("button", { name: "Start thread" }); + await gateway.deferNext("environments.list"); + const profileRequests = (await gateway.getRequests("environments.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(profileRequests); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => startButton.isDisabled()).toBe(true); + await gateway.rejectDeferred("environments.list", { + code: "UNAVAILABLE", + message: "profile lookup unavailable", + }); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => startButton.isDisabled()).toBe(true); + const failedProfileRequests = (await gateway.getRequests("environments.list")).length; + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(failedProfileRequests); + await expect.poll(() => startButton.isDisabled()).toBe(false); + + await startButton.click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "cloud", + message: "", + worktree: true, + worktreeBaseRef: "main", + cwd: TARGET_REPO, + thinkingLevel: "high", + }); + expect(create.params).not.toHaveProperty("attachments"); + await gateway.waitForRequest("sessions.dispatch"); + await gateway.rejectDeferred("sessions.dispatch", { + code: "UNAVAILABLE", + message: "allocation response lost", + }); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "cloud worker placement could not be verified", + ); + const alert = page.locator(".new-session-page__alert"); + await expect.poll(() => alert.getAttribute("role")).toBe("alert"); + await expect.poll(() => alert.locator("svg").count()).toBe(1); + const [alertBox, composerBox] = await Promise.all([ + alert.boundingBox(), + page.locator(".new-session-page__composer").boundingBox(), + ]); + expect(alertBox).not.toBeNull(); + expect(composerBox).not.toBeNull(); + expect( + Math.abs( + (alertBox?.x ?? 0) + + (alertBox?.width ?? 0) / 2 - + ((composerBox?.x ?? 0) + (composerBox?.width ?? 0) / 2), + ), + ).toBeLessThanOrEqual(1); + await expect.poll(() => startButton.isDisabled()).toBe(false); + await page.getByRole("button", { name: "Start thread" }).click(); + await expect + .poll(async () => (await gateway.getRequests("sessions.dispatch")).length) + .toBe(2); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + expect(await gateway.getRequests("sessions.delete")).toHaveLength(0); + const dispatches = await gateway.getRequests("sessions.dispatch"); + expect(dispatches.at(-1)?.params).toEqual({ + key: sessionKey, + agentId: "cloud", + profileId: "aws", + }); + const send = await gateway.waitForRequest("sessions.send"); + expect(send.params).toMatchObject({ + key: sessionKey, + agentId: "cloud", + message, + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + const orderedMethods = (await gateway.getRequests()) + .map((request) => request.method) + .filter((method) => + ["sessions.create", "sessions.dispatch", "sessions.send"].includes(method), + ); + expect(orderedMethods).toEqual([ + "sessions.create", + "sessions.dispatch", + "sessions.dispatch", + "sessions.send", + ]); + + await gateway.setMethodResponse("sessions.list", { + count: 4, + path: "", + defaults: {}, + sessions: [ + { + key: sessionKey, + kind: "direct", + label: "Cloud session", + updatedAt: Date.now(), + worktree: { id: "worktree-1", branch: "openclaw/cloud-e2e", repoRoot: WORKSPACE }, + placement: { state: "active" }, + }, + { + key: "agent:cloud:managed-e2e", + kind: "direct", + label: "Managed session", + updatedAt: Date.now() - 1, + placement: { state: "active" }, + }, + { + key: "agent:cloud:local-e2e", + kind: "direct", + label: "Local session", + updatedAt: Date.now() - 2, + placement: { state: "local" }, + }, + { + key: "agent:cloud:neutral-e2e", + kind: "direct", + label: "Neutral session", + updatedAt: Date.now() - 3, + placement: { state: "local" }, + }, + ], + ts: Date.now(), + }); + await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" }); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, "agent:cloud:neutral-e2e")); + const managedSessionKey = "agent:cloud:managed-e2e"; + const sessionRow = page.locator(`[data-session-key="${managedSessionKey}"]`); + const localSessionRow = page.locator('[data-session-key="agent:cloud:local-e2e"]'); + await sessionRow.waitFor(); + await localSessionRow.waitFor(); + const cloudPlacementBadge = sessionRow.locator('[data-placement-state="active"]'); + await cloudPlacementBadge.waitFor(); + await sessionRow.hover(); + await sessionRow.getByRole("button", { name: "Open thread menu" }).click(); + const stopWorker = page + .locator("openclaw-session-menu") + .getByRole("menuitem", { name: "Stop cloud worker…" }); + await stopWorker.waitFor(); + await captureUiProof(page, "02-active-cloud-worker-stop.png"); + expect(await localSessionRow.locator(".session-row-badge--cloud").count()).toBe(0); + expect(await cloudPlacementBadge.locator("circle").count()).toBe(1); + expect(await cloudPlacementBadge.locator("rect").count()).toBe(0); + page.once("dialog", (dialog) => void dialog.accept()); + await stopWorker.click(); + const reclaim = await gateway.waitForRequest("sessions.reclaim"); + expect(reclaim.params).toEqual({ key: managedSessionKey, agentId: "cloud" }); + } finally { + await context.close(); + } + }); + + it("clears cloud placement when the selected agent changes", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "local", + identity: { name: "Local" }, + name: "Local", + workspace: "/home/peter/local", + workspaceGit: false, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + const trigger = page.locator("#new-session-place-trigger"); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + + await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] }); + const profileRequests = (await gateway.getRequests("environments.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(profileRequests); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await pollLocatorText(trigger).toContain("Cloud · aws"); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(true); + await trigger.click(); + await expect + .poll(() => + page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .isDisabled(), + ) + .toBe(true); + await page.keyboard.press("Escape"); + + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await agentPicker.locator(".agent-select__trigger").click(); + await agentPicker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: "Local" }) + .click(); + await page.getByRole("heading", { name: "Local" }).waitFor(); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBeNull(); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false"); + } finally { + await context.close(); + } + }); + + it("restores a cloud startup after a page reload without creating another session", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const sessionKey = "agent:cloud:reload-recovery"; + const message = "resume this cloud task after reload"; + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: sessionKey }, + "sessions.dispatch": { + ok: true, + key: sessionKey, + sessionId: "session-reload-recovery", + placement: { + state: "active", + generation: 1, + createdAtMs: 1, + updatedAtMs: 2, + stateChangedAtMs: 2, + environmentId: "worker-reload-recovery", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "manifest-reload-recovery", + remoteWorkspaceDir: "/workspace", + }, + }, + "sessions.list": { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }], + ts: Date.now(), + }, + "chat.history": { + messages: [], + sessionId: "session-reload-recovery", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + await page.evaluate(() => { + const originalSetItem = sessionStorage.setItem.bind(sessionStorage); + Storage.prototype.setItem = function (key: string, value: string) { + if ( + key.startsWith("openclaw.new-session.cloud-recovery.v1:") || + key.startsWith("openclaw.control-ui-e2e.") + ) { + originalSetItem(key, value); + return; + } + throw new DOMException("composer storage disabled", "SecurityError"); + }; + }); + await gateway.deferNext("sessions.send"); + await page.locator(".new-session-page__message").fill(message); + await pastePng(page.locator(".new-session-page__message")); + await page.getByRole("button", { name: "Start thread" }).click(); + const firstSend = await gateway.waitForRequest("sessions.send"); + expect(firstSend.params).toMatchObject({ + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + await gateway.rejectDeferred("sessions.send", { + code: "UNAVAILABLE", + message: "send outcome unknown", + }); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "send outcome unknown", + ); + await gateway.setMethodResponse("sessions.send", { + runId: "run-reload-recovery", + status: "started", + }); + + await page.reload(); + await gateway.waitForRequest("environments.list"); + await expect + .poll(() => page.locator(".new-session-page__message").inputValue()) + .toBe(message); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await expect + .poll(() => page.getByRole("button", { name: "Remove attachment" }).isDisabled()) + .toBe(true); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(false); + await page.getByRole("button", { name: "Start thread" }).click(); + const resumedSend = await gateway.waitForRequest("sessions.send"); + expect(resumedSend.params).toMatchObject({ + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey, + key: sessionKey, + message, + }); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + } finally { + await context.close(); + } + }); + + it("restores cloud recovery added while the Gateway is disconnected", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const recoveryIdentity = await page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + gateway: { + connection: { gatewayUrl: string }; + snapshot: { client?: { recoveryScope?: string } | null }; + }; + }; + }; + }; + const gatewaySnapshot = app.runtime?.context.gateway; + const gatewayUrl = gatewaySnapshot?.connection.gatewayUrl ?? ""; + const recoveryScope = gatewaySnapshot?.snapshot.client?.recoveryScope ?? ""; + if (!gatewayUrl || !recoveryScope) { + throw new Error("Gateway recovery identity is unavailable"); + } + return { gatewayUrl, recoveryScope }; + }); + + await gateway.setOnline(false); + await expect + .poll(() => + page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { gateway: { snapshot: { phase: string } } } }; + }; + return app.runtime?.context.gateway.snapshot.phase === "connected"; + }), + ) + .toBe(false); + await page.evaluate(({ gatewayUrl, recoveryScope }) => { + sessionStorage.setItem( + `openclaw.new-session.cloud-recovery.v1:${gatewayUrl}:${recoveryScope}`, + JSON.stringify({ + sessionKey: "agent:cloud:offline-recovery", + messageId: "message-offline-recovery", + message: "restore after reconnect", + attachments: [ + { + type: "image", + mimeType: "image/png", + fileName: "pixel.png", + content: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=", + }, + ], + profileId: "aws", + agentId: "cloud", + gatewayUrl, + recoveryScope, + phase: "sending", + }), + ); + }, recoveryIdentity); + + await gateway.setOnline(true); + await expect + .poll(() => page.locator(".new-session-page__message").inputValue()) + .toBe("restore after reconnect"); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(false); + await page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + gateway: { + snapshot: { + client?: { recoveryScopeTracker?: { ready: boolean } } | null; + }; + }; + }; + }; + }; + const client = app.runtime?.context.gateway.snapshot.client; + if (!client?.recoveryScopeTracker) { + throw new Error("Gateway recovery tracker is unavailable"); + } + client.recoveryScopeTracker.ready = false; + ( + document.querySelector("openclaw-new-session-page") as + | (HTMLElement & { requestUpdate: () => void }) + | null + )?.requestUpdate(); + }); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(true); + } finally { + await context.close(); + } + }); + + it("retries an ambiguous cloud create with the same session key", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const message = "recover the cloud create"; + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.create"], + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.dispatch": { + placement: { state: "active", environmentId: "worker-create-recovery" }, + }, + "sessions.send": { runId: "run-create-recovery", status: "started" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + const firstCreate = await gateway.waitForRequest("sessions.create"); + const firstKey = (firstCreate.params as { key?: string }).key; + if (!firstKey) { + throw new Error("expected the first recovery create to include a session key"); + } + expect(firstKey).toMatch(/^agent:cloud:dashboard:/); + + await page.reload(); + await gateway.waitForRequest("environments.list"); + await expect + .poll(() => page.locator(".new-session-page__message").inputValue()) + .toBe(message); + await page.getByRole("button", { name: "Start thread" }).click(); + const retryCreate = await gateway.waitForRequest("sessions.create"); + expect(retryCreate.params).toMatchObject({ key: firstKey, message: "", worktree: true }); + await gateway.resolveDeferred("sessions.create", { key: firstKey }); + + expect(await gateway.waitForRequest("sessions.dispatch")).toMatchObject({ + params: { key: firstKey, agentId: "cloud", profileId: "aws" }, + }); + expect(await gateway.waitForRequest("sessions.send")).toMatchObject({ + params: { key: firstKey, agentId: "cloud", message }, + }); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(firstKey), { + timeout: 30_000, + }); + } finally { + await context.close(); + } + }); + + it("keeps the original recovery identity when a cloud create settles after reset", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const message = "preserve this late cloud create"; + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.create", "sessions.delete"], + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + const readRecovery = () => + page.evaluate(() => { + const key = Object.keys(sessionStorage).find((candidate) => + candidate.startsWith("openclaw.new-session.cloud-recovery.v1:"), + ); + return key ? (JSON.parse(sessionStorage.getItem(key) ?? "null") as unknown) : null; + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + const sessionKey = (create.params as { key: string }).key; + const staged = await readRecovery(); + + await page.evaluate(() => { + history.pushState(null, "", "new?agent=cloud"); + dispatchEvent(new PopStateEvent("popstate")); + }); + await gateway.resolveDeferred("sessions.create", { key: sessionKey }); + await gateway.waitForRequest("sessions.delete"); + await gateway.rejectDeferred("sessions.delete", { + code: "UNAVAILABLE", + message: "cleanup unavailable", + }); + + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "cleanup unavailable", + ); + const stagedIdentity = staged as { messageId: string; profileId: string; agentId: string }; + expect(await readRecovery()).toMatchObject({ + sessionKey, + messageId: stagedIdentity.messageId, + message, + profileId: stagedIdentity.profileId, + agentId: stagedIdentity.agentId, + phase: "dispatching", + }); + } finally { + await context.close(); + } + }); + + it("retries an unpersisted cloud turn with its original recovery identity", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:cloud:storage-recovery"; + const message = "keep this cloud recovery task"; + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.send"], + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "cloud", + identity: { name: "Cloud" }, + name: "Cloud", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "cloud", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: sessionKey }, + "sessions.dispatch": { + ok: true, + key: sessionKey, + sessionId: "session-storage-recovery", + placement: { + state: "active", + generation: 1, + createdAtMs: 1, + updatedAtMs: 2, + stateChangedAtMs: 2, + environmentId: "worker-storage-recovery", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "manifest-storage-recovery", + remoteWorkspaceDir: "/workspace", + }, + }, + "sessions.list": { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }], + ts: Date.now(), + }, + "chat.history": { + messages: [], + sessionId: "session-storage-recovery", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Cloud · aws" }) + .click(); + await page.evaluate(() => { + const originalSetItem = sessionStorage.setItem.bind(sessionStorage); + Storage.prototype.setItem = function (key: string, value: string) { + if (key.startsWith("openclaw.new-session.cloud-recovery.v1:")) { + originalSetItem(key, value); + return; + } + throw new DOMException("composer storage disabled", "SecurityError"); + }; + }); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + const firstSend = await gateway.waitForRequest("sessions.send"); + await gateway.rejectDeferred("sessions.send", { + code: "UNAVAILABLE", + message: "send outcome unknown", + }); + + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "send outcome unknown", + ); + await expect.poll(() => page.locator(".new-session-page__message").isDisabled()).toBe(true); + expect(await page.locator(".new-session-page__message").inputValue()).toBe(message); + expect(new URL(page.url()).pathname).toContain("/new"); + await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] }); + const profileRequests = (await gateway.getRequests("environments.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(profileRequests); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + + const sends = await gateway.getRequests("sessions.send"); + expect(sends).toHaveLength(2); + expect(sends[1]?.params).toMatchObject({ + idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey, + key: sessionKey, + message, + }); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + const dispatches = await gateway.getRequests("sessions.dispatch"); + expect(dispatches).toHaveLength(2); + expect(dispatches[1]?.params).toMatchObject({ profileId: "aws" }); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts deleted file mode 100644 index 8d98126549fd..000000000000 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ /dev/null @@ -1,4467 +0,0 @@ -// Control UI tests cover the full-page new-session draft and its place picker -// against a mocked Gateway: sidebar entry, fs.listDir browsing, and the final -// sessions.create payload. -import { mkdir } from "node:fs/promises"; -import path from "node:path"; -import { chromium, type Browser, type Locator, type Page } from "playwright"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - canRunPlaywrightChromium, - controlUiSessionPath, - controlUiSessionUrl, - installMockGateway, - resolvePlaywrightChromiumExecutablePath, - startControlUiE2eServer, - type ControlUiE2eServer, -} from "../test-helpers/control-ui-e2e.ts"; - -const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); -const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); -const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; -const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; -const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; -const uiProofArtifactDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "cloud-worker-session", -); -const reconnectProofArtifactDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "initial-prompt-reconnect", -); - -const WORKSPACE = "/home/peter/openclaw"; -const PICKED = "/home/peter/openclaw/packages"; -const SOURCE_REPO = "/tmp/source-repo"; -const TARGET_REPO = "/tmp/target-repo"; -const REFRESHED_RESEARCH_WORKSPACE = "/home/peter/research-next"; -const MOVED_WORKSPACE = "/home/peter/openclaw-next"; -const NODE_HOME = "/Users/peter"; -const NODE_PICKED = "/Users/peter/Projects"; -const NODE_UNC = "\\\\server\\share\\repo"; -const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo"; -const LOCATOR_TEXT_READ_TIMEOUT_MS = 500; -const LOCATOR_TEXT_POLL_TIMEOUT_MS = 10_000; - -const ONE_PIXEL_PNG_B64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; -const SESSION_LIST_DEFAULTS = { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", -}; - -function pollLocatorText(locator: Locator) { - return expect.poll(() => locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS }), { - timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS, - }); -} - -function createdSessionListResult(sessionKey: string) { - return { - count: 1, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [ - { - contextTokens: null, - displayName: "Created session", - key: sessionKey, - kind: "direct", - model: "gpt-5.5", - modelProvider: "openai", - totalTokens: 0, - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }; -} - -async function captureUiProof(page: Page, fileName: string) { - if (!captureUiProofEnabled) { - return; - } - await mkdir(uiProofArtifactDir, { recursive: true }); - await page.screenshot({ - animations: "disabled", - fullPage: true, - path: path.join(uiProofArtifactDir, fileName), - }); -} - -async function pastePng(target: Locator, count = 1) { - await target.evaluate( - (element, { base64, fileCount }) => { - const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); - const clipboard = new DataTransfer(); - for (let index = 0; index < fileCount; index += 1) { - const fileName = fileCount === 1 ? "pixel.png" : `pixel-${index + 1}.png`; - clipboard.items.add(new File([bytes], fileName, { type: "image/png" })); - } - element.dispatchEvent( - new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }), - ); - }, - { base64: ONE_PIXEL_PNG_B64, fileCount: count }, - ); -} - -async function replaceGatewayClient(page: Page) { - await page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { context: { gateway: { connect: () => void } } }; - }; - if (!app.runtime) { - throw new Error("OpenClaw application runtime is unavailable"); - } - app.runtime.context.gateway.connect(); - }); -} - -async function navigateInApp(page: Page, routeId: string, search = "") { - await page.evaluate( - ({ targetRouteId, targetSearch }) => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - context: { - navigate: (routeId: string, options?: { search?: string }) => void; - }; - }; - }; - if (!app.runtime) { - throw new Error("OpenClaw application runtime is unavailable"); - } - app.runtime.context.navigate(targetRouteId, { search: targetSearch }); - }, - { targetRouteId: routeId, targetSearch: search }, - ); -} - -/** - * Chat first pushes its base path and then commits the canonical session path. - * A repeated URL alone cannot prove that the router has finished, so require - * the successful active match and browser location to agree before leaving. - */ -async function waitForCommittedChatRoute(page: Page) { - await page.waitForURL((url) => url.pathname.startsWith("/chat/")); - await expect - .poll(() => - page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - router: { - getState: () => { - status: string; - resolvedLocation: { pathname: string } | null; - matches: { routeId: string }[]; - pendingMatches: unknown[]; - }; - }; - }; - }; - const state = app.runtime?.router.getState(); - return ( - state?.status === "success" && - state.matches[0]?.routeId === "chat" && - state.resolvedLocation?.pathname === window.location.pathname && - state.pendingMatches.length === 0 - ); - }), - ) - .toBe(true); -} - -async function choosePackagesFolder(page: Page) { - await page.locator("#new-session-place-trigger").click(); - await page.getByRole("button", { name: "Browse folders" }).click(); - await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click(); - await page.getByRole("button", { name: "Use this folder" }).click(); -} - -let browser: Browser; -let server: ControlUiE2eServer; - -describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { - beforeAll(async () => { - if (!chromiumAvailable) { - throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`); - } - server = await startControlUiE2eServer(); - browser = await chromium.launch({ executablePath: chromiumExecutablePath }); - }); - - afterAll(async () => { - await browser?.close(); - await server?.close(); - }); - - it("grows the first prompt through ten lines before using a narrow scrollbar", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page); - try { - await page.goto(`${server.baseUrl}new`); - const message = page.locator(".new-session-page__message"); - await message.waitFor(); - - const initial = await message.evaluate((element) => ({ - height: element.clientHeight, - overflowY: getComputedStyle(element).overflowY, - })); - await message.fill(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n")); - const tenLines = await message.evaluate((element) => { - const style = getComputedStyle(element); - return { - height: element.clientHeight, - lineHeight: Number.parseFloat(style.lineHeight), - overflowY: style.overflowY, - padding: Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom), - scrollbarWidth: style.scrollbarWidth, - webkitScrollbarWidth: getComputedStyle(element, "::-webkit-scrollbar").width, - }; - }); - - expect(tenLines.height).toBeGreaterThan(initial.height); - expect(tenLines.height).toBeGreaterThanOrEqual( - Math.floor(tenLines.lineHeight * 10 + tenLines.padding) - 1, - ); - expect(tenLines.overflowY).toBe("hidden"); - expect(tenLines.scrollbarWidth).toBe("thin"); - expect(Number.parseFloat(tenLines.webkitScrollbarWidth)).toBeLessThanOrEqual(6); - await captureUiProof(page, "new-session-composer-ten-lines.png"); - - const longPrompt = Array.from({ length: 14 }, (_, index) => `line ${index + 1}`).join("\n"); - await message.fill(longPrompt); - const capped = await message.evaluate((element) => ({ - clientHeight: element.clientHeight, - overflowY: getComputedStyle(element).overflowY, - scrollHeight: element.scrollHeight, - })); - expect(capped.clientHeight).toBeLessThan(capped.scrollHeight); - expect(capped.overflowY).toBe("auto"); - await captureUiProof(page, "new-session-composer-capped-scrollbar.png"); - const start = page.getByRole("button", { name: "Start thread" }); - await expect(start.isVisible()).resolves.toBe(true); - await expect(start.isEnabled()).resolves.toBe(true); - await start.focus(); - await expect(start.evaluate((element) => document.activeElement === element)).resolves.toBe( - true, - ); - await start.press("Enter"); - await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ - params: { message: longPrompt }, - }); - } finally { - await context.close(); - } - }); - - it("pastes an image into the draft and forwards it with the initial turn", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: "agent:main:image-draft", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const message = page.locator(".new-session-page__message"); - await message.waitFor(); - await pastePng(message); - - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await page.getByRole("button", { name: "Start thread" }).click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "main", - message: "", - attachments: [ - { - type: "image", - mimeType: "image/png", - fileName: "pixel.png", - content: ONE_PIXEL_PNG_B64, - }, - ], - }); - } finally { - await context.close(); - } - }); - - it("shows the initial prompt while the newly created session is still running", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:visible-initial-prompt"; - const message = "keep this prompt visible while the agent works"; - const activeOutputTimestamp = Date.now() + 60_000; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: sessionKey, runStarted: true }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "active-tool-call", - name: "read", - arguments: { path: "SKILL.md" }, - }, - ], - timestamp: activeOutputTimestamp, - __openclaw: { id: "active-assistant", seq: 2 }, - }, - { - role: "toolResult", - toolCallId: "active-tool-call", - toolName: "read", - content: [{ type: "text", text: "working" }], - timestamp: activeOutputTimestamp + 1, - __openclaw: { id: "active-tool-result", seq: 3 }, - }, - ], - sessionId: "visible-initial-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - await page.locator(".new-session-page__message").fill(message); - await page.getByRole("button", { name: "Start thread" }).click(); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - await gateway.waitForRequest("chat.startup"); - await page.getByText("SKILL.md", { exact: true }).waitFor(); - - await pollLocatorText(page.locator(".chat-group.user")).toContain(message); - const userRow = await page.locator(".chat-group.user").boundingBox(); - const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox(); - expect(userRow).not.toBeNull(); - expect(toolRow).not.toBeNull(); - if (!userRow || !toolRow) { - throw new Error("expected visible prompt and tool rows"); - } - expect(userRow.y).toBeLessThan(toolRow.y); - } finally { - await context.close(); - } - }); - - it("keeps the initial prompt visible across a Gateway reconnect", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:reconnected-initial-prompt"; - const message = "keep this first prompt through reconnect"; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: sessionKey, runStarted: true }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [], - sessionId: "reconnected-initial-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - await page.locator(".new-session-page__message").fill(message); - await page.getByRole("button", { name: "Start thread" }).click(); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - await gateway.waitForRequest("chat.startup"); - await pollLocatorText(page.locator(".chat-group.user")).toContain(message); - - const socketsBeforeReconnect = await gateway.getSocketCount(); - await gateway.setOnline(false); - await expect - .poll(() => gateway.getSocketCount(), { timeout: 10_000 }) - .toBeGreaterThan(socketsBeforeReconnect); - await gateway.setOnline(true); - await expect - .poll(() => - page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { context: { gateway: { snapshot: { phase: string } } } }; - }; - return app.runtime?.context.gateway.snapshot.phase; - }), - ) - .toBe("connected"); - await page.evaluate((selectedSessionKey) => { - const pane = document.querySelector("openclaw-chat-pane") as unknown as HTMLElement & { - state: { chatMessages: unknown[]; chatMessagesBySession: Map }; - switchPaneSession: (sessionKey: string) => void; - }; - pane.state.chatMessages = []; - pane.state.chatMessagesBySession.clear(); - pane.switchPaneSession("agent:main:temporary-session"); - pane.switchPaneSession(selectedSessionKey); - }, sessionKey); - if (captureUiProofEnabled) { - await mkdir(reconnectProofArtifactDir, { recursive: true }); - await page.screenshot({ - path: path.join(reconnectProofArtifactDir, "reconnected-session.png"), - fullPage: true, - }); - } - - await pollLocatorText(page.locator(".chat-group.user")).toContain(message); - await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1); - } finally { - await context.close(); - } - }); - - it("reconciles an image-bearing initial prompt into one user row", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:single-image-prompt"; - const message = "testing if dual prompts show"; - const gateway = await installMockGateway(page, { - deferredMethods: ["chat.startup"], - methodResponses: { - "sessions.create": { - key: sessionKey, - runId: "initial-image-send", - runStarted: true, - messageSeq: 1, - }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.startup": { - messages: [ - { - role: "user", - content: [ - { - type: "image", - source: { type: "url", url: "/persisted-image.png" }, - }, - { type: "text", text: message }, - ], - timestamp: Date.now(), - __openclaw: { - id: "persisted-image-prompt", - idempotencyKey: "initial-image-send:user", - seq: 1, - }, - }, - ], - sessionId: "single-image-prompt", - sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - await composer.fill(message); - await pastePng(composer); - await page.getByRole("button", { name: "Start thread" }).click(); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - await gateway.waitForRequest("chat.startup"); - - const userRow = page.locator(".chat-group.user"); - const userImage = userRow.locator("img.chat-message-image"); - await expect.poll(() => userRow.count()).toBe(1); - await expect.poll(() => userImage.count()).toBe(1); - await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u); - const initialImageSrc = await userImage.getAttribute("src"); - await userImage.evaluate((image) => image.setAttribute("data-initial-image-node", "true")); - await pollLocatorText(userRow).toContain(message); - await pollLocatorText(userRow).not.toContain("Attached image"); - - await gateway.resolveDeferred("chat.startup"); - - await expect.poll(() => userRow.count()).toBe(1); - await expect.poll(() => userImage.count()).toBe(1); - await expect.poll(() => userImage.getAttribute("data-initial-image-node")).toBe("true"); - await expect.poll(() => userImage.getAttribute("src")).toBe(initialImageSrc); - await pollLocatorText(userRow).toContain(message); - await pollLocatorText(userRow).not.toContain("Attached image"); - } finally { - await context.close(); - } - }); - - it("waits for pasted image reads before enabling session creation", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") - ?.value as FileReader["readAsDataURL"]; - FileReader.prototype.readAsDataURL = function (blob: Blob) { - (globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead = - () => readAsDataUrl.call(this, blob); - }; - }); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - const submit = page.getByRole("button", { name: "Start thread" }); - await composer.fill("include the image that is still loading"); - await pastePng(composer); - - await expect.poll(() => submit.isDisabled()).toBe(true); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - await page.evaluate(() => { - const finish = (globalThis as unknown as { finishPastedImageRead?: () => void }) - .finishPastedImageRead; - if (!finish) { - throw new Error("Pasted image read was not started"); - } - finish(); - }); - - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await expect.poll(() => submit.isEnabled()).toBe(true); - await submit.click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - message: "include the image that is still loading", - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - } finally { - await context.close(); - } - }); - - it("releases a completed file when the rest of its pasted batch is aborted", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") - ?.value as FileReader["readAsDataURL"]; - let readCount = 0; - FileReader.prototype.readAsDataURL = function (blob: Blob) { - readCount += 1; - if (readCount === 1) { - readAsDataUrl.call(this, blob); - } - }; - const createObjectURL = URL.createObjectURL.bind(URL); - const revokeObjectURL = URL.revokeObjectURL.bind(URL); - const proof = { created: 0, revoked: 0 }; - (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; - URL.createObjectURL = (blob: Blob) => { - proof.created += 1; - return createObjectURL(blob); - }; - URL.revokeObjectURL = (url: string) => { - proof.revoked += 1; - revokeObjectURL(url); - }; - }); - await installMockGateway(page); - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - await pastePng(composer, 2); - await expect - .poll(() => - page.evaluate( - () => - (globalThis as unknown as { attachmentUrlProof: { created: number } }) - .attachmentUrlProof.created, - ), - ) - .toBe(1); - - await page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { context: { navigate: (routeId: string) => void } }; - }; - app.runtime?.context.navigate("chat"); - }); - await page.waitForURL((url) => url.pathname.endsWith("/chat")); - await expect - .poll(() => - page.evaluate( - () => - (globalThis as unknown as { attachmentUrlProof: { revoked: number } }) - .attachmentUrlProof.revoked, - ), - ) - .toBe(1); - } finally { - await context.close(); - } - }); - - it("releases pasted image previews after remove, reset, disconnect, and success", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const createObjectURL = URL.createObjectURL.bind(URL); - const revokeObjectURL = URL.revokeObjectURL.bind(URL); - const proof = { created: 0, revoked: 0 }; - (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; - URL.createObjectURL = (blob: Blob) => { - proof.created += 1; - return createObjectURL(blob); - }; - URL.revokeObjectURL = (url: string) => { - proof.revoked += 1; - revokeObjectURL(url); - }; - }); - await installMockGateway(page, { - methodResponses: { - "agents.list": { - defaultId: "main", - mainKey: "main", - scope: "agent", - agents: [ - { id: "main", name: "Main" }, - { id: "writer", name: "Writer" }, - ], - }, - "sessions.create": { key: "agent:main:preview-cleanup", runStarted: true }, - }, - }); - const proof = () => - page.evaluate( - () => - (globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } }) - .attachmentUrlProof, - ); - const navigate = (routeId: string, search = "") => - page.evaluate( - ({ targetRouteId, targetSearch }) => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - context: { - navigate: (routeId: string, options?: { search?: string }) => void; - }; - }; - }; - if (!app.runtime) { - throw new Error("OpenClaw application runtime is unavailable"); - } - app.runtime.context.navigate(targetRouteId, { search: targetSearch }); - }, - { targetRouteId: routeId, targetSearch: search }, - ); - - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - - await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await page.getByRole("button", { name: "Remove attachment" }).click(); - await expect.poll(async () => (await proof()).revoked).toBe(1); - - await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown"); - await page.locator(".new-session-page__select--agent .agent-select__trigger").click(); - await expect - .poll(() => - agentDropdown.evaluate((dropdown) => (dropdown as HTMLElement & { open: boolean }).open), - ) - .toBe(true); - await navigate("new-session", "?agent=main&catalog=missing"); - await expect - .poll(() => - page.evaluate( - () => - ( - document.querySelector(".new-session-page__select--agent wa-dropdown") as - | (HTMLElement & { open: boolean }) - | null - )?.open ?? false, - ), - ) - .toBe(false); - await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0); - await expect.poll(async () => (await proof()).revoked).toBe(2); - - await navigate("new-session"); - await composer.waitFor(); - await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await navigate("chat"); - await page.waitForURL((url) => url.pathname.endsWith("/chat")); - await expect.poll(async () => (await proof()).revoked).toBe(3); - - await navigate("new-session"); - await composer.waitFor(); - await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await page.getByRole("button", { name: "Start thread" }).click(); - await page.waitForURL( - (url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"), - ); - await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 }); - } finally { - await context.close(); - } - }); - - it("selects the model for a plain new session", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, - ], - methodResponses: { - "sessions.create": { key: "agent:main:model-draft", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const modelSelect = page.locator('[data-chat-model-select="true"]'); - await modelSelect.waitFor(); - expect( - await page.locator('.new-session-page__composer [data-chat-model-select="true"]').count(), - ).toBe(1); - expect( - await page.locator('.new-session-page__triggers [data-chat-model-select="true"]').count(), - ).toBe(0); - await modelSelect.click(); - const pickerOpen = () => - modelSelect.evaluate( - (element) => element.closest("details")?.hasAttribute("open") ?? false, - ); - const modelTriggerBox = await modelSelect.boundingBox(); - const modelMenuBox = await page - .locator(".chat-controls__inline-select-menu--combined") - .boundingBox(); - expect(modelTriggerBox).not.toBeNull(); - expect(modelMenuBox).not.toBeNull(); - expect(modelMenuBox?.x ?? 0).toBeLessThan(modelTriggerBox?.x ?? 0); - expect((modelMenuBox?.x ?? 0) + (modelMenuBox?.width ?? 0)).toBeCloseTo( - (modelTriggerBox?.x ?? 0) + (modelTriggerBox?.width ?? 0), - 0, - ); - await page.locator('[data-chat-model-provider="anthropic"]').click(); - await expect.poll(pickerOpen).toBe(true); - await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); - // Inside changes stay grouped; only explicit light-dismissal closes the picker. - await expect.poll(pickerOpen).toBe(true); - await page.keyboard.press("Escape"); - await expect.poll(pickerOpen).toBe(false); - await expect - .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) - .toBe(true); - await modelSelect.click(); - await expect.poll(pickerOpen).toBe(true); - await page.mouse.click(8, 8); - await expect.poll(pickerOpen).toBe(false); - await page.locator(".new-session-page__message").fill("use this model"); - await page.getByRole("button", { name: "Start thread" }).click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - message: "use this model", - model: "anthropic/claude-sonnet-4-6", - }); - } finally { - await context.close(); - } - }); - - it("restores valid preferences and repairs a worktree rejected by workspace metadata", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", - }, - ], - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { - path: PICKED, - parent: WORKSPACE, - home: "/home/peter", - entries: [], - }, - }, - ], - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const placeTrigger = page.locator("#new-session-place-trigger"); - await choosePackagesFolder(page); - await placeTrigger.click(); - await page.getByRole("button", { name: "Worktree" }).click(); - await page.keyboard.press("Escape"); - - const modelSelect = page.locator('[data-chat-model-select="true"]'); - await modelSelect.click(); - await page.locator('[data-chat-model-provider="anthropic"]').click(); - await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); - const thinkingSlider = page.locator('[data-chat-thinking-slider="true"]'); - await thinkingSlider.press("End"); - await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); - - await page.goto(`${server.baseUrl}new`); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "packages", - ); - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); - await expect - .poll(() => modelSelect.getAttribute("data-chat-select-value")) - .toBe("anthropic/claude-sonnet-4-6"); - await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); - await captureUiProof(page, "new-session-preferences-restored.png"); - - const branchRequests = await gateway.getRequests("worktrees.branches"); - expect(branchRequests.at(-1)?.params).toMatchObject({ repoRoot: PICKED }); - - await page.evaluate((workspace) => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - if (!key) { - throw new Error("missing new-session preference"); - } - const value = JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - }; - const main = value.agents?.main; - if (!main) { - throw new Error("missing main-agent preference"); - } - main.folder = workspace; - main.workspace = workspace; - localStorage.setItem(key, JSON.stringify(value)); - }, WORKSPACE); - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: false, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - await page.reload(); - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); - const storedWorktree = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - const value = key - ? (JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null) - : null; - return value?.agents?.main?.worktree; - }); - expect(storedWorktree).toBe(false); - } finally { - await context.close(); - } - }); - - it("blocks an immediate submit until remembered model and worktree choices validate", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const models = [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", - }, - ]; - const branches = { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }; - const gateway = await installMockGateway(page, { - workspaceGit: true, - models, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": branches, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - await choosePackagesFolder(page); - const placeTrigger = page.locator("#new-session-place-trigger"); - await placeTrigger.click(); - await page.getByRole("button", { name: "Worktree" }).click(); - await page.keyboard.press("Escape"); - const modelSelect = page.locator('[data-chat-model-select="true"]'); - await modelSelect.click(); - await page.locator('[data-chat-model-provider="anthropic"]').click(); - await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); - - await navigateInApp(page, "chat"); - await waitForCommittedChatRoute(page); - const metadataRequests = (await gateway.getRequests("chat.metadata")).length; - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.deferNext("chat.metadata"); - await gateway.deferNext("worktrees.branches"); - await navigateInApp(page, "new-session"); - await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); - await expect - .poll(async () => (await gateway.getRequests("chat.metadata")).length) - .toBe(metadataRequests + 1); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); - - await page.locator(".new-session-page__message").fill("keep both remembered choices"); - const start = page.getByRole("button", { name: "Start thread" }); - await expect.poll(() => start.isDisabled()).toBe(true); - - await gateway.resolveDeferred("chat.metadata", { models }); - await expect.poll(() => start.isDisabled()).toBe(true); - await gateway.rejectDeferred("worktrees.branches", { - code: "UNAVAILABLE", - message: "branch lookup unavailable", - }); - // A failed lookup disables the worktree toggle, so restoring the stored - // choice would strand the draft behind a control the user cannot clear. - // The draft drops it and stays submittable; storage keeps the preference. - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); - await expect.poll(() => start.isDisabled()).toBe(false); - - await page.reload(); - await page.locator(".new-session-page__message").fill("keep both remembered choices"); - await expect - .poll(() => modelSelect.getAttribute("data-chat-select-value")) - .toBe("anthropic/claude-sonnet-4-6"); - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); - await expect.poll(() => start.isDisabled()).toBe(false); - await start.click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - cwd: PICKED, - message: "keep both remembered choices", - model: "anthropic/claude-sonnet-4-6", - worktree: true, - }); - } finally { - await context.close(); - } - }); - - it("repairs a remembered default folder when the agent workspace moves", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - models: [ - { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", - }, - ], - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [], - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const placeTrigger = page.locator("#new-session-place-trigger"); - await placeTrigger.click(); - await page.getByRole("button", { name: "Browse folders" }).click(); - await page.getByRole("button", { name: "Use this folder" }).click(); - await navigateInApp(page, "chat"); - await page.waitForURL((url) => url.pathname.endsWith("/chat")); - - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: MOVED_WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - await page.reload(); - await navigateInApp(page, "new-session"); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw-next", - ); - - const modelSelect = page.locator('[data-chat-model-select="true"]'); - await modelSelect.click(); - await page.locator('[data-chat-model-provider="anthropic"]').click(); - await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); - const storedPreference = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - if (!key) { - return null; - } - const value = JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null; - return value?.agents?.main ?? null; - }); - expect(storedPreference).toMatchObject({ - workspace: MOVED_WORKSPACE, - folder: MOVED_WORKSPACE, - model: "anthropic/claude-sonnet-4-6", - }); - } finally { - await context.close(); - } - }); - - it("falls back to the current workspace when the remembered folder is gone", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - await choosePackagesFolder(page); - - await navigateInApp(page, "chat"); - await waitForCommittedChatRoute(page); - const validationRequests = (await gateway.getRequests("fs.listDir")).length; - await gateway.deferNext("fs.listDir"); - await navigateInApp(page, "new-session"); - await expect - .poll(async () => (await gateway.getRequests("fs.listDir")).length) - .toBeGreaterThan(validationRequests); - const message = page.locator(".new-session-page__message"); - await message.fill("use a safe folder"); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(true); - - await gateway.rejectDeferred("fs.listDir", { - code: "INVALID_REQUEST", - message: `Error: ENOENT: no such file or directory, scandir '${PICKED}'`, - }); - const placeTrigger = page.locator("#new-session-place-trigger"); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw", - ); - - const pickedListRequests = (await gateway.getRequests("fs.listDir")).filter( - (request) => - typeof request.params === "object" && - request.params != null && - "path" in request.params && - request.params.path === PICKED, - ).length; - await navigateInApp(page, "chat"); - await waitForCommittedChatRoute(page); - await navigateInApp(page, "new-session"); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw", - ); - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); - await expect - .poll( - async () => - (await gateway.getRequests("fs.listDir")).filter( - (request) => - typeof request.params === "object" && - request.params != null && - "path" in request.params && - request.params.path === PICKED, - ).length, - ) - .toBe(pickedListRequests); - - await message.fill("use the repaired preference"); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(false); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("keeps a newer folder choice when remembered-folder validation finishes late", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [{ name: "packages", path: PICKED }], - }, - }, - { - match: { path: PICKED }, - response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, - }, - ], - }, - "sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - await choosePackagesFolder(page); - - await navigateInApp(page, "chat"); - await waitForCommittedChatRoute(page); - const validationRequests = (await gateway.getRequests("fs.listDir")).length; - await gateway.deferNext("fs.listDir"); - await navigateInApp(page, "new-session"); - await expect - .poll(async () => (await gateway.getRequests("fs.listDir")).length) - .toBeGreaterThan(validationRequests); - - const placeTrigger = page.locator("#new-session-place-trigger"); - await placeTrigger.click(); - await page - .locator('wa-popover.new-session-page__place-popover [data-value="workspace"]') - .click(); - await gateway.resolveDeferred("fs.listDir", { - path: PICKED, - parent: WORKSPACE, - home: "/home/peter", - entries: [], - }); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw", - ); - - await page.locator(".new-session-page__message").fill("keep the newer choice"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("keeps a folder chosen before the agent roster finishes loading submit-ready", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["agents.list"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - try { - await page.goto(`${server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - await trigger.click(); - await page.getByRole("button", { name: "Browse folders" }).click(); - const browserPath = page.locator("input.new-session-page__browser-path"); - // Resolve the browser listing without resolving the deferred agent roster; - // otherwise its initial path update can race Playwright's folder input. - await expect.poll(() => browserPath.inputValue()).toBe(TARGET_REPO); - await browserPath.fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await gateway.resolveDeferred("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - - await page.locator(".new-session-page__message").fill("keep my early folder choice"); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(false); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toContain( - "target-repo", - ); - const storedPreference = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - if (!key) { - return null; - } - const value = JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null; - return value?.agents?.main ?? null; - }); - expect(storedPreference).toMatchObject({ folder: TARGET_REPO }); - } finally { - await context.close(); - } - }); - - it("drafts a session with a browsed folder and creates it on first message", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "fs.listDir": { - cases: [ - { - match: { path: WORKSPACE }, - response: { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [ - { name: "packages", path: PICKED }, - { name: ".git", path: `${WORKSPACE}/.git`, hidden: true }, - ], - }, - }, - { - match: { path: PICKED }, - response: { - path: PICKED, - parent: WORKSPACE, - home: "/home/peter", - entries: [], - }, - }, - ], - }, - "sessions.create": { key: "agent:main:draft-e2e" }, - }, - }); - - try { - // Deep-link to /new: the page loads agents via agents.list (the sidebar - // "+" navigates to the same route with ?agent=). - const response = await page.goto(`${server.baseUrl}new`); - expect(response?.status()).toBe(200); - // The draft page shows the start-screen welcome hero for the agent. - await page.getByRole("heading", { name: "Main" }).waitFor(); - await page.locator(".new-session-page__message").waitFor(); - - // Unified layout: the trigger row (menus above the composer) sits - // inside the start-screen welcome, below the hero. - const heroBox = await page.locator(".agent-chat__welcome h2").boundingBox(); - const triggersBox = await page.locator(".new-session-page__triggers").boundingBox(); - const composerBox = await page.locator(".new-session-page__composer").boundingBox(); - const modelBox = await page.locator('[data-chat-model-select="true"]').boundingBox(); - const modelWrapperBox = await page - .locator(".new-session-page__composer .chat-composer-model-control") - .boundingBox(); - const footerBox = await page - .locator(".new-session-page__composer .agent-chat__composer-footer") - .boundingBox(); - expect(heroBox).not.toBeNull(); - expect(triggersBox).not.toBeNull(); - expect(composerBox).not.toBeNull(); - expect(modelBox).not.toBeNull(); - expect(modelWrapperBox).not.toBeNull(); - expect(footerBox).not.toBeNull(); - expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual( - (triggersBox?.y ?? 0) + 1, - ); - expect((triggersBox?.y ?? 0) + (triggersBox?.height ?? 0)).toBeLessThanOrEqual( - (composerBox?.y ?? 0) + 1, - ); - expect( - await page.locator(".new-session-page__composer .agent-chat__composer-footer").count(), - ).toBe(1); - expect( - await page - .locator('[data-chat-model-select="true"]') - .evaluate((element) => element.closest(".agent-chat__composer-footer") != null), - ).toBe(true); - expect(modelWrapperBox?.x ?? 0).toBeGreaterThan( - (footerBox?.x ?? 0) + (footerBox?.width ?? 0) / 2, - ); - expect( - (footerBox?.x ?? 0) + - (footerBox?.width ?? 0) - - ((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)), - ).toBeLessThanOrEqual(12); - expect(triggersBox?.x).toBeCloseTo(composerBox?.x ?? 0, 0); - expect(triggersBox?.width).toBeCloseTo(composerBox?.width ?? 0, 0); - expect(composerBox?.width).toBeCloseTo(48 * 16, 0); - expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1"); - - // The place trigger labels the workspace and opens the unified menu. - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - const placeTrigger = page.locator("#new-session-place-trigger"); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw", - ); - - // Browse from the workspace, descend one level, then adopt the folder. - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click(); - await expect - .poll(() => page.locator("input.new-session-page__browser-path").inputValue()) - .toBe(PICKED); - await page.getByRole("button", { name: "Use this folder" }).click(); - - // The adopted folder closes the menu and updates the trigger label. - await expect.poll(() => placeSelect.getAttribute("open")).toBeNull(); - await expect - .poll(() => page.evaluate(() => document.activeElement?.id)) - .toBe("new-session-place-trigger"); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "packages", - ); - - // Git-backed custom folders stay direct until the user explicitly chooses isolation. - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); - await placeTrigger.click(); - const worktreeItem = page.getByRole("button", { name: "Worktree" }); - await expect.poll(() => worktreeItem.getAttribute("aria-pressed")).toBe("false"); - expect(await worktreeItem.isEnabled()).toBe(true); - await worktreeItem.click(); - await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); - await page.keyboard.press("Escape"); - await expect - .poll(() => page.evaluate(() => document.activeElement?.id)) - .toBe("new-session-place-trigger"); - - // Pointer light-dismiss still retires the unified popover after its - // asynchronous hide animation completes. - await placeTrigger.click(); - const afterPointerHide = placeSelect.evaluate( - (element) => - new Promise((resolve) => { - element.addEventListener("wa-after-hide", () => resolve(), { once: true }); - }), - ); - await page.locator(".agent-chat__welcome h2").click(); - await afterPointerHide; - await expect.poll(() => placeSelect.getAttribute("open")).toBeNull(); - - const message = page.locator(".new-session-page__message"); - await message.fill("fix the flaky test"); - await page.getByRole("button", { name: "Start thread" }).click(); - - const createRequest = await gateway.waitForRequest("sessions.create"); - expect(createRequest.params).toMatchObject({ - agentId: "main", - message: "fix the flaky test", - worktree: true, - worktreeBaseRef: "main", - cwd: PICKED, - }); - - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath("agent:main:draft-e2e")); - } finally { - await context.close(); - } - }); - - it("runs directly in a custom non-Git Gateway folder", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - cases: [ - { - match: { repoRoot: WORKSPACE }, - response: { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - { - match: { repoRoot: "/home" }, - response: { branches: [], repositoryStatus: "not_git" }, - }, - ], - }, - "sessions.create": { key: "agent:main:plain-folder" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill("/home"); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: "/home", includeRepositoryStatus: true }); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( - "home · Gateway · local", - ); - - await trigger.click(); - expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); - const cloud = place.getByRole("button", { name: "Cloud · aws" }); - expect(await cloud.isDisabled()).toBe(true); - expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree"); - await page.keyboard.press("Escape"); - - await page.locator(".new-session-page__message").fill("clone and inspect this project"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "main", - cwd: "/home", - message: "clone and inspect this project", - }); - expect(create.params).not.toHaveProperty("worktree"); - expect(create.params).not.toHaveProperty("worktreeBaseRef"); - } finally { - await context.close(); - } - }); - - it("hides the destination axis when the Gateway is the only place", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "node.list": { nodes: [] }, - "environments.list": { environments: [], profiles: [] }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const trigger = page.locator("#new-session-place-trigger"); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); - await trigger.click(); - const place = page.locator("wa-popover.new-session-page__place-popover"); - expect(await place.getByText("Places", { exact: true }).count()).toBe(0); - await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); - } finally { - await context.close(); - } - }); - - it("uses advertised system info for Gateway place labels", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - featureMethods: ["chat.metadata", "chat.startup", "system.info"], - methodResponses: { - "system.info": { - machineName: "Peters-Mac-Studio", - hostname: "peters-mac-studio.local", - platform: "darwin", - }, - "node.list": { - nodes: [ - { - nodeId: "macbook", - displayName: "MacBook", - connected: true, - commands: ["system.run"], - }, - ], - }, - "environments.list": { environments: [], profiles: [] }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("system.info"); - const trigger = page.locator("#new-session-place-trigger"); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( - "openclaw · Gateway · Peters-Mac-Studio", - ); - await trigger.click(); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await expect - .poll(() => - page.locator("input.new-session-page__browser-path").getAttribute("placeholder"), - ) - .toBe("Gateway · Peters-Mac-Studio"); - - await gateway.setMethodResponse("node.list", { nodes: [] }); - const nodeRequests = (await gateway.getRequests("node.list")).length; - await replaceGatewayClient(page); - await expect - .poll(async () => (await gateway.getRequests("node.list")).length) - .toBeGreaterThan(nodeRequests); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); - await trigger.click(); - await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor(); - } finally { - await context.close(); - } - }); - - it("disambiguates duplicate node names without changing the selected chip", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "node.list": { - nodes: [ - { - nodeId: "11111111aaaaaaaa", - displayName: "Mac Studio", - platform: "darwin", - modelIdentifier: "Mac14,12", - remoteIp: "192.168.1.11", - connected: true, - commands: ["system.run"], - }, - { - nodeId: "22222222bbbbbbbb", - displayName: "Mac Studio", - platform: "darwin", - modelIdentifier: "Mac15,14", - remoteIp: "192.168.1.12", - connected: true, - commands: ["system.run"], - }, - { - nodeId: "33333333cccccccc", - displayName: "iPhone", - platform: "iOS 26.4", - deviceFamily: "iPhone", - modelIdentifier: "iPhone17,2", - remoteIp: "192.168.1.30", - connected: true, - commands: ["system.run"], - }, - ], - }, - "environments.list": { environments: [], profiles: [] }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const trigger = page.locator("#new-session-place-trigger"); - await trigger.click(); - const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); - const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); - const phone = page.locator('[data-value="node:33333333cccccccc"]'); - await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12"); - await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14"); - await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone"); - expect(await first.locator(".session-menu__icon svg").count()).toBe(1); - expect(await second.locator(".session-menu__icon svg").count()).toBe(1); - expect(await phone.locator(".session-menu__icon svg").count()).toBe(1); - expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11"); - expect(await second.getAttribute("title")).toContain("192.168.1.12"); - await second.click(); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( - "Agent workspace · Mac Studio", - ); - expect(await trigger.textContent()).not.toContain("Mac15,14"); - expect(await trigger.textContent()).not.toContain("192.168.1.12"); - } finally { - await context.close(); - } - }); - - it("disambiguates duplicate recent basenames and applies the selected path", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "node.list": { nodes: [] }, - "environments.list": { environments: [], profiles: [] }, - "sessions.list": { - count: 2, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [ - { key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" }, - { key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" }, - ], - ts: Date.now(), - }, - "sessions.create": { key: "agent:main:recent-collision" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const trigger = page.locator("#new-session-place-trigger"); - await trigger.click(); - const first = page.locator('[data-value="recent::/a/openclaw"]'); - const second = page.locator('[data-value="recent::/b/openclaw"]'); - await pollLocatorText(first.locator(".session-menu__sub")).toBe("a"); - await pollLocatorText(second.locator(".session-menu__sub")).toBe("b"); - await second.click(); - await page.locator(".new-session-page__message").fill("continue in work checkout"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - cwd: "/b/openclaw", - message: "continue in work checkout", - }); - } finally { - await context.close(); - } - }); - - it("applies a recent folder and node as one place", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "node.list": { - nodes: [ - { - nodeId: "macbook", - displayName: "MacBook", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "sessions.list": { - count: 2, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [ - { - key: "agent:main:recent-node", - kind: "direct", - updatedAt: 2, - execCwd: NODE_PICKED, - execNode: "macbook", - }, - { - key: "agent:main:workspace", - kind: "direct", - updatedAt: 1, - execCwd: WORKSPACE, - }, - ], - ts: Date.now(), - }, - "sessions.create": { key: "agent:main:recent-place" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const trigger = page.locator("#new-session-place-trigger"); - await trigger.click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Projects · MacBook", exact: true }) - .click(); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( - "Projects · MacBook", - ); - - await page.locator(".new-session-page__message").fill("continue on the recent node"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - cwd: NODE_PICKED, - execNode: "macbook", - message: "continue on the recent node", - }); - } finally { - await context.close(); - } - }); - - it("returns from the browse root to the place menu", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "fs.listDir": { - path: WORKSPACE, - home: WORKSPACE, - entries: [], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await gateway.waitForRequest("fs.listDir"); - await place.getByRole("button", { name: "Parent folder" }).click(); - await place.getByRole("button", { name: "Worktree" }).waitFor(); - expect(await place.getAttribute("open")).not.toBeNull(); - - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").press("Escape"); - await place.getByRole("button", { name: "Worktree" }).waitFor(); - expect(await place.getAttribute("open")).not.toBeNull(); - } finally { - await context.close(); - } - }); - - it("dispatches a cloud target before sending its first turn and shows placement", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:cloud:cloud-e2e"; - const gateway = await installMockGateway(page, { - defaultAgentId: "cloud", - deferredMethods: ["sessions.dispatch"], - featureMethods: ["chat.metadata", "chat.startup", "sessions.reclaim"], - workspaceGit: true, - sessionKey: "agent:cloud:neutral-e2e", - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "fs.listDir": { - path: WORKSPACE, - parent: "/home/peter", - home: "/home/peter", - entries: [], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - "sessions.list": createdSessionListResult(sessionKey), - "sessions.dispatch": { - ok: true, - key: sessionKey, - sessionId: "session-cloud-e2e", - placement: { - state: "active", - generation: 1, - createdAtMs: 1, - updatedAtMs: 2, - stateChangedAtMs: 2, - environmentId: "worker-1", - activeOwnerEpoch: 1, - workerBundleHash: "a".repeat(64), - workspaceBaseManifestRef: "manifest-1", - remoteWorkspaceDir: "/workspace", - }, - }, - "sessions.describe": { session: {} }, - "sessions.delete": { ok: true, deleted: true }, - "sessions.reclaim": { ok: true }, - "sessions.send": { runId: "run-cloud-e2e", status: "started" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - expect( - await page.evaluate(() => ({ - hasSubtleCrypto: Boolean(globalThis.crypto.subtle), - isSecureContext: globalThis.isSecureContext, - })), - ).toEqual({ hasSubtleCrypto: true, isSecureContext: true }); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await place.getByRole("button", { name: "Cloud · aws" }).click(); - const trigger = page.locator("#new-session-place-trigger"); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("main"); - - const modelSelect = page.locator( - '.new-session-page__composer [data-chat-model-select="true"]', - ); - await modelSelect.click(); - await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-select")).toBe("true"); - const thinkingSlider = page.locator( - '.new-session-page__composer [data-chat-thinking-slider="true"]', - ); - await expect - .poll(() => thinkingSlider.getAttribute("data-chat-thinking-values")) - .toBe("off,minimal,low,medium,high"); - await expect - .poll(() => page.locator(".new-session-page__composer [data-chat-speed-toggle]").count()) - .toBe(0); - await thinkingSlider.press("End"); - await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); - await captureUiProof(page, "01-cloud-thinking-level.png"); - await modelSelect.click(); - await expect - .poll(() => modelSelect.evaluate((element) => element.closest("details")?.open ?? false)) - .toBe(false); - - // Picking a Gateway repo keeps the cloud selection: that folder is what - // the managed worktree checks out and dispatch syncs to the worker. - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - await trigger.click(); - await pollLocatorText(place.locator(".new-session-page__menu-note")).toContain( - "Syncs target-repo to the cloud worker", - ); - await captureUiProof(page, "01-cloud-worker-target.png"); - await page.keyboard.press("Escape"); - - const message = "fix the cloud-only failure"; - const composer = page.locator(".new-session-page__message"); - await composer.fill(message); - await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - const startButton = page.getByRole("button", { name: "Start thread" }); - await gateway.deferNext("environments.list"); - const profileRequests = (await gateway.getRequests("environments.list")).length; - await replaceGatewayClient(page); - await expect - .poll(async () => (await gateway.getRequests("environments.list")).length) - .toBeGreaterThan(profileRequests); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => startButton.isDisabled()).toBe(true); - await gateway.rejectDeferred("environments.list", { - code: "UNAVAILABLE", - message: "profile lookup unavailable", - }); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => startButton.isDisabled()).toBe(true); - const failedProfileRequests = (await gateway.getRequests("environments.list")).length; - await expect - .poll(async () => (await gateway.getRequests("environments.list")).length) - .toBeGreaterThan(failedProfileRequests); - await expect.poll(() => startButton.isDisabled()).toBe(false); - - await startButton.click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "cloud", - message: "", - worktree: true, - worktreeBaseRef: "main", - cwd: TARGET_REPO, - thinkingLevel: "high", - }); - expect(create.params).not.toHaveProperty("attachments"); - await gateway.waitForRequest("sessions.dispatch"); - await gateway.rejectDeferred("sessions.dispatch", { - code: "UNAVAILABLE", - message: "allocation response lost", - }); - await pollLocatorText(page.locator(".new-session-page__error")).toContain( - "cloud worker placement could not be verified", - ); - const alert = page.locator(".new-session-page__alert"); - await expect.poll(() => alert.getAttribute("role")).toBe("alert"); - await expect.poll(() => alert.locator("svg").count()).toBe(1); - const [alertBox, composerBox] = await Promise.all([ - alert.boundingBox(), - page.locator(".new-session-page__composer").boundingBox(), - ]); - expect(alertBox).not.toBeNull(); - expect(composerBox).not.toBeNull(); - expect( - Math.abs( - (alertBox?.x ?? 0) + - (alertBox?.width ?? 0) / 2 - - ((composerBox?.x ?? 0) + (composerBox?.width ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); - await expect.poll(() => startButton.isDisabled()).toBe(false); - await page.getByRole("button", { name: "Start thread" }).click(); - await expect - .poll(async () => (await gateway.getRequests("sessions.dispatch")).length) - .toBe(2); - expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - expect(await gateway.getRequests("sessions.delete")).toHaveLength(0); - const dispatches = await gateway.getRequests("sessions.dispatch"); - expect(dispatches.at(-1)?.params).toEqual({ - key: sessionKey, - agentId: "cloud", - profileId: "aws", - }); - const send = await gateway.waitForRequest("sessions.send"); - expect(send.params).toMatchObject({ - key: sessionKey, - agentId: "cloud", - message, - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - const orderedMethods = (await gateway.getRequests()) - .map((request) => request.method) - .filter((method) => - ["sessions.create", "sessions.dispatch", "sessions.send"].includes(method), - ); - expect(orderedMethods).toEqual([ - "sessions.create", - "sessions.dispatch", - "sessions.dispatch", - "sessions.send", - ]); - - await gateway.setMethodResponse("sessions.list", { - count: 4, - path: "", - defaults: {}, - sessions: [ - { - key: sessionKey, - kind: "direct", - label: "Cloud session", - updatedAt: Date.now(), - worktree: { id: "worktree-1", branch: "openclaw/cloud-e2e", repoRoot: WORKSPACE }, - placement: { state: "active" }, - }, - { - key: "agent:cloud:managed-e2e", - kind: "direct", - label: "Managed session", - updatedAt: Date.now() - 1, - placement: { state: "active" }, - }, - { - key: "agent:cloud:local-e2e", - kind: "direct", - label: "Local session", - updatedAt: Date.now() - 2, - placement: { state: "local" }, - }, - { - key: "agent:cloud:neutral-e2e", - kind: "direct", - label: "Neutral session", - updatedAt: Date.now() - 3, - placement: { state: "local" }, - }, - ], - ts: Date.now(), - }); - await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" }); - await page.goto(controlUiSessionUrl(server.baseUrl, "agent:cloud:neutral-e2e")); - const managedSessionKey = "agent:cloud:managed-e2e"; - const sessionRow = page.locator(`[data-session-key="${managedSessionKey}"]`); - const localSessionRow = page.locator('[data-session-key="agent:cloud:local-e2e"]'); - await sessionRow.waitFor(); - await localSessionRow.waitFor(); - const cloudPlacementBadge = sessionRow.locator('[data-placement-state="active"]'); - await cloudPlacementBadge.waitFor(); - await sessionRow.hover(); - await sessionRow.getByRole("button", { name: "Open thread menu" }).click(); - const stopWorker = page - .locator("openclaw-session-menu") - .getByRole("menuitem", { name: "Stop cloud worker…" }); - await stopWorker.waitFor(); - await captureUiProof(page, "02-active-cloud-worker-stop.png"); - expect(await localSessionRow.locator(".session-row-badge--cloud").count()).toBe(0); - expect(await cloudPlacementBadge.locator("circle").count()).toBe(1); - expect(await cloudPlacementBadge.locator("rect").count()).toBe(0); - page.once("dialog", (dialog) => void dialog.accept()); - await stopWorker.click(); - const reclaim = await gateway.waitForRequest("sessions.reclaim"); - expect(reclaim.params).toEqual({ key: managedSessionKey, agentId: "cloud" }); - } finally { - await context.close(); - } - }); - - it("clears cloud placement when the selected agent changes", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "local", - identity: { name: "Local" }, - name: "Local", - workspace: "/home/peter/local", - workspaceGit: false, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - const trigger = page.locator("#new-session-place-trigger"); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - - await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] }); - const profileRequests = (await gateway.getRequests("environments.list")).length; - await replaceGatewayClient(page); - await expect - .poll(async () => (await gateway.getRequests("environments.list")).length) - .toBeGreaterThan(profileRequests); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await pollLocatorText(trigger).toContain("Cloud · aws"); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(true); - await trigger.click(); - await expect - .poll(() => - page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .isDisabled(), - ) - .toBe(true); - await page.keyboard.press("Escape"); - - const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); - await agentPicker.locator(".agent-select__trigger").click(); - await agentPicker - .locator("wa-dropdown-item[data-agent-option]") - .filter({ hasText: "Local" }) - .click(); - await page.getByRole("heading", { name: "Local" }).waitFor(); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBeNull(); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false"); - } finally { - await context.close(); - } - }); - - it("restores a cloud startup after a page reload without creating another session", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const sessionKey = "agent:cloud:reload-recovery"; - const message = "resume this cloud task after reload"; - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - "sessions.dispatch": { - ok: true, - key: sessionKey, - sessionId: "session-reload-recovery", - placement: { - state: "active", - generation: 1, - createdAtMs: 1, - updatedAtMs: 2, - stateChangedAtMs: 2, - environmentId: "worker-reload-recovery", - activeOwnerEpoch: 1, - workerBundleHash: "a".repeat(64), - workspaceBaseManifestRef: "manifest-reload-recovery", - remoteWorkspaceDir: "/workspace", - }, - }, - "sessions.list": { - count: 1, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }], - ts: Date.now(), - }, - "chat.history": { - messages: [], - sessionId: "session-reload-recovery", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - await page.evaluate(() => { - const originalSetItem = sessionStorage.setItem.bind(sessionStorage); - Storage.prototype.setItem = function (key: string, value: string) { - if ( - key.startsWith("openclaw.new-session.cloud-recovery.v1:") || - key.startsWith("openclaw.control-ui-e2e.") - ) { - originalSetItem(key, value); - return; - } - throw new DOMException("composer storage disabled", "SecurityError"); - }; - }); - await gateway.deferNext("sessions.send"); - await page.locator(".new-session-page__message").fill(message); - await pastePng(page.locator(".new-session-page__message")); - await page.getByRole("button", { name: "Start thread" }).click(); - const firstSend = await gateway.waitForRequest("sessions.send"); - expect(firstSend.params).toMatchObject({ - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - await gateway.rejectDeferred("sessions.send", { - code: "UNAVAILABLE", - message: "send outcome unknown", - }); - await pollLocatorText(page.locator(".new-session-page__error")).toContain( - "send outcome unknown", - ); - await gateway.setMethodResponse("sessions.send", { - runId: "run-reload-recovery", - status: "started", - }); - - await page.reload(); - await gateway.waitForRequest("environments.list"); - await expect - .poll(() => page.locator(".new-session-page__message").inputValue()) - .toBe(message); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await expect - .poll(() => page.getByRole("button", { name: "Remove attachment" }).isDisabled()) - .toBe(true); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(false); - await page.getByRole("button", { name: "Start thread" }).click(); - const resumedSend = await gateway.waitForRequest("sessions.send"); - expect(resumedSend.params).toMatchObject({ - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey, - key: sessionKey, - message, - }); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - } finally { - await context.close(); - } - }); - - it("restores cloud recovery added while the Gateway is disconnected", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - const recoveryIdentity = await page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - context: { - gateway: { - connection: { gatewayUrl: string }; - snapshot: { client?: { recoveryScope?: string } | null }; - }; - }; - }; - }; - const gatewaySnapshot = app.runtime?.context.gateway; - const gatewayUrl = gatewaySnapshot?.connection.gatewayUrl ?? ""; - const recoveryScope = gatewaySnapshot?.snapshot.client?.recoveryScope ?? ""; - if (!gatewayUrl || !recoveryScope) { - throw new Error("Gateway recovery identity is unavailable"); - } - return { gatewayUrl, recoveryScope }; - }); - - await gateway.setOnline(false); - await expect - .poll(() => - page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { context: { gateway: { snapshot: { phase: string } } } }; - }; - return app.runtime?.context.gateway.snapshot.phase === "connected"; - }), - ) - .toBe(false); - await page.evaluate(({ gatewayUrl, recoveryScope }) => { - sessionStorage.setItem( - `openclaw.new-session.cloud-recovery.v1:${gatewayUrl}:${recoveryScope}`, - JSON.stringify({ - sessionKey: "agent:cloud:offline-recovery", - messageId: "message-offline-recovery", - message: "restore after reconnect", - attachments: [ - { - type: "image", - mimeType: "image/png", - fileName: "pixel.png", - content: - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=", - }, - ], - profileId: "aws", - agentId: "cloud", - gatewayUrl, - recoveryScope, - phase: "sending", - }), - ); - }, recoveryIdentity); - - await gateway.setOnline(true); - await expect - .poll(() => page.locator(".new-session-page__message").inputValue()) - .toBe("restore after reconnect"); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(false); - await page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - context: { - gateway: { - snapshot: { - client?: { recoveryScopeTracker?: { ready: boolean } } | null; - }; - }; - }; - }; - }; - const client = app.runtime?.context.gateway.snapshot.client; - if (!client?.recoveryScopeTracker) { - throw new Error("Gateway recovery tracker is unavailable"); - } - client.recoveryScopeTracker.ready = false; - ( - document.querySelector("openclaw-new-session-page") as - | (HTMLElement & { requestUpdate: () => void }) - | null - )?.requestUpdate(); - }); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) - .toBe(true); - } finally { - await context.close(); - } - }); - - it("retries an ambiguous cloud create with the same session key", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const message = "recover the cloud create"; - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.create"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.dispatch": { - placement: { state: "active", environmentId: "worker-create-recovery" }, - }, - "sessions.send": { runId: "run-create-recovery", status: "started" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - await page.locator(".new-session-page__message").fill(message); - await page.getByRole("button", { name: "Start thread" }).click(); - const firstCreate = await gateway.waitForRequest("sessions.create"); - const firstKey = (firstCreate.params as { key?: string }).key; - if (!firstKey) { - throw new Error("expected the first recovery create to include a session key"); - } - expect(firstKey).toMatch(/^agent:cloud:dashboard:/); - - await page.reload(); - await gateway.waitForRequest("environments.list"); - await expect - .poll(() => page.locator(".new-session-page__message").inputValue()) - .toBe(message); - await page.getByRole("button", { name: "Start thread" }).click(); - const retryCreate = await gateway.waitForRequest("sessions.create"); - expect(retryCreate.params).toMatchObject({ key: firstKey, message: "", worktree: true }); - await gateway.resolveDeferred("sessions.create", { key: firstKey }); - - expect(await gateway.waitForRequest("sessions.dispatch")).toMatchObject({ - params: { key: firstKey, agentId: "cloud", profileId: "aws" }, - }); - expect(await gateway.waitForRequest("sessions.send")).toMatchObject({ - params: { key: firstKey, agentId: "cloud", message }, - }); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(firstKey), { - timeout: 30_000, - }); - } finally { - await context.close(); - } - }); - - it("keeps the original recovery identity when a cloud create settles after reset", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const message = "preserve this late cloud create"; - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.create", "sessions.delete"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - const readRecovery = () => - page.evaluate(() => { - const key = Object.keys(sessionStorage).find((candidate) => - candidate.startsWith("openclaw.new-session.cloud-recovery.v1:"), - ); - return key ? (JSON.parse(sessionStorage.getItem(key) ?? "null") as unknown) : null; - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - await page.locator(".new-session-page__message").fill(message); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - const sessionKey = (create.params as { key: string }).key; - const staged = await readRecovery(); - - await page.evaluate(() => { - history.pushState(null, "", "new?agent=cloud"); - dispatchEvent(new PopStateEvent("popstate")); - }); - await gateway.resolveDeferred("sessions.create", { key: sessionKey }); - await gateway.waitForRequest("sessions.delete"); - await gateway.rejectDeferred("sessions.delete", { - code: "UNAVAILABLE", - message: "cleanup unavailable", - }); - - await pollLocatorText(page.locator(".new-session-page__error")).toContain( - "cleanup unavailable", - ); - const stagedIdentity = staged as { messageId: string; profileId: string; agentId: string }; - expect(await readRecovery()).toMatchObject({ - sessionKey, - messageId: stagedIdentity.messageId, - message, - profileId: stagedIdentity.profileId, - agentId: stagedIdentity.agentId, - phase: "dispatching", - }); - } finally { - await context.close(); - } - }); - - it("retries an unpersisted cloud turn with its original recovery identity", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:cloud:storage-recovery"; - const message = "keep this cloud recovery task"; - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.send"], - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "cloud", - identity: { name: "Cloud" }, - name: "Cloud", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "cloud", - mainKey: "main", - scope: "agent", - }, - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - "sessions.dispatch": { - ok: true, - key: sessionKey, - sessionId: "session-storage-recovery", - placement: { - state: "active", - generation: 1, - createdAtMs: 1, - updatedAtMs: 2, - stateChangedAtMs: 2, - environmentId: "worker-storage-recovery", - activeOwnerEpoch: 1, - workerBundleHash: "a".repeat(64), - workspaceBaseManifestRef: "manifest-storage-recovery", - remoteWorkspaceDir: "/workspace", - }, - }, - "sessions.list": { - count: 1, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [{ key: sessionKey, kind: "direct", updatedAt: Date.now() }], - ts: Date.now(), - }, - "chat.history": { - messages: [], - sessionId: "session-storage-recovery", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - await page.locator("#new-session-place-trigger").click(); - await page - .locator("wa-popover.new-session-page__place-popover") - .getByRole("button", { name: "Cloud · aws" }) - .click(); - await page.evaluate(() => { - const originalSetItem = sessionStorage.setItem.bind(sessionStorage); - Storage.prototype.setItem = function (key: string, value: string) { - if (key.startsWith("openclaw.new-session.cloud-recovery.v1:")) { - originalSetItem(key, value); - return; - } - throw new DOMException("composer storage disabled", "SecurityError"); - }; - }); - await page.locator(".new-session-page__message").fill(message); - await page.getByRole("button", { name: "Start thread" }).click(); - const firstSend = await gateway.waitForRequest("sessions.send"); - await gateway.rejectDeferred("sessions.send", { - code: "UNAVAILABLE", - message: "send outcome unknown", - }); - - await pollLocatorText(page.locator(".new-session-page__error")).toContain( - "send outcome unknown", - ); - await expect.poll(() => page.locator(".new-session-page__message").isDisabled()).toBe(true); - expect(await page.locator(".new-session-page__message").inputValue()).toBe(message); - expect(new URL(page.url()).pathname).toContain("/new"); - await gateway.setMethodResponse("environments.list", { environments: [], profiles: [] }); - const profileRequests = (await gateway.getRequests("environments.list")).length; - await replaceGatewayClient(page); - await expect - .poll(async () => (await gateway.getRequests("environments.list")).length) - .toBeGreaterThan(profileRequests); - await page.getByRole("button", { name: "Start thread" }).click(); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - - const sends = await gateway.getRequests("sessions.send"); - expect(sends).toHaveLength(2); - expect(sends[1]?.params).toMatchObject({ - idempotencyKey: (firstSend.params as { idempotencyKey: string }).idempotencyKey, - key: sessionKey, - message, - }); - expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - const dispatches = await gateway.getRequests("sessions.dispatch"); - expect(dispatches).toHaveLength(2); - expect(dispatches[1]?.params).toMatchObject({ profileId: "aws" }); - } finally { - await context.close(); - } - }); - - it("creates a catalog-targeted draft with its advertised model", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.catalog.list": { - catalogs: [ - { - id: "claude", - label: "Claude Code", - capabilities: { - continueSession: true, - archive: false, - createSession: { model: "anthropic/claude-opus-4-8" }, - }, - hosts: [], - }, - ], - }, - "sessions.create": { key: "agent:main:claude-draft" }, - }, - }); - - try { - const model = "anthropic/claude-opus-4-8"; - await page.goto( - `${server.baseUrl}new?agent=Research&catalog=claude&model=${encodeURIComponent("openai/gpt-5")}&label=Spoofed`, - ); - - const catalogRequest = await gateway.waitForRequest("sessions.catalog.list"); - expect(catalogRequest.params).toMatchObject({ - agentId: "research", - catalogId: "claude", - }); - const runtime = page.locator(".new-session-page__runtime"); - await pollLocatorText(runtime).toContain("Claude Code"); - expect(await runtime.getAttribute("title")).toBe(model); - expect(await page.locator('.new-session-page__trigger[title="Agent"]').count()).toBe(0); - expect(await page.locator('[data-chat-model-select="true"]').count()).toBe(0); - - await page.locator(".new-session-page__message").fill("use Claude Code"); - await page.getByRole("button", { name: "Start thread" }).click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "research", - message: "use Claude Code", - catalogId: "claude", - }); - expect(create.params).not.toHaveProperty("model"); - } finally { - await context.close(); - } - }); - - it("navigates to a created session while canonical session refresh is pending", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:refresh-overlap-e2e"; - const listResponse = { - count: 0, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [], - ts: Date.now(), - }; - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "sessions.create": { key: sessionKey }, - "sessions.list": listResponse, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const message = page.locator(".new-session-page__message"); - await message.waitFor({ state: "visible", timeout: 10_000 }); - const listCalls = (await gateway.getRequests("sessions.list")).length; - - await gateway.deferNext("sessions.list"); - await gateway.emitGatewayEvent("sessions.changed", { - key: "agent:main:other-client", - kind: "direct", - reason: "update", - sessionKey: "agent:main:other-client", - updatedAt: Date.now(), - }); - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length) - .toBe(listCalls + 1); - - await message.fill("create during refresh"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "main", - message: "create during refresh", - }); - await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionKey)); - - await gateway.resolveDeferred("sessions.list", listResponse); - } finally { - await context.close(); - } - }); - - it("resolves a pending catalog target after reconnect without clearing the draft", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.catalog.list": { - catalogs: [ - { - id: "claude", - label: "Claude Code", - capabilities: { - continueSession: true, - archive: false, - createSession: { model: "anthropic/claude-opus-4-8" }, - }, - hosts: [], - }, - ], - }, - "sessions.create": { key: "agent:research:claude-reconnect" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new?agent=research`); - await page.getByRole("heading", { name: "Research" }).waitFor(); - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - - await page.evaluate(() => { - history.pushState(null, "", "new?agent=research&catalog=claude"); - dispatchEvent(new PopStateEvent("popstate")); - }); - - const message = page.locator(".new-session-page__message"); - await message.fill("keep this reconnect draft"); - await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("claude"); - await expect - .poll(() => page.getByRole("button", { name: "Start thread" }).isEnabled()) - .toBe(false); - expect(await gateway.getRequests("sessions.catalog.list")).toHaveLength(0); - - await gateway.deferNext("sessions.catalog.list"); - await gateway.setOnline(true); - await gateway.waitForRequest("sessions.catalog.list"); - await gateway.deferNext("sessions.catalog.list"); - await gateway.rejectDeferred("sessions.catalog.list", { - code: "UNAVAILABLE", - message: "catalog warming up", - retryable: true, - }); - await expect - .poll(async () => (await gateway.getRequests("sessions.catalog.list")).length) - .toBe(2); - await gateway.resolveDeferred("sessions.catalog.list", { catalogs: [] }); - await expect - .poll(async () => (await gateway.getRequests("sessions.catalog.list")).length, { - timeout: 10_000, - }) - .toBe(3); - await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); - await expect.poll(() => message.inputValue()).toBe("keep this reconnect draft"); - await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); - - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "research", - message: "keep this reconnect draft", - catalogId: "claude", - }); - expect(create.params).not.toHaveProperty("model"); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("preserves a manually selected agent across a same-client reconnect", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: "agent:research:manual-reconnect" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await page.getByRole("heading", { name: "Main" }).waitFor(); - await gateway.waitForRequest("worktrees.branches"); - const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); - await agentPicker.locator(".agent-select__trigger").click(); - await agentPicker - .locator("wa-dropdown-item[data-agent-option]") - .filter({ hasText: "Research" }) - .click(); - await page.getByRole("heading", { name: "Research" }).waitFor(); - - const message = page.locator(".new-session-page__message"); - await message.fill("keep my selected agent"); - const agentRequestsBefore = (await gateway.getRequests("agents.list")).length; - const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length; - - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: REFRESHED_RESEARCH_WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - await gateway.setOnline(true); - - await expect - .poll(async () => (await gateway.getRequests("agents.list")).length) - .toBe(agentRequestsBefore + 1); - await expect.poll(() => message.inputValue()).toBe("keep my selected agent"); - await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); - await pollLocatorText( - page.locator("#new-session-place-trigger .new-session-page__trigger-label"), - ).toBe("research-next"); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequestsBefore + 1); - expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({ - repoRoot: REFRESHED_RESEARCH_WORKSPACE, - includeRepositoryStatus: true, - }); - - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - const placeTrigger = page.locator("#new-session-place-trigger"); - await placeTrigger.click(); - const worktreeItem = placeSelect.getByRole("button", { name: "Worktree" }); - await worktreeItem.click(); - const baseInput = page.getByLabel("Base branch"); - await expect.poll(() => baseInput.inputValue()).toBe("main"); - await page.keyboard.press("Escape"); - - await gateway.deferNext("worktrees.branches"); - const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches")) - .length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchesBeforeSameWorkspaceReconnect + 1); - expect((await gateway.getRequests("worktrees.branches")).at(-1)?.params).toEqual({ - repoRoot: REFRESHED_RESEARCH_WORKSPACE, - includeRepositoryStatus: true, - }); - expect(await baseInput.inputValue()).toBe(""); - expect(await baseInput.getAttribute("placeholder")).toBe("Loading…"); - await placeTrigger.click(); - await baseInput.fill("feature-choice"); - await gateway.resolveDeferred("worktrees.branches", { - branches: [{ kind: "local", name: "beta" }], - defaultBranch: "beta", - repositoryStatus: "git", - }); - await expect.poll(() => baseInput.inputValue()).toBe("feature-choice"); - - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "research", - message: "keep my selected agent", - worktree: true, - worktreeBaseRef: "feature-choice", - }); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("preserves a selected workspace worktree when branch rediscovery is unavailable", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: "agent:main:worktree-unavailable" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("worktrees.branches"); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Worktree" }).click(); - await page.keyboard.press("Escape"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.deferNext("worktrees.branches"); - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); - await gateway.rejectDeferred("worktrees.branches", { - code: "UNAVAILABLE", - message: "branch lookup unavailable", - }); - - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - await trigger.click(); - const worktree = place.getByRole("button", { name: "Worktree" }); - await expect.poll(() => worktree.getAttribute("aria-pressed")).toBe("true"); - expect(await worktree.isEnabled()).toBe(true); - await page.keyboard.press("Escape"); - - await page.locator(".new-session-page__message").fill("keep this task isolated"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "main", - message: "keep this task isolated", - worktree: true, - }); - } finally { - await context.close(); - } - }); - - it("clears a custom worktree when the folder becomes confirmed non-Git", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: "agent:main:custom-now-direct" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); - await trigger.click(); - await place.getByRole("button", { name: "Worktree" }).click(); - await page.keyboard.press("Escape"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - - await gateway.setMethodResponse("worktrees.branches", { - branches: [], - repositoryStatus: "not_git", - }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); - - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false"); - const storedWorktree = await page.evaluate(() => { - const key = Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index), - ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); - const value = key - ? (JSON.parse(localStorage.getItem(key) ?? "null") as { - agents?: Record; - } | null) - : null; - return value?.agents?.main?.worktree; - }); - expect(storedWorktree).toBe(false); - await trigger.click(); - expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); - await page.keyboard.press("Escape"); - await page.locator(".new-session-page__message").fill("continue directly"); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ cwd: TARGET_REPO, message: "continue directly" }); - expect(create.params).not.toHaveProperty("worktree"); - } finally { - await context.close(); - } - }); - - it("blocks a custom worktree when Git rediscovery is unavailable", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); - await trigger.click(); - await place.getByRole("button", { name: "Worktree" }).click(); - await page.keyboard.press("Escape"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - - await gateway.setMethodResponse("worktrees.branches", { - branches: [], - repositoryStatus: "unavailable", - }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); - - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - await page.locator(".new-session-page__message").fill("do not run directly"); - const start = page.getByRole("button", { name: "Start thread" }); - await expect.poll(() => start.isDisabled()).toBe(true); - await trigger.click(); - const worktree = place.getByRole("button", { name: "Worktree" }); - expect(await worktree.isDisabled()).toBe(true); - expect(await worktree.getAttribute("title")).toBe( - "Couldn't verify Git for this folder. Choose it again to retry.", - ); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await context.close(); - } - }); - - it("blocks a custom cloud worktree when Git rediscovery is unavailable", async () => { - const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "environments.list": { - environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], - }, - "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("environments.list"); - const trigger = page.locator("#new-session-place-trigger"); - const place = page.locator("wa-popover.new-session-page__place-popover"); - await trigger.click(); - await place.getByRole("button", { name: "Browse folders" }).click(); - await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); - await page.getByRole("button", { name: "Use this folder" }).click(); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); - await trigger.click(); - await place.getByRole("button", { name: "Cloud · aws" }).click(); - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - - await gateway.setMethodResponse("worktrees.branches", { - branches: [], - repositoryStatus: "unavailable", - }); - const branchRequests = (await gateway.getRequests("worktrees.branches")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchRequests + 1); - - await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); - await page.locator(".new-session-page__message").fill("do not run directly"); - const start = page.getByRole("button", { name: "Start thread" }); - await expect.poll(() => start.isDisabled()).toBe(true); - await trigger.click(); - const cloud = place.getByRole("button", { name: "Cloud · aws" }); - expect(await cloud.isDisabled()).toBe(true); - expect(await cloud.getAttribute("title")).toBe( - "Couldn't verify Git for this folder. Choose it again to retry.", - ); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await context.close(); - } - }); - - it("validates a retained device before enabling submit after reconnect", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "node.list": { - nodes: [ - { - nodeId: "old-device", - displayName: "Old device", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: "agent:main:validated-device" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - await page.locator("#new-session-place-trigger").click(); - await placeSelect.getByRole("button", { name: "Old device" }).click(); - await page.locator(".new-session-page__message").fill("use a validated device"); - const start = page.locator("button.chat-send-btn"); - const nodeRequestsBefore = (await gateway.getRequests("node.list")).length; - - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.deferNext("node.list"); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("node.list")).length) - .toBe(nodeRequestsBefore + 1); - await expect.poll(() => start.isDisabled()).toBe(true); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - - await gateway.resolveDeferred("node.list", { nodes: [] }); - await expect.poll(() => start.isEnabled()).toBe(true); - await start.click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).not.toHaveProperty("execNode"); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("rediscovers Gateway-owned draft state when the app replaces its client", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Original agent" }, - name: "Original agent", - workspace: SOURCE_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "node.list": { - nodes: [ - { - nodeId: "old-device", - displayName: "Old device", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "alpha" }], - defaultBranch: "alpha", - repositoryStatus: "git", - }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await page.getByRole("heading", { name: "Original agent" }).waitFor(); - await gateway.waitForRequest("node.list"); - await gateway.waitForRequest("worktrees.branches"); - - const message = page.locator(".new-session-page__message"); - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - const placeTrigger = page.locator("#new-session-place-trigger"); - await message.fill("preserve this replacement draft"); - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Old device" }).click(); - - // Keep an old-client browser request in flight. Replacement must close - // its menu and prevent its eventual completion from reviving old state. - await gateway.deferNext("fs.listDir"); - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await gateway.waitForRequest("fs.listDir"); - - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Replacement agent" }, - name: "Replacement agent", - workspace: TARGET_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - await gateway.setMethodResponse("node.list", { - nodes: [ - { - nodeId: "new-device", - displayName: "New device", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - ], - }); - await gateway.setMethodResponse("worktrees.branches", { - branches: [{ kind: "local", name: "beta" }], - defaultBranch: "beta", - repositoryStatus: "git", - }); - const socketsBefore = await gateway.getSocketCount(); - const nodesBefore = (await gateway.getRequests("node.list")).length; - const branchesBefore = (await gateway.getRequests("worktrees.branches")).length; - - await replaceGatewayClient(page); - - await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1); - await expect - .poll(async () => (await gateway.getRequests("node.list")).length) - .toBe(nodesBefore + 1); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).length) - .toBe(branchesBefore + 1); - await page.getByRole("heading", { name: "Replacement agent" }).waitFor(); - await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); - await expect - .poll(() => - placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), - ) - .toBe(false); - await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( - "target-repo · Gateway · local", - ); - - const branchRequests = await gateway.getRequests("worktrees.branches"); - expect(branchRequests.at(-1)?.params).toEqual({ - repoRoot: TARGET_REPO, - includeRepositoryStatus: true, - }); - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "New device" }).waitFor(); - expect(await placeSelect.getByRole("button", { name: "Old device" }).count()).toBe(0); - await placeSelect.getByRole("button", { name: "Worktree" }).click(); - await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("beta"); - await page.keyboard.press("Escape"); - - await gateway.resolveDeferred("fs.listDir", { - path: "/stale-device-path", - home: "/stale-device-path", - entries: [], - }); - await expect - .poll(() => - placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), - ) - .toBe(false); - await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); - } finally { - await context.close(); - } - }); - - for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) { - it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`; - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Original agent" }, - name: "Original agent", - workspace: SOURCE_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.create": { key: sessionKey }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await page.getByRole("heading", { name: "Original agent" }).waitFor(); - const message = page.locator(".new-session-page__message"); - const start = page.locator("button.chat-send-btn"); - await message.fill("retry this draft after reconnect"); - await gateway.deferNext("sessions.create"); - await start.click(); - await gateway.waitForRequest("sessions.create"); - await expect.poll(() => start.isDisabled()).toBe(true); - - if (reconnectKind === "client replacement") { - await gateway.setMethodResponse("agents.list", { - agents: [ - { - id: "main", - identity: { name: "Replacement agent" }, - name: "Replacement agent", - workspace: TARGET_REPO, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }); - const socketsBefore = await gateway.getSocketCount(); - await replaceGatewayClient(page); - await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1); - await page.getByRole("heading", { name: "Replacement agent" }).waitFor(); - } else { - const agentRequestsBefore = (await gateway.getRequests("agents.list")).length; - await gateway.setOnline(false); - await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); - await gateway.setOnline(true); - await expect - .poll(async () => (await gateway.getRequests("agents.list")).length) - .toBe(agentRequestsBefore + 1); - } - await expect.poll(() => message.inputValue()).toBe("retry this draft after reconnect"); - await expect.poll(() => message.isEnabled()).toBe(true); - await expect.poll(() => start.isDisabled()).toBe(true); - await page - .getByText( - "The Gateway changed while this thread was starting. Check recent threads before starting this task again.", - ) - .waitFor(); - expect(new URL(page.url()).pathname).toBe("/new"); - expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - } finally { - await context.close(); - } - }); - } - - it("resets agent-derived workspace state when retargeted to a catalog", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.catalog.list": { - catalogs: [ - { - id: "claude", - label: "Claude Code", - capabilities: { - continueSession: true, - archive: false, - createSession: { model: "anthropic/claude-opus-4-8" }, - }, - hosts: [], - }, - ], - }, - "sessions.create": { key: "agent:main:claude-retarget" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new?agent=research`); - const folderLabel = page.locator( - "#new-session-place-trigger .new-session-page__trigger-label", - ); - await pollLocatorText(folderLabel).toBe("research"); - - await page.evaluate(() => { - history.pushState(null, "", "new?agent=main&catalog=claude"); - dispatchEvent(new PopStateEvent("popstate")); - }); - - await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); - await pollLocatorText(folderLabel).toBe("openclaw"); - await page.locator(".new-session-page__message").fill("retarget this draft"); - await page.getByRole("button", { name: "Start thread" }).click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - agentId: "main", - message: "retarget this draft", - catalogId: "claude", - }); - expect(create.params).not.toHaveProperty("model"); - expect(create.params).not.toHaveProperty("cwd"); - } finally { - await context.close(); - } - }); - - it("locks the submitted draft until creation settles and restores it after failure", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:locked-new-session-draft"; - const submittedMessage = "keep this submitted draft atomic"; - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.list": { - count: 0, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [], - ts: Date.now(), - }, - "sessions.create": { key: sessionKey }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await gateway.deferNext("sessions.create"); - - const draft = page.locator(".new-session-page__scroll"); - const message = page.locator(".new-session-page__message"); - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - const placeSummary = page.locator("#new-session-place-trigger"); - - await message.fill(submittedMessage); - await placeSummary.click(); - expect(await placeSelect.getAttribute("open")).not.toBeNull(); - await page.getByRole("button", { name: "Start thread" }).click(); - - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ message: submittedMessage }); - await expect.poll(() => message.isDisabled()).toBe(true); - expect(await draft.getAttribute("inert")).not.toBeNull(); - expect(await draft.getAttribute("aria-busy")).toBe("true"); - expect(await placeSelect.getAttribute("open")).toBeNull(); - expect(await placeSummary.isDisabled()).toBe(true); - - await expect( - message.fill("silently discarded late edit", { timeout: 250 }), - ).rejects.toThrow(); - await placeSummary.click({ force: true }); - await page.locator(".agent-chat__suggestion").first().click({ force: true }); - expect(await placeSelect.getAttribute("open")).toBeNull(); - expect(await message.inputValue()).toBe(submittedMessage); - expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - - await gateway.rejectDeferred("sessions.create", { - code: "UNAVAILABLE", - message: "session creation unavailable", - }); - await expect.poll(() => message.isDisabled()).toBe(false); - expect(await draft.getAttribute("inert")).toBeNull(); - expect(await draft.getAttribute("aria-busy")).toBe("false"); - expect(await message.inputValue()).toBe(submittedMessage); - expect(await placeSummary.isDisabled()).toBe(false); - - await page.getByRole("button", { name: "Start thread" }).click(); - await expect.poll(async () => (await gateway.getRequests("sessions.create")).length).toBe(2); - const retry = (await gateway.getRequests("sessions.create")).at(-1); - expect(retry?.params).toMatchObject({ message: submittedMessage }); - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - } finally { - await context.close(); - } - }); - - it("keeps a rejected first message visible and retryable after reload", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:rejected-first-message"; - const message = "keep this rejected first message"; - const runError = "send blocked by session policy"; - const gateway = await installMockGateway(page, { - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "worktrees.branches": { - branches: [{ kind: "local", name: "main" }], - defaultBranch: "main", - repositoryStatus: "git", - }, - "sessions.list": { - count: 1, - defaults: SESSION_LIST_DEFAULTS, - path: "", - sessions: [ - { - hasActiveRun: false, - key: sessionKey, - kind: "direct", - status: "done", - updatedAt: Date.now(), - }, - ], - ts: Date.now(), - }, - "sessions.create": { - key: sessionKey, - runStarted: false, - runError: { code: "INVALID_REQUEST", message: runError }, - }, - "chat.history": { - messages: [], - sessionId: "rejected-first-message", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - "chat.send": { runId: "retry-run", status: "started" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - await composer.fill(message); - await pastePng(composer); - await page.getByRole("button", { name: "Start thread" }).click(); - const create = await gateway.waitForRequest("sessions.create"); - expect(create.params).toMatchObject({ - message, - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - await expect - .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) - .toContain(message); - await expect - .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) - .toContain(runError); - - await page.reload(); - await expect - .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) - .toContain(message); - await expect - .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) - .toContain(runError); - - await page.getByRole("button", { name: "Retry queued message" }).click(); - const retry = await gateway.waitForRequest("chat.send"); - expect(retry.params).toMatchObject({ - sessionKey, - message, - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - expect(await gateway.getRequests("sessions.create")).toHaveLength(0); - } finally { - await context.close(); - } - }); - - it("adopts a created session when rejected-turn persistence exceeds browser storage", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript(() => { - const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem") - ?.value as Storage["setItem"]; - Storage.prototype.setItem = function (key: string, value: string) { - if (key.startsWith("openclaw.control.chatComposer.v2:")) { - throw new DOMException("Quota exceeded", "QuotaExceededError"); - } - return setItem.call(this, key, value); - }; - }); - const sessionKey = "agent:main:storage-failed-initial-turn"; - const message = "retry this in the session that already exists"; - const runError = "initial send rejected"; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.create": { - key: sessionKey, - runStarted: false, - runError: { code: "INVALID_REQUEST", message: runError }, - }, - "sessions.list": createdSessionListResult(sessionKey), - "chat.history": { - messages: [], - sessionId: "storage-failed-initial-turn", - sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, - }, - "chat.send": { runId: "storage-failure-retry", status: "started" }, - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - const composer = page.locator(".new-session-page__message"); - await composer.fill(message); - await pastePng(composer); - await page.getByRole("button", { name: "Start thread" }).click(); - - await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { - timeout: 30_000, - }); - await expect - .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) - .toContain(message); - await expect - .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) - .toContain(runError); - await page.getByRole("button", { name: "Retry queued message" }).click(); - const retry = await gateway.waitForRequest("chat.send"); - expect(retry.params).toMatchObject({ - sessionKey, - message, - attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], - }); - expect(await gateway.getRequests("sessions.create")).toHaveLength(1); - } finally { - await context.close(); - } - }); - - it("browses capable nodes and accepts manual paths for exec-only nodes", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspaceGit: true, - methodResponses: { - "agents.list": { - agents: [ - { - id: "main", - identity: { name: "Main" }, - name: "Main", - workspace: WORKSPACE, - workspaceGit: true, - }, - { - id: "research", - identity: { name: "Research" }, - name: "Research", - workspace: "/home/peter/research", - workspaceGit: true, - }, - ], - defaultId: "main", - mainKey: "main", - scope: "agent", - }, - "node.list": { - nodes: [ - { - nodeId: "macbook", - displayName: "MacBook", - connected: true, - commands: ["system.run", "fs.listDir"], - }, - { - nodeId: "old-node", - displayName: "Old node", - connected: true, - commands: ["system.run"], - }, - { - nodeId: "offline-node", - displayName: "Offline node", - connected: false, - commands: ["system.run", "fs.listDir"], - }, - ], - }, - "fs.listDir": { - cases: [ - { - match: { nodeId: "macbook", path: NODE_UNC }, - response: { - path: NODE_UNC, - parent: "\\\\server\\share", - home: "C:\\Users\\peter", - entries: [], - }, - }, - { - match: { nodeId: "macbook", path: NODE_PICKED }, - response: { - path: NODE_PICKED, - parent: NODE_HOME, - home: NODE_HOME, - entries: [], - }, - }, - { - match: { nodeId: "macbook" }, - response: { - path: NODE_HOME, - home: NODE_HOME, - entries: [{ name: "Projects", path: NODE_PICKED }], - }, - }, - ], - }, - "sessions.create": { key: "agent:main:node-draft-e2e" }, - "sessions.list": createdSessionListResult("agent:main:node-draft-e2e"), - }, - }); - - try { - await page.goto(`${server.baseUrl}new`); - await page.locator(".new-session-page__message").waitFor(); - const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); - const placeTrigger = page.locator("#new-session-place-trigger"); - const placeLabel = placeTrigger.locator(".new-session-page__trigger-label"); - const browserEntries = page.locator(".new-session-page__browser-list"); - - // Pick the node from Places. - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "MacBook" }).click(); - await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); - // Node sessions cannot use managed worktrees, so the menu drops the item. - await placeTrigger.click(); - expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); - await page.keyboard.press("Escape"); - - // Manual path entry in the browser head preserves UNC paths; these - // cannot be rediscovered by starting at the node home directory. - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - const pathInput = page.locator("input.new-session-page__browser-path"); - await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); - await pathInput.fill(NODE_UNC); - await pathInput.press("Enter"); - await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC); - // Escape returns to the picker root without applying or closing it. - await page.keyboard.press("Escape"); - await expect - .poll(() => - placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), - ) - .toBe(true); - await placeSelect.getByText("Places", { exact: true }).waitFor(); - - // Destination selection stays in Places; browsing is fixed to the current target. - await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); - await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); - await placeTrigger.click(); - expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); - await placeSelect.getByRole("button", { name: "MacBook" }).click(); - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await browserEntries.getByRole("button", { name: "Projects" }).click(); - await page.getByRole("button", { name: "Use this folder" }).click(); - - // Using a node folder retargets the draft to that node. - await pollLocatorText(placeLabel).toBe("Projects · MacBook"); - - // A node cwd belongs to the selected agent's draft and must not leak - // across an agent change, even though the execution node stays selected. - const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); - await agentPicker.locator(".agent-select__trigger").click(); - await agentPicker - .locator("wa-dropdown-item[data-agent-option]") - .filter({ hasText: "Research" }) - .click(); - await page.getByRole("heading", { name: "Research" }).waitFor(); - await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); - - // Clearing the path applies the node's default directory (empty folder), - // the state the replaced clearable folder textbox could express. - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); - await pathInput.fill(""); - await page.getByRole("button", { name: "Use this folder" }).click(); - await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); - - // Browse back to the custom folder, then retarget to the exec-only node - // with a manual absolute path for the final create assertion. - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await browserEntries.getByRole("button", { name: "Projects" }).click(); - await page.getByRole("button", { name: "Use this folder" }).click(); - await pollLocatorText(placeLabel).toBe("Projects · MacBook"); - - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Old node" }).click(); - await placeTrigger.click(); - await placeSelect.getByRole("button", { name: "Browse folders" }).click(); - await expect.poll(() => pathInput.inputValue()).toBe(""); - await pathInput.fill(EXEC_ONLY_PICKED); - await pathInput.press("Enter"); - expect( - (await gateway.getRequests("fs.listDir")).filter( - (request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node", - ), - ).toHaveLength(0); - await page.getByRole("button", { name: "Use this folder" }).click(); - await pollLocatorText(placeLabel).toBe("repo · Old node"); - - await page.locator(".new-session-page__message").fill("inspect the remote checkout"); - await page.getByRole("button", { name: "Start thread" }).click(); - const createRequest = await gateway.waitForRequest("sessions.create"); - expect(createRequest.params).toMatchObject({ - agentId: "research", - message: "inspect the remote checkout", - execNode: "old-node", - cwd: EXEC_ONLY_PICKED, - }); - expect(createRequest.params).not.toHaveProperty("worktree"); - } finally { - await context.close(); - } - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/e2e/new-session-page.places.e2e.test.ts b/ui/src/e2e/new-session-page.places.e2e.test.ts new file mode 100644 index 000000000000..cd59ce4300ac --- /dev/null +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -0,0 +1,789 @@ +import { expect, it } from "vitest"; +import { + EXEC_ONLY_PICKED, + NODE_HOME, + NODE_PICKED, + NODE_UNC, + PICKED, + SESSION_LIST_DEFAULTS, + WORKSPACE, + controlUiSessionPath, + createNewSessionPageE2eSuite, + createdSessionListResult, + installMockGateway, + pollLocatorText, + replaceGatewayClient, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("drafts a session with a browsed folder and creates it on first message", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [ + { name: "packages", path: PICKED }, + { name: ".git", path: `${WORKSPACE}/.git`, hidden: true }, + ], + }, + }, + { + match: { path: PICKED }, + response: { + path: PICKED, + parent: WORKSPACE, + home: "/home/peter", + entries: [], + }, + }, + ], + }, + "sessions.create": { key: "agent:main:draft-e2e" }, + }, + }); + + try { + // Deep-link to /new: the page loads agents via agents.list (the sidebar + // "+" navigates to the same route with ?agent=). + const response = await page.goto(`${suite.server.baseUrl}new`); + expect(response?.status()).toBe(200); + // The draft page shows the start-screen welcome hero for the agent. + await page.getByRole("heading", { name: "Main" }).waitFor(); + await page.locator(".new-session-page__message").waitFor(); + + // Unified layout: the trigger row (menus above the composer) sits + // inside the start-screen welcome, below the hero. + const heroBox = await page.locator(".agent-chat__welcome h2").boundingBox(); + const triggersBox = await page.locator(".new-session-page__triggers").boundingBox(); + const composerBox = await page.locator(".new-session-page__composer").boundingBox(); + const modelBox = await page.locator('[data-chat-model-select="true"]').boundingBox(); + const modelWrapperBox = await page + .locator(".new-session-page__composer .chat-composer-model-control") + .boundingBox(); + const footerBox = await page + .locator(".new-session-page__composer .agent-chat__composer-footer") + .boundingBox(); + expect(heroBox).not.toBeNull(); + expect(triggersBox).not.toBeNull(); + expect(composerBox).not.toBeNull(); + expect(modelBox).not.toBeNull(); + expect(modelWrapperBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual( + (triggersBox?.y ?? 0) + 1, + ); + expect((triggersBox?.y ?? 0) + (triggersBox?.height ?? 0)).toBeLessThanOrEqual( + (composerBox?.y ?? 0) + 1, + ); + expect( + await page.locator(".new-session-page__composer .agent-chat__composer-footer").count(), + ).toBe(1); + expect( + await page + .locator('[data-chat-model-select="true"]') + .evaluate((element) => element.closest(".agent-chat__composer-footer") != null), + ).toBe(true); + expect(modelWrapperBox?.x ?? 0).toBeGreaterThan( + (footerBox?.x ?? 0) + (footerBox?.width ?? 0) / 2, + ); + expect( + (footerBox?.x ?? 0) + + (footerBox?.width ?? 0) - + ((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)), + ).toBeLessThanOrEqual(12); + expect(triggersBox?.x).toBeCloseTo(composerBox?.x ?? 0, 0); + expect(triggersBox?.width).toBeCloseTo(composerBox?.width ?? 0, 0); + expect(composerBox?.width).toBeCloseTo(48 * 16, 0); + expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1"); + + // The place trigger labels the workspace and opens the unified menu. + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); + + // Browse from the workspace, descend one level, then adopt the folder. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click(); + await expect + .poll(() => page.locator("input.new-session-page__browser-path").inputValue()) + .toBe(PICKED); + await page.getByRole("button", { name: "Use this folder" }).click(); + + // The adopted folder closes the menu and updates the trigger label. + await expect.poll(() => placeSelect.getAttribute("open")).toBeNull(); + await expect + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("new-session-place-trigger"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "packages", + ); + + // Git-backed custom folders stay direct until the user explicitly chooses isolation. + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); + await placeTrigger.click(); + const worktreeItem = page.getByRole("button", { name: "Worktree" }); + await expect.poll(() => worktreeItem.getAttribute("aria-pressed")).toBe("false"); + expect(await worktreeItem.isEnabled()).toBe(true); + await worktreeItem.click(); + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); + await page.keyboard.press("Escape"); + await expect + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("new-session-place-trigger"); + + // Pointer light-dismiss still retires the unified popover after its + // asynchronous hide animation completes. + await placeTrigger.click(); + const afterPointerHide = placeSelect.evaluate( + (element) => + new Promise((resolve) => { + element.addEventListener("wa-after-hide", () => resolve(), { once: true }); + }), + ); + await page.locator(".agent-chat__welcome h2").click(); + await afterPointerHide; + await expect.poll(() => placeSelect.getAttribute("open")).toBeNull(); + + const message = page.locator(".new-session-page__message"); + await message.fill("fix the flaky test"); + await page.getByRole("button", { name: "Start thread" }).click(); + + const createRequest = await gateway.waitForRequest("sessions.create"); + expect(createRequest.params).toMatchObject({ + agentId: "main", + message: "fix the flaky test", + worktree: true, + worktreeBaseRef: "main", + cwd: PICKED, + }); + + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath("agent:main:draft-e2e")); + } finally { + await context.close(); + } + }); + + it("runs directly in a custom non-Git Gateway folder", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + cases: [ + { + match: { repoRoot: WORKSPACE }, + response: { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + { + match: { repoRoot: "/home" }, + response: { branches: [], repositoryStatus: "not_git" }, + }, + ], + }, + "sessions.create": { key: "agent:main:plain-folder" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill("/home"); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: "/home", includeRepositoryStatus: true }); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "home · Gateway · local", + ); + + await trigger.click(); + expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); + const cloud = place.getByRole("button", { name: "Cloud · aws" }); + expect(await cloud.isDisabled()).toBe(true); + expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree"); + await page.keyboard.press("Escape"); + + await page.locator(".new-session-page__message").fill("clone and inspect this project"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + cwd: "/home", + message: "clone and inspect this project", + }); + expect(create.params).not.toHaveProperty("worktree"); + expect(create.params).not.toHaveProperty("worktreeBaseRef"); + } finally { + await context.close(); + } + }); + + it("hides the destination axis when the Gateway is the only place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { environments: [], profiles: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + expect(await place.getByText("Places", { exact: true }).count()).toBe(0); + await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("uses advertised system info for Gateway place labels", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + featureMethods: ["chat.metadata", "chat.startup", "system.info"], + methodResponses: { + "system.info": { + machineName: "Peters-Mac-Studio", + hostname: "peters-mac-studio.local", + platform: "darwin", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { environments: [], profiles: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("system.info"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw · Gateway · Peters-Mac-Studio", + ); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await expect + .poll(() => + page.locator("input.new-session-page__browser-path").getAttribute("placeholder"), + ) + .toBe("Gateway · Peters-Mac-Studio"); + + await gateway.setMethodResponse("node.list", { nodes: [] }); + const nodeRequests = (await gateway.getRequests("node.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(nodeRequests); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("disambiguates duplicate node names without changing the selected chip", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "11111111aaaaaaaa", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac14,12", + remoteIp: "192.168.1.11", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "22222222bbbbbbbb", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac15,14", + remoteIp: "192.168.1.12", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "33333333cccccccc", + displayName: "iPhone", + platform: "iOS 26.4", + deviceFamily: "iPhone", + modelIdentifier: "iPhone17,2", + remoteIp: "192.168.1.30", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { environments: [], profiles: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); + const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); + const phone = page.locator('[data-value="node:33333333cccccccc"]'); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14"); + await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone"); + expect(await first.locator(".session-menu__icon svg").count()).toBe(1); + expect(await second.locator(".session-menu__icon svg").count()).toBe(1); + expect(await phone.locator(".session-menu__icon svg").count()).toBe(1); + expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11"); + expect(await second.getAttribute("title")).toContain("192.168.1.12"); + await second.click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Agent workspace · Mac Studio", + ); + expect(await trigger.textContent()).not.toContain("Mac15,14"); + expect(await trigger.textContent()).not.toContain("192.168.1.12"); + } finally { + await context.close(); + } + }); + + it("disambiguates duplicate recent basenames and applies the selected path", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { environments: [], profiles: [] }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" }, + { key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-collision" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const first = page.locator('[data-value="recent::/a/openclaw"]'); + const second = page.locator('[data-value="recent::/b/openclaw"]'); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("a"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("b"); + await second.click(); + await page.locator(".new-session-page__message").fill("continue in work checkout"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: "/b/openclaw", + message: "continue in work checkout", + }); + } finally { + await context.close(); + } + }); + + it("applies a recent folder and node as one place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + key: "agent:main:recent-node", + kind: "direct", + updatedAt: 2, + execCwd: NODE_PICKED, + execNode: "macbook", + }, + { + key: "agent:main:workspace", + kind: "direct", + updatedAt: 1, + execCwd: WORKSPACE, + }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-place" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Projects · MacBook", exact: true }) + .click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Projects · MacBook", + ); + + await page.locator(".new-session-page__message").fill("continue on the recent node"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: NODE_PICKED, + execNode: "macbook", + message: "continue on the recent node", + }); + } finally { + await context.close(); + } + }); + + it("returns from the browse root to the place menu", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "fs.listDir": { + path: WORKSPACE, + home: WORKSPACE, + entries: [], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await gateway.waitForRequest("fs.listDir"); + await place.getByRole("button", { name: "Parent folder" }).click(); + await place.getByRole("button", { name: "Worktree" }).waitFor(); + expect(await place.getAttribute("open")).not.toBeNull(); + + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").press("Escape"); + await place.getByRole("button", { name: "Worktree" }).waitFor(); + expect(await place.getAttribute("open")).not.toBeNull(); + } finally { + await context.close(); + } + }); + + it("browses capable nodes and accepts manual paths for exec-only nodes", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + { + nodeId: "old-node", + displayName: "Old node", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "offline-node", + displayName: "Offline node", + connected: false, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "fs.listDir": { + cases: [ + { + match: { nodeId: "macbook", path: NODE_UNC }, + response: { + path: NODE_UNC, + parent: "\\\\server\\share", + home: "C:\\Users\\peter", + entries: [], + }, + }, + { + match: { nodeId: "macbook", path: NODE_PICKED }, + response: { + path: NODE_PICKED, + parent: NODE_HOME, + home: NODE_HOME, + entries: [], + }, + }, + { + match: { nodeId: "macbook" }, + response: { + path: NODE_HOME, + home: NODE_HOME, + entries: [{ name: "Projects", path: NODE_PICKED }], + }, + }, + ], + }, + "sessions.create": { key: "agent:main:node-draft-e2e" }, + "sessions.list": createdSessionListResult("agent:main:node-draft-e2e"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator(".new-session-page__message").waitFor(); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + const placeLabel = placeTrigger.locator(".new-session-page__trigger-label"); + const browserEntries = page.locator(".new-session-page__browser-list"); + + // Pick the node from Places. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + // Node sessions cannot use managed worktrees, so the menu drops the item. + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await page.keyboard.press("Escape"); + + // Manual path entry in the browser head preserves UNC paths; these + // cannot be rediscovered by starting at the node home directory. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(NODE_UNC); + await pathInput.press("Enter"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC); + // Escape returns to the picker root without applying or closing it. + await page.keyboard.press("Escape"); + await expect + .poll(() => + placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(true); + await placeSelect.getByText("Places", { exact: true }).waitFor(); + + // Destination selection stays in Places; browsing is fixed to the current target. + await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); + await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + + // Using a node folder retargets the draft to that node. + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + // A node cwd belongs to the selected agent's draft and must not leak + // across an agent change, even though the execution node stays selected. + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await agentPicker.locator(".agent-select__trigger").click(); + await agentPicker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: "Research" }) + .click(); + await page.getByRole("heading", { name: "Research" }).waitFor(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Clearing the path applies the node's default directory (empty folder), + // the state the replaced clearable folder textbox could express. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(""); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Browse back to the custom folder, then retarget to the exec-only node + // with a manual absolute path for the final create assertion. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Old node" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(""); + await pathInput.fill(EXEC_ONLY_PICKED); + await pathInput.press("Enter"); + expect( + (await gateway.getRequests("fs.listDir")).filter( + (request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node", + ), + ).toHaveLength(0); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("repo · Old node"); + + await page.locator(".new-session-page__message").fill("inspect the remote checkout"); + await page.getByRole("button", { name: "Start thread" }).click(); + const createRequest = await gateway.waitForRequest("sessions.create"); + expect(createRequest.params).toMatchObject({ + agentId: "research", + message: "inspect the remote checkout", + execNode: "old-node", + cwd: EXEC_ONLY_PICKED, + }); + expect(createRequest.params).not.toHaveProperty("worktree"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts new file mode 100644 index 000000000000..0837c55b1d71 --- /dev/null +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -0,0 +1,836 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { + ONE_PIXEL_PNG_B64, + SESSION_LIST_DEFAULTS, + WORKSPACE, + captureUiProof, + captureUiProofEnabled, + controlUiSessionPath, + createNewSessionPageE2eSuite, + createdSessionListResult, + installMockGateway, + pastePng, + pollLocatorText, + reconnectProofArtifactDir, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("grows the first prompt through ten lines before using a narrow scrollbar", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + try { + await page.goto(`${suite.server.baseUrl}new`); + const message = page.locator(".new-session-page__message"); + await message.waitFor(); + + const initial = await message.evaluate((element) => ({ + height: element.clientHeight, + overflowY: getComputedStyle(element).overflowY, + })); + await message.fill(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n")); + const tenLines = await message.evaluate((element) => { + const style = getComputedStyle(element); + return { + height: element.clientHeight, + lineHeight: Number.parseFloat(style.lineHeight), + overflowY: style.overflowY, + padding: Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom), + scrollbarWidth: style.scrollbarWidth, + webkitScrollbarWidth: getComputedStyle(element, "::-webkit-scrollbar").width, + }; + }); + + expect(tenLines.height).toBeGreaterThan(initial.height); + expect(tenLines.height).toBeGreaterThanOrEqual( + Math.floor(tenLines.lineHeight * 10 + tenLines.padding) - 1, + ); + expect(tenLines.overflowY).toBe("hidden"); + expect(tenLines.scrollbarWidth).toBe("thin"); + expect(Number.parseFloat(tenLines.webkitScrollbarWidth)).toBeLessThanOrEqual(6); + await captureUiProof(page, "new-session-composer-ten-lines.png"); + + const longPrompt = Array.from({ length: 14 }, (_, index) => `line ${index + 1}`).join("\n"); + await message.fill(longPrompt); + const capped = await message.evaluate((element) => ({ + clientHeight: element.clientHeight, + overflowY: getComputedStyle(element).overflowY, + scrollHeight: element.scrollHeight, + })); + expect(capped.clientHeight).toBeLessThan(capped.scrollHeight); + expect(capped.overflowY).toBe("auto"); + await captureUiProof(page, "new-session-composer-capped-scrollbar.png"); + const start = page.getByRole("button", { name: "Start thread" }); + await expect(start.isVisible()).resolves.toBe(true); + await expect(start.isEnabled()).resolves.toBe(true); + await start.focus(); + await expect(start.evaluate((element) => document.activeElement === element)).resolves.toBe( + true, + ); + await start.press("Enter"); + await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ + params: { message: longPrompt }, + }); + } finally { + await context.close(); + } + }); + + it("pastes an image into the draft and forwards it with the initial turn", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: "agent:main:image-draft", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const message = page.locator(".new-session-page__message"); + await message.waitFor(); + await pastePng(message); + + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("button", { name: "Start thread" }).click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + message: "", + attachments: [ + { + type: "image", + mimeType: "image/png", + fileName: "pixel.png", + content: ONE_PIXEL_PNG_B64, + }, + ], + }); + } finally { + await context.close(); + } + }); + + it("shows the initial prompt while the newly created session is still running", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:visible-initial-prompt"; + const message = "keep this prompt visible while the agent works"; + const activeOutputTimestamp = Date.now() + 60_000; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "active-tool-call", + name: "read", + arguments: { path: "SKILL.md" }, + }, + ], + timestamp: activeOutputTimestamp, + __openclaw: { id: "active-assistant", seq: 2 }, + }, + { + role: "toolResult", + toolCallId: "active-tool-call", + toolName: "read", + content: [{ type: "text", text: "working" }], + timestamp: activeOutputTimestamp + 1, + __openclaw: { id: "active-tool-result", seq: 3 }, + }, + ], + sessionId: "visible-initial-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + await gateway.waitForRequest("chat.startup"); + await page.getByText("SKILL.md", { exact: true }).waitFor(); + + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); + const userRow = await page.locator(".chat-group.user").boundingBox(); + const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox(); + expect(userRow).not.toBeNull(); + expect(toolRow).not.toBeNull(); + if (!userRow || !toolRow) { + throw new Error("expected visible prompt and tool rows"); + } + expect(userRow.y).toBeLessThan(toolRow.y); + } finally { + await context.close(); + } + }); + + it("keeps the initial prompt visible across a Gateway reconnect", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:reconnected-initial-prompt"; + const message = "keep this first prompt through reconnect"; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: sessionKey, runStarted: true }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [], + sessionId: "reconnected-initial-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + await gateway.waitForRequest("chat.startup"); + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); + + const socketsBeforeReconnect = await gateway.getSocketCount(); + await gateway.setOnline(false); + await expect + .poll(() => gateway.getSocketCount(), { timeout: 10_000 }) + .toBeGreaterThan(socketsBeforeReconnect); + await gateway.setOnline(true); + await expect + .poll(() => + page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { gateway: { snapshot: { phase: string } } } }; + }; + return app.runtime?.context.gateway.snapshot.phase; + }), + ) + .toBe("connected"); + await page.evaluate((selectedSessionKey) => { + const pane = document.querySelector("openclaw-chat-pane") as unknown as HTMLElement & { + state: { chatMessages: unknown[]; chatMessagesBySession: Map }; + switchPaneSession: (sessionKey: string) => void; + }; + pane.state.chatMessages = []; + pane.state.chatMessagesBySession.clear(); + pane.switchPaneSession("agent:main:temporary-session"); + pane.switchPaneSession(selectedSessionKey); + }, sessionKey); + if (captureUiProofEnabled) { + await mkdir(reconnectProofArtifactDir, { recursive: true }); + await page.screenshot({ + path: path.join(reconnectProofArtifactDir, "reconnected-session.png"), + fullPage: true, + }); + } + + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); + await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1); + } finally { + await context.close(); + } + }); + + it("reconciles an image-bearing initial prompt into one user row", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:single-image-prompt"; + const message = "testing if dual prompts show"; + const gateway = await installMockGateway(page, { + deferredMethods: ["chat.startup"], + methodResponses: { + "sessions.create": { + key: sessionKey, + runId: "initial-image-send", + runStarted: true, + messageSeq: 1, + }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.startup": { + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "url", url: "/persisted-image.png" }, + }, + { type: "text", text: message }, + ], + timestamp: Date.now(), + __openclaw: { + id: "persisted-image-prompt", + idempotencyKey: "initial-image-send:user", + seq: 1, + }, + }, + ], + sessionId: "single-image-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + await composer.fill(message); + await pastePng(composer); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + await gateway.waitForRequest("chat.startup"); + + const userRow = page.locator(".chat-group.user"); + const userImage = userRow.locator("img.chat-message-image"); + await expect.poll(() => userRow.count()).toBe(1); + await expect.poll(() => userImage.count()).toBe(1); + await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u); + const initialImageSrc = await userImage.getAttribute("src"); + await userImage.evaluate((image) => image.setAttribute("data-initial-image-node", "true")); + await pollLocatorText(userRow).toContain(message); + await pollLocatorText(userRow).not.toContain("Attached image"); + + await gateway.resolveDeferred("chat.startup"); + + await expect.poll(() => userRow.count()).toBe(1); + await expect.poll(() => userImage.count()).toBe(1); + await expect.poll(() => userImage.getAttribute("data-initial-image-node")).toBe("true"); + await expect.poll(() => userImage.getAttribute("src")).toBe(initialImageSrc); + await pollLocatorText(userRow).toContain(message); + await pollLocatorText(userRow).not.toContain("Attached image"); + } finally { + await context.close(); + } + }); + + it("waits for pasted image reads before enabling session creation", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") + ?.value as FileReader["readAsDataURL"]; + FileReader.prototype.readAsDataURL = function (blob: Blob) { + (globalThis as unknown as { finishPastedImageRead?: () => void }).finishPastedImageRead = + () => readAsDataUrl.call(this, blob); + }; + }); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: "agent:main:delayed-image-draft", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + const submit = page.getByRole("button", { name: "Start thread" }); + await composer.fill("include the image that is still loading"); + await pastePng(composer); + + await expect.poll(() => submit.isDisabled()).toBe(true); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + await page.evaluate(() => { + const finish = (globalThis as unknown as { finishPastedImageRead?: () => void }) + .finishPastedImageRead; + if (!finish) { + throw new Error("Pasted image read was not started"); + } + finish(); + }); + + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await expect.poll(() => submit.isEnabled()).toBe(true); + await submit.click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + message: "include the image that is still loading", + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + } finally { + await context.close(); + } + }); + + it("releases a completed file when the rest of its pasted batch is aborted", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + const readAsDataUrl = Object.getOwnPropertyDescriptor(FileReader.prototype, "readAsDataURL") + ?.value as FileReader["readAsDataURL"]; + let readCount = 0; + FileReader.prototype.readAsDataURL = function (blob: Blob) { + readCount += 1; + if (readCount === 1) { + readAsDataUrl.call(this, blob); + } + }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + const proof = { created: 0, revoked: 0 }; + (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; + URL.createObjectURL = (blob: Blob) => { + proof.created += 1; + return createObjectURL(blob); + }; + URL.revokeObjectURL = (url: string) => { + proof.revoked += 1; + revokeObjectURL(url); + }; + }); + await installMockGateway(page); + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + await pastePng(composer, 2); + await expect + .poll(() => + page.evaluate( + () => + (globalThis as unknown as { attachmentUrlProof: { created: number } }) + .attachmentUrlProof.created, + ), + ) + .toBe(1); + + await page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { navigate: (routeId: string) => void } }; + }; + app.runtime?.context.navigate("chat"); + }); + await page.waitForURL((url) => url.pathname.endsWith("/chat")); + await expect + .poll(() => + page.evaluate( + () => + (globalThis as unknown as { attachmentUrlProof: { revoked: number } }) + .attachmentUrlProof.revoked, + ), + ) + .toBe(1); + } finally { + await context.close(); + } + }); + + it("releases pasted image previews after remove, reset, disconnect, and success", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + const proof = { created: 0, revoked: 0 }; + (globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof; + URL.createObjectURL = (blob: Blob) => { + proof.created += 1; + return createObjectURL(blob); + }; + URL.revokeObjectURL = (url: string) => { + proof.revoked += 1; + revokeObjectURL(url); + }; + }); + await installMockGateway(page, { + methodResponses: { + "agents.list": { + defaultId: "main", + mainKey: "main", + scope: "agent", + agents: [ + { id: "main", name: "Main" }, + { id: "writer", name: "Writer" }, + ], + }, + "sessions.create": { key: "agent:main:preview-cleanup", runStarted: true }, + }, + }); + const proof = () => + page.evaluate( + () => + (globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } }) + .attachmentUrlProof, + ); + const navigate = (routeId: string, search = "") => + page.evaluate( + ({ targetRouteId, targetSearch }) => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + navigate: (routeId: string, options?: { search?: string }) => void; + }; + }; + }; + if (!app.runtime) { + throw new Error("OpenClaw application runtime is unavailable"); + } + app.runtime.context.navigate(targetRouteId, { search: targetSearch }); + }, + { targetRouteId: routeId, targetSearch: search }, + ); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + + await pastePng(composer); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("button", { name: "Remove attachment" }).click(); + await expect.poll(async () => (await proof()).revoked).toBe(1); + + await pastePng(composer); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown"); + await page.locator(".new-session-page__select--agent .agent-select__trigger").click(); + await expect + .poll(() => + agentDropdown.evaluate((dropdown) => (dropdown as HTMLElement & { open: boolean }).open), + ) + .toBe(true); + await navigate("new-session", "?agent=main&catalog=missing"); + await expect + .poll(() => + page.evaluate( + () => + ( + document.querySelector(".new-session-page__select--agent wa-dropdown") as + | (HTMLElement & { open: boolean }) + | null + )?.open ?? false, + ), + ) + .toBe(false); + await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0); + await expect.poll(async () => (await proof()).revoked).toBe(2); + + await navigate("new-session"); + await composer.waitFor(); + await pastePng(composer); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await navigate("chat"); + await page.waitForURL((url) => url.pathname.endsWith("/chat")); + await expect.poll(async () => (await proof()).revoked).toBe(3); + + await navigate("new-session"); + await composer.waitFor(); + await pastePng(composer); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL( + (url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"), + ); + await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 }); + } finally { + await context.close(); + } + }); + + it("locks the submitted draft until creation settles and restores it after failure", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:locked-new-session-draft"; + const submittedMessage = "keep this submitted draft atomic"; + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.list": { + count: 0, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [], + ts: Date.now(), + }, + "sessions.create": { key: sessionKey }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.deferNext("sessions.create"); + + const draft = page.locator(".new-session-page__scroll"); + const message = page.locator(".new-session-page__message"); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeSummary = page.locator("#new-session-place-trigger"); + + await message.fill(submittedMessage); + await placeSummary.click(); + expect(await placeSelect.getAttribute("open")).not.toBeNull(); + await page.getByRole("button", { name: "Start thread" }).click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ message: submittedMessage }); + await expect.poll(() => message.isDisabled()).toBe(true); + expect(await draft.getAttribute("inert")).not.toBeNull(); + expect(await draft.getAttribute("aria-busy")).toBe("true"); + expect(await placeSelect.getAttribute("open")).toBeNull(); + expect(await placeSummary.isDisabled()).toBe(true); + + await expect( + message.fill("silently discarded late edit", { timeout: 250 }), + ).rejects.toThrow(); + await placeSummary.click({ force: true }); + await page.locator(".agent-chat__suggestion").first().click({ force: true }); + expect(await placeSelect.getAttribute("open")).toBeNull(); + expect(await message.inputValue()).toBe(submittedMessage); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + + await gateway.rejectDeferred("sessions.create", { + code: "UNAVAILABLE", + message: "session creation unavailable", + }); + await expect.poll(() => message.isDisabled()).toBe(false); + expect(await draft.getAttribute("inert")).toBeNull(); + expect(await draft.getAttribute("aria-busy")).toBe("false"); + expect(await message.inputValue()).toBe(submittedMessage); + expect(await placeSummary.isDisabled()).toBe(false); + + await page.getByRole("button", { name: "Start thread" }).click(); + await expect.poll(async () => (await gateway.getRequests("sessions.create")).length).toBe(2); + const retry = (await gateway.getRequests("sessions.create")).at(-1); + expect(retry?.params).toMatchObject({ message: submittedMessage }); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + } finally { + await context.close(); + } + }); + + it("keeps a rejected first message visible and retryable after reload", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:rejected-first-message"; + const message = "keep this rejected first message"; + const runError = "send blocked by session policy"; + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.list": { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + hasActiveRun: false, + key: sessionKey, + kind: "direct", + status: "done", + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }, + "sessions.create": { + key: sessionKey, + runStarted: false, + runError: { code: "INVALID_REQUEST", message: runError }, + }, + "chat.history": { + messages: [], + sessionId: "rejected-first-message", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + "chat.send": { runId: "retry-run", status: "started" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + await composer.fill(message); + await pastePng(composer); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + message, + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + await expect + .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) + .toContain(message); + await expect + .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) + .toContain(runError); + + await page.reload(); + await expect + .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) + .toContain(message); + await expect + .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) + .toContain(runError); + + await page.getByRole("button", { name: "Retry queued message" }).click(); + const retry = await gateway.waitForRequest("chat.send"); + expect(retry.params).toMatchObject({ + sessionKey, + message, + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + } finally { + await context.close(); + } + }); + + it("adopts a created session when rejected-turn persistence exceeds browser storage", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + const setItem = Object.getOwnPropertyDescriptor(Storage.prototype, "setItem") + ?.value as Storage["setItem"]; + Storage.prototype.setItem = function (key: string, value: string) { + if (key.startsWith("openclaw.control.chatComposer.v2:")) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + return setItem.call(this, key, value); + }; + }); + const sessionKey = "agent:main:storage-failed-initial-turn"; + const message = "retry this in the session that already exists"; + const runError = "initial send rejected"; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { + key: sessionKey, + runStarted: false, + runError: { code: "INVALID_REQUEST", message: runError }, + }, + "sessions.list": createdSessionListResult(sessionKey), + "chat.history": { + messages: [], + sessionId: "storage-failed-initial-turn", + sessionInfo: { hasActiveRun: false, key: sessionKey, status: "done" }, + }, + "chat.send": { runId: "storage-failure-retry", status: "started" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + await composer.fill(message); + await pastePng(composer); + await page.getByRole("button", { name: "Start thread" }).click(); + + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey), { + timeout: 30_000, + }); + await expect + .poll(() => page.locator(".chat-queue__text").allInnerTexts(), { timeout: 30_000 }) + .toContain(message); + await expect + .poll(() => page.locator(".chat-queue__error").allInnerTexts(), { timeout: 30_000 }) + .toContain(runError); + await page.getByRole("button", { name: "Retry queued message" }).click(); + const retry = await gateway.waitForRequest("chat.send"); + expect(retry.params).toMatchObject({ + sessionKey, + message, + attachments: [{ fileName: "pixel.png", content: ONE_PIXEL_PNG_B64 }], + }); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts new file mode 100644 index 000000000000..efbaa8fc5077 --- /dev/null +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -0,0 +1,182 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { expect } from "vitest"; +import { + controlUiSessionPath, + controlUiSessionUrl, + installMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +export { controlUiSessionPath, controlUiSessionUrl, installMockGateway }; + +export const WORKSPACE = "/home/peter/openclaw"; +export const PICKED = "/home/peter/openclaw/packages"; +export const SOURCE_REPO = "/tmp/source-repo"; +export const TARGET_REPO = "/tmp/target-repo"; +export const REFRESHED_RESEARCH_WORKSPACE = "/home/peter/research-next"; +export const MOVED_WORKSPACE = "/home/peter/openclaw-next"; +export const NODE_HOME = "/Users/peter"; +export const NODE_PICKED = "/Users/peter/Projects"; +export const NODE_UNC = "\\\\server\\share\\repo"; +export const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo"; +const LOCATOR_TEXT_READ_TIMEOUT_MS = 500; +const LOCATOR_TEXT_POLL_TIMEOUT_MS = 10_000; + +export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const uiProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "cloud-worker-session", +); +export const reconnectProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "initial-prompt-reconnect", +); +export const ONE_PIXEL_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; +export const SESSION_LIST_DEFAULTS = { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", +}; + +export function pollLocatorText(locator: Locator) { + return expect.poll(() => locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS }), { + timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS, + }); +} + +export function createNewSessionPageE2eSuite() { + return createControlUiE2eSuite({ + name: "Control UI new-session page mocked Gateway E2E", + unavailableMessage: (executablePath) => + `Playwright Chromium is unavailable at ${executablePath}`, + }); +} + +export function createdSessionListResult(sessionKey: string) { + return { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + contextTokens: null, + displayName: "Created session", + key: sessionKey, + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + totalTokens: 0, + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +export async function captureUiProof(page: Page, fileName: string) { + if (!captureUiProofEnabled) { + return; + } + await mkdir(uiProofArtifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(uiProofArtifactDir, fileName), + }); +} + +export async function pastePng(target: Locator, count = 1) { + await target.evaluate( + (element, { base64, fileCount }) => { + const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + const clipboard = new DataTransfer(); + for (let index = 0; index < fileCount; index += 1) { + const fileName = fileCount === 1 ? "pixel.png" : `pixel-${index + 1}.png`; + clipboard.items.add(new File([bytes], fileName, { type: "image/png" })); + } + element.dispatchEvent( + new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }), + ); + }, + { base64: ONE_PIXEL_PNG_B64, fileCount: count }, + ); +} + +export async function replaceGatewayClient(page: Page) { + await page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { gateway: { connect: () => void } } }; + }; + if (!app.runtime) { + throw new Error("OpenClaw application runtime is unavailable"); + } + app.runtime.context.gateway.connect(); + }); +} + +export async function navigateInApp(page: Page, routeId: string, search = "") { + await page.evaluate( + ({ targetRouteId, targetSearch }) => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + context: { + navigate: (routeId: string, options?: { search?: string }) => void; + }; + }; + }; + if (!app.runtime) { + throw new Error("OpenClaw application runtime is unavailable"); + } + app.runtime.context.navigate(targetRouteId, { search: targetSearch }); + }, + { targetRouteId: routeId, targetSearch: search }, + ); +} + +/** + * Chat first pushes its base path and then commits the canonical session path. + * A repeated URL alone cannot prove that the router has finished, so require + * the successful active match and browser location to agree before leaving. + */ +export async function waitForCommittedChatRoute(page: Page) { + await page.waitForURL((url) => url.pathname.startsWith("/chat/")); + await expect + .poll(() => + page.evaluate(() => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + router: { + getState: () => { + status: string; + resolvedLocation: { pathname: string } | null; + matches: { routeId: string }[]; + pendingMatches: unknown[]; + }; + }; + }; + }; + const state = app.runtime?.router.getState(); + return ( + state?.status === "success" && + state.matches[0]?.routeId === "chat" && + state.resolvedLocation?.pathname === window.location.pathname && + state.pendingMatches.length === 0 + ); + }), + ) + .toBe(true); +} + +export async function choosePackagesFolder(page: Page) { + await page.locator("#new-session-place-trigger").click(); + await page.getByRole("button", { name: "Browse folders" }).click(); + await page.locator(".new-session-page__browser-entry", { hasText: "packages" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); +} diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts new file mode 100644 index 000000000000..01e87c4e0ce8 --- /dev/null +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -0,0 +1,719 @@ +import { expect, it } from "vitest"; +import { + MOVED_WORKSPACE, + PICKED, + TARGET_REPO, + WORKSPACE, + captureUiProof, + choosePackagesFolder, + createNewSessionPageE2eSuite, + installMockGateway, + navigateInApp, + pollLocatorText, + waitForCommittedChatRoute, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("selects the model for a plain new session", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + models: [ + { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, + ], + methodResponses: { + "sessions.create": { key: "agent:main:model-draft", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const modelSelect = page.locator('[data-chat-model-select="true"]'); + await modelSelect.waitFor(); + expect( + await page.locator('.new-session-page__composer [data-chat-model-select="true"]').count(), + ).toBe(1); + expect( + await page.locator('.new-session-page__triggers [data-chat-model-select="true"]').count(), + ).toBe(0); + await modelSelect.click(); + const pickerOpen = () => + modelSelect.evaluate( + (element) => element.closest("details")?.hasAttribute("open") ?? false, + ); + const modelTriggerBox = await modelSelect.boundingBox(); + const modelMenuBox = await page + .locator(".chat-controls__inline-select-menu--combined") + .boundingBox(); + expect(modelTriggerBox).not.toBeNull(); + expect(modelMenuBox).not.toBeNull(); + expect(modelMenuBox?.x ?? 0).toBeLessThan(modelTriggerBox?.x ?? 0); + expect((modelMenuBox?.x ?? 0) + (modelMenuBox?.width ?? 0)).toBeCloseTo( + (modelTriggerBox?.x ?? 0) + (modelTriggerBox?.width ?? 0), + 0, + ); + await page.locator('[data-chat-model-provider="anthropic"]').click(); + await expect.poll(pickerOpen).toBe(true); + await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); + // Inside changes stay grouped; only explicit light-dismissal closes the picker. + await expect.poll(pickerOpen).toBe(true); + await page.keyboard.press("Escape"); + await expect.poll(pickerOpen).toBe(false); + await expect + .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) + .toBe(true); + await modelSelect.click(); + await expect.poll(pickerOpen).toBe(true); + await page.mouse.click(8, 8); + await expect.poll(pickerOpen).toBe(false); + await page.locator(".new-session-page__message").fill("use this model"); + await page.getByRole("button", { name: "Start thread" }).click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + message: "use this model", + model: "anthropic/claude-sonnet-4-6", + }); + } finally { + await context.close(); + } + }); + + it("restores valid preferences and repairs a worktree rejected by workspace metadata", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + models: [ + { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + }, + ], + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [{ name: "packages", path: PICKED }], + }, + }, + { + match: { path: PICKED }, + response: { + path: PICKED, + parent: WORKSPACE, + home: "/home/peter", + entries: [], + }, + }, + ], + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const placeTrigger = page.locator("#new-session-place-trigger"); + await choosePackagesFolder(page); + await placeTrigger.click(); + await page.getByRole("button", { name: "Worktree" }).click(); + await page.keyboard.press("Escape"); + + const modelSelect = page.locator('[data-chat-model-select="true"]'); + await modelSelect.click(); + await page.locator('[data-chat-model-provider="anthropic"]').click(); + await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); + const thinkingSlider = page.locator('[data-chat-thinking-slider="true"]'); + await thinkingSlider.press("End"); + await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); + + await page.goto(`${suite.server.baseUrl}new`); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "packages", + ); + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); + await expect + .poll(() => modelSelect.getAttribute("data-chat-select-value")) + .toBe("anthropic/claude-sonnet-4-6"); + await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); + await captureUiProof(page, "new-session-preferences-restored.png"); + + const branchRequests = await gateway.getRequests("worktrees.branches"); + expect(branchRequests.at(-1)?.params).toMatchObject({ repoRoot: PICKED }); + + await page.evaluate((workspace) => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + if (!key) { + throw new Error("missing new-session preference"); + } + const value = JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record; + }; + const main = value.agents?.main; + if (!main) { + throw new Error("missing main-agent preference"); + } + main.folder = workspace; + main.workspace = workspace; + localStorage.setItem(key, JSON.stringify(value)); + }, WORKSPACE); + await gateway.setMethodResponse("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: false, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + await page.reload(); + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); + const storedWorktree = await page.evaluate(() => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + const value = key + ? (JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record; + } | null) + : null; + return value?.agents?.main?.worktree; + }); + expect(storedWorktree).toBe(false); + } finally { + await context.close(); + } + }); + + it("blocks an immediate submit until remembered model and worktree choices validate", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const models = [ + { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + }, + ]; + const branches = { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }; + const gateway = await installMockGateway(page, { + workspaceGit: true, + models, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": branches, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [{ name: "packages", path: PICKED }], + }, + }, + { + match: { path: PICKED }, + response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, + }, + ], + }, + "sessions.create": { key: "agent:main:restored-fast-submit", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await choosePackagesFolder(page); + const placeTrigger = page.locator("#new-session-place-trigger"); + await placeTrigger.click(); + await page.getByRole("button", { name: "Worktree" }).click(); + await page.keyboard.press("Escape"); + const modelSelect = page.locator('[data-chat-model-select="true"]'); + await modelSelect.click(); + await page.locator('[data-chat-model-provider="anthropic"]').click(); + await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); + + await navigateInApp(page, "chat"); + await waitForCommittedChatRoute(page); + const metadataRequests = (await gateway.getRequests("chat.metadata")).length; + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.deferNext("chat.metadata"); + await gateway.deferNext("worktrees.branches"); + await navigateInApp(page, "new-session"); + await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); + await expect + .poll(async () => (await gateway.getRequests("chat.metadata")).length) + .toBe(metadataRequests + 1); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); + + await page.locator(".new-session-page__message").fill("keep both remembered choices"); + const start = page.getByRole("button", { name: "Start thread" }); + await expect.poll(() => start.isDisabled()).toBe(true); + + await gateway.resolveDeferred("chat.metadata", { models }); + await expect.poll(() => start.isDisabled()).toBe(true); + await gateway.rejectDeferred("worktrees.branches", { + code: "UNAVAILABLE", + message: "branch lookup unavailable", + }); + // A failed lookup disables the worktree toggle, so restoring the stored + // choice would strand the draft behind a control the user cannot clear. + // The draft drops it and stays submittable; storage keeps the preference. + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); + await expect.poll(() => start.isDisabled()).toBe(false); + + await page.reload(); + await page.locator(".new-session-page__message").fill("keep both remembered choices"); + await expect + .poll(() => modelSelect.getAttribute("data-chat-select-value")) + .toBe("anthropic/claude-sonnet-4-6"); + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); + await expect.poll(() => start.isDisabled()).toBe(false); + await start.click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: PICKED, + message: "keep both remembered choices", + model: "anthropic/claude-sonnet-4-6", + worktree: true, + }); + } finally { + await context.close(); + } + }); + + it("repairs a remembered default folder when the agent workspace moves", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + models: [ + { id: "gpt-5.5", name: "GPT 5.5", provider: "openai" }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + }, + ], + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "fs.listDir": { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [], + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const placeTrigger = page.locator("#new-session-place-trigger"); + await placeTrigger.click(); + await page.getByRole("button", { name: "Browse folders" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + await navigateInApp(page, "chat"); + await page.waitForURL((url) => url.pathname.endsWith("/chat")); + + await gateway.setMethodResponse("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: MOVED_WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + await page.reload(); + await navigateInApp(page, "new-session"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw-next", + ); + + const modelSelect = page.locator('[data-chat-model-select="true"]'); + await modelSelect.click(); + await page.locator('[data-chat-model-provider="anthropic"]').click(); + await page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]').click(); + const storedPreference = await page.evaluate(() => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + if (!key) { + return null; + } + const value = JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record; + } | null; + return value?.agents?.main ?? null; + }); + expect(storedPreference).toMatchObject({ + workspace: MOVED_WORKSPACE, + folder: MOVED_WORKSPACE, + model: "anthropic/claude-sonnet-4-6", + }); + } finally { + await context.close(); + } + }); + + it("falls back to the current workspace when the remembered folder is gone", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [{ name: "packages", path: PICKED }], + }, + }, + { + match: { path: PICKED }, + response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, + }, + ], + }, + "sessions.create": { key: "agent:main:stale-folder-fallback", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await choosePackagesFolder(page); + + await navigateInApp(page, "chat"); + await waitForCommittedChatRoute(page); + const validationRequests = (await gateway.getRequests("fs.listDir")).length; + await gateway.deferNext("fs.listDir"); + await navigateInApp(page, "new-session"); + await expect + .poll(async () => (await gateway.getRequests("fs.listDir")).length) + .toBeGreaterThan(validationRequests); + const message = page.locator(".new-session-page__message"); + await message.fill("use a safe folder"); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(true); + + await gateway.rejectDeferred("fs.listDir", { + code: "INVALID_REQUEST", + message: `Error: ENOENT: no such file or directory, scandir '${PICKED}'`, + }); + const placeTrigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); + + const pickedListRequests = (await gateway.getRequests("fs.listDir")).filter( + (request) => + typeof request.params === "object" && + request.params != null && + "path" in request.params && + request.params.path === PICKED, + ).length; + await navigateInApp(page, "chat"); + await waitForCommittedChatRoute(page); + await navigateInApp(page, "new-session"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); + await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); + await expect + .poll( + async () => + (await gateway.getRequests("fs.listDir")).filter( + (request) => + typeof request.params === "object" && + request.params != null && + "path" in request.params && + request.params.path === PICKED, + ).length, + ) + .toBe(pickedListRequests); + + await message.fill("use the repaired preference"); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(false); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); + + it("keeps a newer folder choice when remembered-folder validation finishes late", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { + path: WORKSPACE, + parent: "/home/peter", + home: "/home/peter", + entries: [{ name: "packages", path: PICKED }], + }, + }, + { + match: { path: PICKED }, + response: { path: PICKED, parent: WORKSPACE, home: "/home/peter", entries: [] }, + }, + ], + }, + "sessions.create": { key: "agent:main:newer-folder-wins", runStarted: true }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + await choosePackagesFolder(page); + + await navigateInApp(page, "chat"); + await waitForCommittedChatRoute(page); + const validationRequests = (await gateway.getRequests("fs.listDir")).length; + await gateway.deferNext("fs.listDir"); + await navigateInApp(page, "new-session"); + await expect + .poll(async () => (await gateway.getRequests("fs.listDir")).length) + .toBeGreaterThan(validationRequests); + + const placeTrigger = page.locator("#new-session-place-trigger"); + await placeTrigger.click(); + await page + .locator('wa-popover.new-session-page__place-popover [data-value="workspace"]') + .click(); + await gateway.resolveDeferred("fs.listDir", { + path: PICKED, + parent: WORKSPACE, + home: "/home/peter", + entries: [], + }); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); + + await page.locator(".new-session-page__message").fill("keep the newer choice"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); + + it("keeps a folder chosen before the agent roster finishes loading submit-ready", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["agents.list"], + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "fs.listDir": { path: TARGET_REPO, home: "/home/peter", entries: [] }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + await page.getByRole("button", { name: "Browse folders" }).click(); + const browserPath = page.locator("input.new-session-page__browser-path"); + // Resolve the browser listing without resolving the deferred agent roster; + // otherwise its initial path update can race Playwright's folder input. + await expect.poll(() => browserPath.inputValue()).toBe(TARGET_REPO); + await browserPath.fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await gateway.resolveDeferred("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + + await page.locator(".new-session-page__message").fill("keep my early folder choice"); + await expect + .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) + .toBe(false); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toContain( + "target-repo", + ); + const storedPreference = await page.evaluate(() => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + if (!key) { + return null; + } + const value = JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record; + } | null; + return value?.agents?.main ?? null; + }); + expect(storedPreference).toMatchObject({ folder: TARGET_REPO }); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts new file mode 100644 index 000000000000..dff4dba3c00a --- /dev/null +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -0,0 +1,657 @@ +import { expect, it } from "vitest"; +import { + SOURCE_REPO, + TARGET_REPO, + WORKSPACE, + createNewSessionPageE2eSuite, + installMockGateway, + pollLocatorText, + replaceGatewayClient, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("preserves a selected workspace worktree when branch rediscovery is unavailable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: "agent:main:worktree-unavailable" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("worktrees.branches"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Worktree" }).click(); + await page.keyboard.press("Escape"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.deferNext("worktrees.branches"); + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); + await gateway.rejectDeferred("worktrees.branches", { + code: "UNAVAILABLE", + message: "branch lookup unavailable", + }); + + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await trigger.click(); + const worktree = place.getByRole("button", { name: "Worktree" }); + await expect.poll(() => worktree.getAttribute("aria-pressed")).toBe("true"); + expect(await worktree.isEnabled()).toBe(true); + await page.keyboard.press("Escape"); + + await page.locator(".new-session-page__message").fill("keep this task isolated"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + message: "keep this task isolated", + worktree: true, + }); + } finally { + await context.close(); + } + }); + + it("clears a custom worktree when the folder becomes confirmed non-Git", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: "agent:main:custom-now-direct" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + await trigger.click(); + await place.getByRole("button", { name: "Worktree" }).click(); + await page.keyboard.press("Escape"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + + await gateway.setMethodResponse("worktrees.branches", { + branches: [], + repositoryStatus: "not_git", + }); + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); + + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("false"); + const storedWorktree = await page.evaluate(() => { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.new-session.preferences.v1:")); + const value = key + ? (JSON.parse(localStorage.getItem(key) ?? "null") as { + agents?: Record; + } | null) + : null; + return value?.agents?.main?.worktree; + }); + expect(storedWorktree).toBe(false); + await trigger.click(); + expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await page.keyboard.press("Escape"); + await page.locator(".new-session-page__message").fill("continue directly"); + await page.getByRole("button", { name: "Start thread" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ cwd: TARGET_REPO, message: "continue directly" }); + expect(create.params).not.toHaveProperty("worktree"); + } finally { + await context.close(); + } + }); + + it("blocks a custom worktree when Git rediscovery is unavailable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + await trigger.click(); + await place.getByRole("button", { name: "Worktree" }).click(); + await page.keyboard.press("Escape"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + + await gateway.setMethodResponse("worktrees.branches", { + branches: [], + repositoryStatus: "unavailable", + }); + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); + + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await page.locator(".new-session-page__message").fill("do not run directly"); + const start = page.getByRole("button", { name: "Start thread" }); + await expect.poll(() => start.isDisabled()).toBe(true); + await trigger.click(); + const worktree = place.getByRole("button", { name: "Worktree" }); + expect(await worktree.isDisabled()).toBe(true); + expect(await worktree.getAttribute("title")).toBe( + "Couldn't verify Git for this folder. Choose it again to retry.", + ); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + } finally { + await context.close(); + } + }); + + it("blocks a custom cloud worktree when Git rediscovery is unavailable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "environments.list": { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill(TARGET_REPO); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: TARGET_REPO, includeRepositoryStatus: true }); + await trigger.click(); + await place.getByRole("button", { name: "Cloud · aws" }).click(); + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + + await gateway.setMethodResponse("worktrees.branches", { + branches: [], + repositoryStatus: "unavailable", + }); + const branchRequests = (await gateway.getRequests("worktrees.branches")).length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchRequests + 1); + + await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await page.locator(".new-session-page__message").fill("do not run directly"); + const start = page.getByRole("button", { name: "Start thread" }); + await expect.poll(() => start.isDisabled()).toBe(true); + await trigger.click(); + const cloud = place.getByRole("button", { name: "Cloud · aws" }); + expect(await cloud.isDisabled()).toBe(true); + expect(await cloud.getAttribute("title")).toBe( + "Couldn't verify Git for this folder. Choose it again to retry.", + ); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + } finally { + await context.close(); + } + }); + + it("validates a retained device before enabling submit after reconnect", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "node.list": { + nodes: [ + { + nodeId: "old-device", + displayName: "Old device", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: "agent:main:validated-device" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + await page.locator("#new-session-place-trigger").click(); + await placeSelect.getByRole("button", { name: "Old device" }).click(); + await page.locator(".new-session-page__message").fill("use a validated device"); + const start = page.locator("button.chat-send-btn"); + const nodeRequestsBefore = (await gateway.getRequests("node.list")).length; + + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.deferNext("node.list"); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBe(nodeRequestsBefore + 1); + await expect.poll(() => start.isDisabled()).toBe(true); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + + await gateway.resolveDeferred("node.list", { nodes: [] }); + await expect.poll(() => start.isEnabled()).toBe(true); + await start.click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).not.toHaveProperty("execNode"); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); + + it("rediscovers Gateway-owned draft state when the app replaces its client", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Original agent" }, + name: "Original agent", + workspace: SOURCE_REPO, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "node.list": { + nodes: [ + { + nodeId: "old-device", + displayName: "Old device", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "alpha" }], + defaultBranch: "alpha", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.getByRole("heading", { name: "Original agent" }).waitFor(); + await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("worktrees.branches"); + + const message = page.locator(".new-session-page__message"); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + await message.fill("preserve this replacement draft"); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Old device" }).click(); + + // Keep an old-client browser request in flight. Replacement must close + // its menu and prevent its eventual completion from reviving old state. + await gateway.deferNext("fs.listDir"); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await gateway.waitForRequest("fs.listDir"); + + await gateway.setMethodResponse("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Replacement agent" }, + name: "Replacement agent", + workspace: TARGET_REPO, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + await gateway.setMethodResponse("node.list", { + nodes: [ + { + nodeId: "new-device", + displayName: "New device", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }); + await gateway.setMethodResponse("worktrees.branches", { + branches: [{ kind: "local", name: "beta" }], + defaultBranch: "beta", + repositoryStatus: "git", + }); + const socketsBefore = await gateway.getSocketCount(); + const nodesBefore = (await gateway.getRequests("node.list")).length; + const branchesBefore = (await gateway.getRequests("worktrees.branches")).length; + + await replaceGatewayClient(page); + + await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBe(nodesBefore + 1); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).length) + .toBe(branchesBefore + 1); + await page.getByRole("heading", { name: "Replacement agent" }).waitFor(); + await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); + await expect + .poll(() => + placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(false); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "target-repo · Gateway · local", + ); + + const branchRequests = await gateway.getRequests("worktrees.branches"); + expect(branchRequests.at(-1)?.params).toEqual({ + repoRoot: TARGET_REPO, + includeRepositoryStatus: true, + }); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "New device" }).waitFor(); + expect(await placeSelect.getByRole("button", { name: "Old device" }).count()).toBe(0); + await placeSelect.getByRole("button", { name: "Worktree" }).click(); + await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("beta"); + await page.keyboard.press("Escape"); + + await gateway.resolveDeferred("fs.listDir", { + path: "/stale-device-path", + home: "/stale-device-path", + entries: [], + }); + await expect + .poll(() => + placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(false); + await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); + } finally { + await context.close(); + } + }); + + for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) { + it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`; + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Original agent" }, + name: "Original agent", + workspace: SOURCE_REPO, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.create": { key: sessionKey }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.getByRole("heading", { name: "Original agent" }).waitFor(); + const message = page.locator(".new-session-page__message"); + const start = page.locator("button.chat-send-btn"); + await message.fill("retry this draft after reconnect"); + await gateway.deferNext("sessions.create"); + await start.click(); + await gateway.waitForRequest("sessions.create"); + await expect.poll(() => start.isDisabled()).toBe(true); + + if (reconnectKind === "client replacement") { + await gateway.setMethodResponse("agents.list", { + agents: [ + { + id: "main", + identity: { name: "Replacement agent" }, + name: "Replacement agent", + workspace: TARGET_REPO, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }); + const socketsBefore = await gateway.getSocketCount(); + await replaceGatewayClient(page); + await expect.poll(() => gateway.getSocketCount()).toBe(socketsBefore + 1); + await page.getByRole("heading", { name: "Replacement agent" }).waitFor(); + } else { + const agentRequestsBefore = (await gateway.getRequests("agents.list")).length; + await gateway.setOnline(false); + await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 }); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("agents.list")).length) + .toBe(agentRequestsBefore + 1); + } + await expect.poll(() => message.inputValue()).toBe("retry this draft after reconnect"); + await expect.poll(() => message.isEnabled()).toBe(true); + await expect.poll(() => start.isDisabled()).toBe(true); + await page + .getByText( + "The Gateway changed while this thread was starting. Check recent threads before starting this task again.", + ) + .waitFor(); + expect(new URL(page.url()).pathname).toBe("/new"); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + } finally { + await context.close(); + } + }); + } + + it("resets agent-derived workspace state when retargeted to a catalog", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + "sessions.catalog.list": { + catalogs: [ + { + id: "claude", + label: "Claude Code", + capabilities: { + continueSession: true, + archive: false, + createSession: { model: "anthropic/claude-opus-4-8" }, + }, + hosts: [], + }, + ], + }, + "sessions.create": { key: "agent:main:claude-retarget" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new?agent=research`); + const folderLabel = page.locator( + "#new-session-place-trigger .new-session-page__trigger-label", + ); + await pollLocatorText(folderLabel).toBe("research"); + + await page.evaluate(() => { + history.pushState(null, "", "new?agent=main&catalog=claude"); + dispatchEvent(new PopStateEvent("popstate")); + }); + + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); + await pollLocatorText(folderLabel).toBe("openclaw"); + await page.locator(".new-session-page__message").fill("retarget this draft"); + await page.getByRole("button", { name: "Start thread" }).click(); + + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + message: "retarget this draft", + catalogId: "claude", + }); + expect(create.params).not.toHaveProperty("model"); + expect(create.params).not.toHaveProperty("cwd"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/session-management.archive.e2e.test.ts b/ui/src/e2e/session-management.archive.e2e.test.ts new file mode 100644 index 000000000000..30b2bc05eb3c --- /dev/null +++ b/ui/src/e2e/session-management.archive.e2e.test.ts @@ -0,0 +1,352 @@ +import { expect, it } from "vitest"; +import { + activateMenuItem, + captureUiProof, + controlUiSessionPath, + createSessionManagementE2eSuite, + installMockGateway, + requireRecord, + sessionRow, + sessionsListResponse, + waitForPatch, +} from "./session-management.test-support.ts"; + +const suite = createSessionManagementE2eSuite(); + +suite.define(() => { + it("archives a session from the Sessions page context menu and kebab", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), + sessionRow( + "agent:main:research", + "Research notes", + Date.parse("2026-07-01T15:00:00.000Z"), + ), + ]), + "sessions.patch": {}, + }, + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}sessions`); + const row = page.locator(".session-data-row").filter({ hasText: "Research notes" }); + await row.waitFor({ state: "visible", timeout: 10_000 }); + + await row.click({ button: "right" }); + const menuHost = page.locator("openclaw-session-menu"); + await menuHost + .getByRole("menuitem", { name: "Archive thread" }) + .waitFor({ state: "visible" }); + await page.keyboard.press("Escape"); + + await row.getByRole("button", { name: "Open thread menu" }).click(); + await activateMenuItem(menuHost.getByRole("menuitem", { name: "Archive thread" })); + const patch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:research" && params.archived === true, + ); + expect(requireRecord(patch.params)).toMatchObject({ + archived: true, + key: "agent:main:research", + }); + } finally { + await context.close(); + } + }); + + // Batch archiving used to force one canonical sessions.list per row, which is + // what made a multi-select stall. The whole selection must archive on one + // refresh and leave the sidebar settled with the rows gone. + + it("archives a sidebar multi-select with one canonical list refresh", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const batchKeys = ["agent:main:batch-a", "agent:main:batch-b", "agent:main:batch-c"] as const; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow(batchKeys[0], "Batch A", baseTime - 1_000), + sessionRow(batchKeys[1], "Batch B", baseTime - 2_000), + sessionRow(batchKeys[2], "Batch C", baseTime - 3_000), + ]), + "sessions.patch": {}, + }, + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const sidebar = page.locator("openclaw-app-sidebar"); + const rowFor = (key: string) => + sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`); + await rowFor(batchKeys[0]).waitFor({ state: "visible", timeout: 10_000 }); + for (const key of batchKeys.slice(1)) { + await rowFor(key).waitFor({ state: "visible" }); + } + const listCountBeforeBatch = (await gateway.getRequests("sessions.list")).length; + + for (const key of batchKeys) { + await rowFor(key).click({ modifiers: ["Meta"] }); + } + await rowFor(batchKeys[0]).click({ button: "right" }); + const batchMenu = page.locator("openclaw-session-menu"); + const archiveItem = batchMenu.getByRole("menuitem", { name: `Archive ${batchKeys.length}` }); + await archiveItem.waitFor({ state: "visible", timeout: 10_000 }); + await captureUiProof(page, "sidebar-multi-select-archive-menu.png"); + await activateMenuItem(archiveItem); + + for (const key of batchKeys) { + await waitForPatch(gateway, (params) => params.key === key && params.archived === true); + } + + const patches = await gateway.getRequests("sessions.patch"); + expect(patches.map((request) => requireRecord(request.params).key)).toEqual([...batchKeys]); + // The whole selection costs one canonical refresh, not one per archived row. + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 }) + .toBe(listCountBeforeBatch + 1); + await captureUiProof(page, "sidebar-multi-select-archive-settled.png"); + // Hold past the batch so a late per-row refresh would still be caught. + await page.waitForTimeout(500); + expect((await gateway.getRequests("sessions.list")).length).toBe(listCountBeforeBatch + 1); + } finally { + await context.close(); + } + }); + + it("keeps the selected session through archive refreshes and restores the composer", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const sessionRows = Array.from({ length: 15 }, (_, index) => + sessionRow( + `agent:main:archive-refresh-${index}`, + `Archive refresh ${index}`, + baseTime - (index + 1) * 1_000, + ), + ); + const selected = sessionRows[2]!; + const batchRows = [sessionRows[0]!, sessionRows[1]!, sessionRows[3]!]; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", baseTime), + ...sessionRows, + ]), + "sessions.patch": {}, + }, + sessionArchiveFiltering: true, + sessionKey: "agent:main:main", + }); + + const assertSelectedRoute = async () => { + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath(selected.key)); + const row = page.locator(`.sidebar-recent-session[data-session-key="${selected.key}"]`); + await row.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => row.getAttribute("class")) + .toContain("sidebar-recent-session--active"); + }; + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const sidebar = page.locator("openclaw-app-sidebar"); + const rowFor = (key: string) => + sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`); + await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 }); + await rowFor(selected.key).locator("a").first().click(); + await assertSelectedRoute(); + await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); + + for (const row of batchRows) { + await rowFor(row.key).click({ modifiers: ["Meta"] }); + } + await rowFor(batchRows[0]!.key).click({ button: "right" }); + const batchMenu = page.locator("openclaw-session-menu"); + await activateMenuItem( + batchMenu.getByRole("menuitem", { name: `Archive ${batchRows.length}` }), + ); + for (const row of batchRows) { + await waitForPatch(gateway, (params) => params.key === row.key && params.archived === true); + await gateway.emitGatewayEvent("sessions.changed", { + ...row, + archived: true, + reason: "update", + sessionKey: row.key, + }); + await assertSelectedRoute(); + } + + await gateway.setMethodResponse("sessions.describe", { + session: { ...selected, archived: true }, + }); + const selectedRow = rowFor(selected.key); + await selectedRow.hover(); + await selectedRow.getByRole("button", { name: "Open thread menu" }).click(); + await activateMenuItem( + page.locator("openclaw-session-menu").getByRole("menuitem", { + name: "Archive thread", + }), + ); + await waitForPatch( + gateway, + (params) => params.key === selected.key && params.archived === true, + ); + await gateway.emitGatewayEvent("sessions.changed", { + ...selected, + archived: true, + reason: "update", + sessionKey: selected.key, + }); + + await assertSelectedRoute(); + await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" }); + const archivedNotice = page.locator(".agent-chat__disabled-banner"); + await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived."); + await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0); + + await archivedNotice.getByRole("button", { name: "Unarchive" }).click(); + await waitForPatch( + gateway, + (params) => params.key === selected.key && params.archived === false, + ); + await gateway.emitGatewayEvent("sessions.changed", { + ...selected, + archived: false, + reason: "update", + sessionKey: selected.key, + }); + + await assertSelectedRoute(); + await archivedNotice.waitFor({ state: "detached", timeout: 10_000 }); + await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); + } finally { + await context.close(); + } + }); + + it("shows the archived notice when an archived session is cold-loaded outside the active list", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const archived = sessionRow( + "agent:main:dashboard:cold-archive", + "Archived planning", + Date.parse("2026-07-01T16:00:00.000Z"), + { archived: true }, + ); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.describe": { session: archived }, + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", archived.updatedAt + 1), + ]), + "sessions.patch": {}, + }, + sessionArchiveFiltering: true, + sessionKey: archived.key, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat?session=${encodeURIComponent(archived.key)}`); + + const selectedRow = page.locator( + `.sidebar-recent-session[data-session-key="${archived.key}"]`, + ); + await selectedRow.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => selectedRow.getAttribute("class")) + .toContain("sidebar-recent-session--active"); + await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" }); + await expect.poll(() => page.getByText("Archived planning", { exact: true }).count()).toBe(2); + + const archivedNotice = page.locator(".agent-chat__disabled-banner"); + await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived."); + await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0); + + await gateway.setMethodResponse("sessions.describe", { + session: { ...archived, archived: false }, + }); + await archivedNotice.getByRole("button", { name: "Unarchive" }).click(); + await waitForPatch( + gateway, + (params) => params.key === archived.key && params.archived === false, + ); + await gateway.emitGatewayEvent("sessions.changed", { + ...archived, + archived: false, + reason: "update", + sessionKey: archived.key, + }); + + await archivedNotice.waitFor({ state: "detached", timeout: 10_000 }); + await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); + } finally { + await context.close(); + } + }); + + it("keeps a session row when the Gateway reports no deletion", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const key = "agent:main:research"; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.delete": { ok: true, deleted: false }, + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), + sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z")), + ]), + }, + sessionKey: "agent:main:main", + }); + page.on("dialog", (dialog) => void dialog.accept()); + + try { + await page.goto(`${suite.server.baseUrl}sessions`); + const row = page.locator(".session-data-row").filter({ hasText: "Research notes" }); + await row.waitFor({ state: "visible", timeout: 10_000 }); + + await row.getByRole("button", { name: "Open thread menu" }).click(); + await activateMenuItem( + page.locator("openclaw-session-menu").getByRole("menuitem", { name: "Delete…" }), + ); + + const request = await gateway.waitForRequest("sessions.delete"); + expect(requireRecord(request.params)).toMatchObject({ key }); + await row.waitFor({ state: "visible" }); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts deleted file mode 100644 index 2ecf5ee46cd7..000000000000 --- a/ui/src/e2e/session-management.e2e.test.ts +++ /dev/null @@ -1,1926 +0,0 @@ -// Control UI tests cover session management through the sidebar, Sessions page, and command palette. -import { mkdir } from "node:fs/promises"; -import path from "node:path"; -import { chromium, type Browser, type Locator, type Page } from "playwright"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - canRunPlaywrightChromium, - controlUiSessionPath, - controlUiSessionUrl, - installMockGateway, - resolvePlaywrightChromiumExecutablePath, - startControlUiE2eServer, - type ControlUiE2eServer, - type MockGatewayControls, - type MockGatewayRequest, -} from "../test-helpers/control-ui-e2e.ts"; - -const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); -const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); -const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; -const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; -const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections"; -const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; -const uiProofArtifactDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "thread-management", -); - -let browser: Browser; -let server: ControlUiE2eServer; - -function sessionRow( - key: string, - label: string, - updatedAt: number, - options: { - archived?: boolean; - category?: string; - pinned?: boolean; - pinnedAt?: number; - hasActiveRun?: boolean; - status?: string; - spawnedBy?: string; - startedAt?: number; - endedAt?: number; - childSessions?: string[]; - execNode?: string; - worktree?: { branch?: string; repoRoot?: string }; - } = {}, -) { - return { - contextTokens: null, - displayName: label, - hasActiveRun: false, - key, - kind: "direct", - label, - model: "gpt-5.5", - modelProvider: "openai", - status: "done", - totalTokens: 0, - updatedAt, - ...options, - }; -} - -function sessionsListResponse( - sessions: unknown[], - options: { - hasMore?: boolean; - nextOffset?: number | null; - offset?: number; - totalCount?: number; - } = {}, -) { - return { - count: sessions.length, - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", - }, - hasMore: options.hasMore ?? false, - limitApplied: 50, - nextOffset: options.nextOffset ?? null, - offset: options.offset ?? 0, - path: "", - sessions, - totalCount: options.totalCount ?? sessions.length, - ts: Date.now(), - }; -} - -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} - -async function waitForPatch( - gateway: MockGatewayControls, - predicate: (params: Record) => boolean, -): Promise { - const deadline = Date.now() + 10_000; - let requests: MockGatewayRequest[] = []; - while (Date.now() < deadline) { - requests = await gateway.getRequests("sessions.patch"); - const match = requests.find((request) => predicate(requireRecord(request.params))); - if (match) { - return match; - } - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } - throw new Error(`No matching sessions.patch request found: ${JSON.stringify(requests)}`); -} - -async function activateMenuItem(item: Locator): Promise { - await item.evaluate((element) => (element as HTMLElement).click()); -} - -function trimmedTextContents(locator: Locator): Promise { - return locator.evaluateAll((elements) => - elements.map((element) => element.textContent?.trim() ?? ""), - ); -} - -function actionOpacity(button: Locator): Promise { - return button.evaluate((element) => globalThis.getComputedStyle(element).opacity); -} - -function actionPointerEvents(button: Locator): Promise { - return button.evaluate((element) => globalThis.getComputedStyle(element).pointerEvents); -} - -async function captureUiProof(page: Page, fileName: string) { - if (!captureUiProofEnabled) { - return; - } - await mkdir(uiProofArtifactDir, { recursive: true }); - await page.screenshot({ fullPage: true, path: path.join(uiProofArtifactDir, fileName) }); -} - -describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { - beforeAll(async () => { - if (!chromiumAvailable) { - throw new Error( - `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, - ); - } - server = await startControlUiE2eServer(); - browser = await chromium.launch({ executablePath: chromiumExecutablePath }); - }); - - afterAll(async () => { - await browser?.close(); - await server?.close(); - }); - - it("recovers an empty group catalog after a transient load failure", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.groups.list"], - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - methodResponses: { - "sessions.list": sessionsListResponse([]), - }, - sessionGroups: ["Recovered group"], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await gateway.waitForRequest("sessions.groups.list"); - await gateway.rejectDeferred("sessions.groups.list", { - code: "UNAVAILABLE", - message: "temporary catalog failure", - retryable: true, - }); - - await expect - .poll(async () => (await gateway.getRequests("sessions.groups.list")).length, { - timeout: 10_000, - }) - .toBe(2); - await page.locator('[data-session-section="category:Recovered group"]').waitFor({ - state: "visible", - }); - } finally { - await context.close(); - } - }); - - it("expands child sessions inline and opens a child chat", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const parentKey = "agent:main:release-plan"; - const childOneKey = "agent:main:research-sources"; - const childTwoKey = "agent:main:verify-tests"; - const context = await browser.newContext({ - colorScheme: "dark", - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": { - cases: [ - { - match: { spawnedBy: parentKey }, - response: sessionsListResponse([ - sessionRow(childOneKey, "Research sources", baseTime - 1_000, { - hasActiveRun: true, - spawnedBy: parentKey, - startedAt: baseTime - 61_000, - status: "running", - }), - sessionRow(childTwoKey, "Verify tests", baseTime - 2_000, { - endedAt: baseTime - 2_000, - spawnedBy: parentKey, - startedAt: baseTime - 62_000, - status: "done", - }), - ]), - }, - { - response: sessionsListResponse([ - sessionRow(parentKey, "Plan release", baseTime, { - childSessions: [childOneKey, childTwoKey], - }), - ]), - }, - ], - }, - }, - sessionKey: parentKey, - }); - - try { - await page.goto(controlUiSessionUrl(server.baseUrl, parentKey)); - const parent = page.locator(`[data-session-key="${parentKey}"]`); - await parent.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => page.locator(".sidebar-recent-session--child").count()).toBe(0); - await captureUiProof(page, "child-sessions-collapsed.png"); - - await parent.getByRole("button", { name: "Show 2 child threads for Plan release" }).click(); - await page.getByText("Research sources", { exact: true }).waitFor({ state: "visible" }); - await page.getByText("Verify tests", { exact: true }).waitFor({ state: "visible" }); - await expect - .poll(async () => - (await gateway.getRequests("sessions.list")).some( - (request) => requireRecord(request.params).spawnedBy === parentKey, - ), - ) - .toBe(true); - - const childRows = page.locator(".sidebar-recent-session--child"); - await expect.poll(() => childRows.count()).toBe(2); - expect(await childRows.getByRole("button", { name: "Open thread menu" }).count()).toBe(0); - await childRows.nth(0).getByRole("img", { name: "Active run" }).waitFor(); - await childRows.nth(1).getByRole("img", { name: "Done" }).waitFor(); - await captureUiProof(page, "child-sessions-expanded.png"); - - await childRows.nth(1).getByRole("link").click(); - await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(childTwoKey)); - } finally { - await context.close(); - } - }); - - it("keeps a rejected sidebar mutation visible until the user dismisses it", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.patch"], - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:rename-me", "Rename me", Date.now()), - ]), - }, - sessionKey: "agent:main:rename-me", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const row = page.locator('[data-session-key="agent:main:rename-me"]'); - await row.waitFor({ state: "visible", timeout: 10_000 }); - await row.hover(); - await row.getByRole("button", { name: "Open thread menu" }).click(); - page.once("dialog", (dialog) => void dialog.accept("Rejected rename")); - await page.getByRole("menuitem", { name: "Rename…" }).click(); - await gateway.waitForRequest("sessions.patch"); - await gateway.rejectDeferred("sessions.patch", { - code: "INVALID_REQUEST", - message: "sidebar rename rejected", - }); - - const error = page.locator("[data-sidebar-session-error]"); - await error.waitFor({ state: "visible" }); - await expect.poll(() => error.textContent()).toContain("sidebar rename rejected"); - expect( - await error - .locator("xpath=ancestor::*[contains(@class, 'sidebar-recent-sessions')]") - .count(), - ).toBe(0); - - await error.getByRole("button", { name: "Dismiss error" }).click(); - await expect.poll(() => error.count()).toBe(0); - } finally { - await context.close(); - } - }); - - it("manages sessions through the sidebar groups and command palette", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const context = await browser.newContext({ - colorScheme: "dark", - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.clock.install(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": { - cases: [ - ...[50, 100, 150].map((offset) => ({ - match: { offset, search: "helpers" }, - response: sessionsListResponse( - Array.from({ length: 50 }, (_, index) => - sessionRow( - `agent:main:hidden-helper-${offset + index}`, - `Hidden helper ${offset + index}`, - baseTime - offset - index, - { spawnedBy: "agent:main:main" }, - ), - ), - { hasMore: true, nextOffset: offset + 50, offset, totalCount: 250 }, - ), - })), - { - match: { search: "helpers" }, - response: sessionsListResponse( - Array.from({ length: 50 }, (_, index) => - sessionRow( - `agent:main:hidden-helper-${index}`, - `Hidden helper ${index}`, - baseTime - index, - { spawnedBy: "agent:main:main" }, - ), - ), - { hasMore: true, nextOffset: 50, totalCount: 250 }, - ), - }, - { - match: { offset: 50, search: "release" }, - response: sessionsListResponse( - [sessionRow("agent:main:release", "Release planning", baseTime - 60_000)], - { offset: 50, totalCount: 51 }, - ), - }, - { - match: { search: "release" }, - response: sessionsListResponse( - Array.from({ length: 50 }, (_, index) => - sessionRow( - `agent:main:release-helper-${index}`, - `Release helper ${index}`, - baseTime - index, - { spawnedBy: "agent:main:main" }, - ), - ), - { hasMore: true, nextOffset: 50, totalCount: 51 }, - ), - }, - { - match: {}, - response: sessionsListResponse([ - sessionRow("agent:main:main", "Main", baseTime), - sessionRow("agent:main:release", "Release planning", baseTime - 60_000, { - pinned: true, - pinnedAt: baseTime - 30_000, - }), - sessionRow("agent:main:migration", "Data migration", baseTime - 90_000, { - hasActiveRun: true, - status: "running", - }), - sessionRow("agent:main:research", "Research notes", baseTime - 120_000), - ]), - }, - ], - }, - "sessions.patch": {}, - }, - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - // Sidebar: pinned rows join the ordered page zone while staying out of Threads. - const sidebarRows = page.locator(".sidebar-recent-session"); - await sidebarRows.first().waitFor({ state: "visible", timeout: 10_000 }); - const pinnedZoneRow = page.locator( - '[data-sidebar-entry="session:agent:main:release"] .sidebar-recent-session', - ); - await expect.poll(() => pinnedZoneRow.textContent()).toContain("Release planning"); - const groups = page.locator(".sidebar-recent-sessions__group"); - await expect.poll(() => groups.count()).toBe(1); - await expect - .poll(() => groups.first().getAttribute("data-session-section")) - .toBe("ungrouped"); - await expect.poll(() => page.locator('[data-session-section="pinned"]').count()).toBe(0); - - // Chats keep recency order with the open session highlighted in place — - // selecting a row must not reshuffle the list. - const chatRows = page.locator('[data-session-section="ungrouped"] .sidebar-recent-session'); - const rowNames = () => - chatRows.evaluateAll((rows) => - rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""), - ); - await expect.poll(rowNames).toEqual(["Data migration", "Research notes"]); - const sidebarMigration = sidebarRows.filter({ hasText: "Data migration" }); - await expect - .poll(() => sidebarMigration.locator(".session-run-spinner").isVisible()) - .toBe(true); - - // Hover-revealed management actions on sidebar rows. - const sidebarResearch = sidebarRows.filter({ hasText: "Research notes" }); - const sidebarResearchPin = sidebarResearch.getByRole("button", { name: "Pin thread" }); - await page.mouse.move(900, 500); - await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("0"); - const sidebarReleasePin = sidebarRows - .filter({ hasText: "Release planning" }) - .getByRole("button", { name: "Unpin thread" }); - await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("0"); - await sidebarResearch.hover(); - await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("1"); - await captureUiProof(page, "sidebar-sessions.png"); - - await sidebarRows.filter({ hasText: "Release planning" }).hover(); - await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("1"); - await sidebarReleasePin.click(); - const pinPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:release" && params.pinned === false, - ); - expect(requireRecord(pinPatch.params)).toMatchObject({ - key: "agent:main:release", - pinned: false, - }); - - // The current-main full context menu remains intact: active rows cannot - // archive, while an idle row can. - await sidebarMigration.hover(); - await sidebarMigration.getByRole("button", { name: "Open thread menu" }).click(); - await expect - .poll(() => page.getByRole("menuitem", { name: "Archive thread" }).isDisabled()) - .toBe(true); - await page.keyboard.press("Escape"); - await sidebarResearch.hover(); - await sidebarResearch.getByRole("button", { name: "Open thread menu" }).click(); - await activateMenuItem(page.getByRole("menuitem", { name: "Archive thread" })); - const archivePatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:research" && params.archived === true, - ); - expect(requireRecord(archivePatch.params)).toMatchObject({ - archived: true, - key: "agent:main:research", - }); - - // Selecting a visible row must not reshuffle the list: the highlight - // moves while every row keeps its slot. (The mocked gateway keeps - // returning the same list, so the archived row stays visible here.) - const researchLink = sidebarResearch.locator("a").first(); - await researchLink.click(); - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath("agent:main:research")); - await expect.poll(rowNames).toEqual(["Release planning", "Data migration", "Research notes"]); - await expect - .poll(() => - chatRows - .filter({ hasText: "Research notes" }) - .first() - .evaluate((row) => row.classList.contains("sidebar-recent-session--active")), - ) - .toBe(true); - - // Command palette is the single search surface: querying lists matching - // chats from the gateway and selecting one navigates to it. - await page.getByRole("button", { name: "Open command palette" }).click(); - const paletteInput = page.locator(".cmd-palette__input"); - await paletteInput.waitFor({ state: "visible", timeout: 10_000 }); - // Automatic search is intentionally bounded: an all-hidden result set - // must not scan the entire session store from one palette query. - await paletteInput.fill("helpers"); - await expect - .poll(async () => { - const requests = await gateway.getRequests("sessions.list"); - return requests.filter((request) => requireRecord(request.params).search === "helpers") - .length; - }) - .toBe(4); - await page.clock.runFor(400); - const boundedSearchRequests = await gateway.getRequests("sessions.list"); - expect( - boundedSearchRequests.filter( - (request) => requireRecord(request.params).search === "helpers", - ), - ).toHaveLength(4); - - await paletteInput.fill("release"); - const paletteOption = page - .locator(".cmd-palette__item") - .filter({ hasText: "Release planning" }); - await paletteOption.waitFor({ state: "visible", timeout: 10_000 }); - // The first result page contains 50 hidden child sessions; search must - // follow nextOffset before exposing the visible chat on page two. - await expect - .poll(() => - page.locator(".cmd-palette__item").filter({ hasText: "Release helper" }).count(), - ) - .toBe(0); - const searchRequests = await gateway.getRequests("sessions.list"); - expect( - searchRequests.some((request) => { - const params = requireRecord(request.params); - return params.search === "release" && params.offset === 50; - }), - ).toBe(true); - await captureUiProof(page, "command-palette-session-search.png"); - await paletteOption.click(); - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath("agent:main:release")); - } finally { - await context.close(); - } - }); - - it("dismisses fixed session menus before the sidebar or drawer hides", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow( - "agent:main:research", - "Research notes", - Date.parse("2026-07-01T15:00:00.000Z"), - ), - ]), - "sessions.patch": {}, - }, - sessionKey: "agent:main:main", - }); - const dialogs: string[] = []; - page.on("dialog", (dialog) => { - dialogs.push(dialog.message()); - void dialog.dismiss(); - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const sidebar = page.locator("openclaw-app-sidebar"); - const row = sidebar.locator( - '.sidebar-recent-session[data-session-key="agent:main:research"]', - ); - const shell = page.locator(".shell"); - const shellNav = page.locator(".shell-nav"); - const collapseButton = page - .locator(".shell-chrome-controls") - .getByRole("button", { name: "Collapse sidebar" }); - const expandButton = page.locator(".shell-chrome-controls__nav-toggle"); - const drawerToggle = page - .locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible") - .first(); - const sessionMenu = page.getByRole("menu", { name: "Actions for Research notes" }); - await row.waitFor({ state: "visible", timeout: 10_000 }); - - const openSessionMenu = async () => { - await row.hover(); - await row.getByRole("button", { name: "Open thread menu" }).click(); - await page - .getByRole("menu", { name: "Actions for Research notes" }) - .waitFor({ state: "visible" }); - }; - const expectDesktopCollapsed = async () => { - await expect.poll(() => sidebar.isVisible()).toBe(false); - await expect.poll(() => expandButton.isVisible()).toBe(true); - await expect - .poll(() => expandButton.evaluate((element) => element === document.activeElement)) - .toBe(true); - }; - const expectDrawerClosed = async () => { - await expect - .poll(() => shell.getAttribute("class")) - .not.toContain("shell--nav-drawer-open"); - await expect - .poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().right)) - .toBeLessThanOrEqual(0); - }; - const hiddenActionCounts = async () => ({ - dialogs: dialogs.length, - patches: (await gateway.getRequests("sessions.patch")).length, - }); - const expectHiddenShortcutsInert = async ( - before: Awaited>, - ) => { - for (const shortcut of ["p", "a", "d"] as const) { - await page.keyboard.press(shortcut); - } - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - expect(await hiddenActionCounts()).toEqual(before); - }; - - // Keyboard collapse bypasses the menu's outside-pointer handler. The shell - // must explicitly unmount it before the sidebar becomes display:none. - await openSessionMenu(); - const beforeKeyboardCollapse = await hiddenActionCounts(); - await page.keyboard.press("Meta+B"); - await expectDesktopCollapsed(); - await expect.poll(() => sessionMenu.count()).toBe(0); - await expectHiddenShortcutsInert(beforeKeyboardCollapse); - - await expandButton.click(); - await expect.poll(() => sidebar.isVisible()).toBe(true); - - // The visible desktop control follows the same focus handoff contract. - await openSessionMenu(); - await collapseButton.click(); - await expectDesktopCollapsed(); - await expect.poll(() => sessionMenu.count()).toBe(0); - await expandButton.click(); - await expect.poll(() => sidebar.isVisible()).toBe(true); - - // Crossing into drawer layout hides the desktop sidebar without toggling - // persisted collapse state, so resize owns this dismissal and focus move. - await openSessionMenu(); - const beforeNarrowTransition = await hiddenActionCounts(); - await page.setViewportSize({ height: 900, width: 900 }); - await expectDrawerClosed(); - await expect.poll(() => sessionMenu.count()).toBe(0); - await expect - .poll(() => drawerToggle.evaluate((element) => element === document.activeElement)) - .toBe(true); - await expectHiddenShortcutsInert(beforeNarrowTransition); - - await drawerToggle.click(); - await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open"); - await expect - .poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().left)) - .toBe(0); - await openSessionMenu(); - const beforeDrawerCollapse = await hiddenActionCounts(); - await page.keyboard.press("Meta+B"); - await expectDrawerClosed(); - await expect.poll(() => sessionMenu.count()).toBe(0); - await expect - .poll(() => drawerToggle.evaluate((element) => element === document.activeElement)) - .toBe(true); - await expectHiddenShortcutsInert(beforeDrawerCollapse); - } finally { - await context.close(); - } - }); - - it("archives a session from the Sessions page context menu and kebab", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow( - "agent:main:research", - "Research notes", - Date.parse("2026-07-01T15:00:00.000Z"), - ), - ]), - "sessions.patch": {}, - }, - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}sessions`); - const row = page.locator(".session-data-row").filter({ hasText: "Research notes" }); - await row.waitFor({ state: "visible", timeout: 10_000 }); - - await row.click({ button: "right" }); - const menuHost = page.locator("openclaw-session-menu"); - await menuHost - .getByRole("menuitem", { name: "Archive thread" }) - .waitFor({ state: "visible" }); - await page.keyboard.press("Escape"); - - await row.getByRole("button", { name: "Open thread menu" }).click(); - await activateMenuItem(menuHost.getByRole("menuitem", { name: "Archive thread" })); - const patch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:research" && params.archived === true, - ); - expect(requireRecord(patch.params)).toMatchObject({ - archived: true, - key: "agent:main:research", - }); - } finally { - await context.close(); - } - }); - - // Batch archiving used to force one canonical sessions.list per row, which is - // what made a multi-select stall. The whole selection must archive on one - // refresh and leave the sidebar settled with the rows gone. - it("archives a sidebar multi-select with one canonical list refresh", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const batchKeys = ["agent:main:batch-a", "agent:main:batch-b", "agent:main:batch-c"] as const; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", baseTime), - sessionRow(batchKeys[0], "Batch A", baseTime - 1_000), - sessionRow(batchKeys[1], "Batch B", baseTime - 2_000), - sessionRow(batchKeys[2], "Batch C", baseTime - 3_000), - ]), - "sessions.patch": {}, - }, - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const sidebar = page.locator("openclaw-app-sidebar"); - const rowFor = (key: string) => - sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`); - await rowFor(batchKeys[0]).waitFor({ state: "visible", timeout: 10_000 }); - for (const key of batchKeys.slice(1)) { - await rowFor(key).waitFor({ state: "visible" }); - } - const listCountBeforeBatch = (await gateway.getRequests("sessions.list")).length; - - for (const key of batchKeys) { - await rowFor(key).click({ modifiers: ["Meta"] }); - } - await rowFor(batchKeys[0]).click({ button: "right" }); - const batchMenu = page.locator("openclaw-session-menu"); - const archiveItem = batchMenu.getByRole("menuitem", { name: `Archive ${batchKeys.length}` }); - await archiveItem.waitFor({ state: "visible", timeout: 10_000 }); - await captureUiProof(page, "sidebar-multi-select-archive-menu.png"); - await activateMenuItem(archiveItem); - - for (const key of batchKeys) { - await waitForPatch(gateway, (params) => params.key === key && params.archived === true); - } - - const patches = await gateway.getRequests("sessions.patch"); - expect(patches.map((request) => requireRecord(request.params).key)).toEqual([...batchKeys]); - // The whole selection costs one canonical refresh, not one per archived row. - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 }) - .toBe(listCountBeforeBatch + 1); - await captureUiProof(page, "sidebar-multi-select-archive-settled.png"); - // Hold past the batch so a late per-row refresh would still be caught. - await page.waitForTimeout(500); - expect((await gateway.getRequests("sessions.list")).length).toBe(listCountBeforeBatch + 1); - } finally { - await context.close(); - } - }); - - it("keeps the selected session through archive refreshes and restores the composer", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const sessionRows = Array.from({ length: 15 }, (_, index) => - sessionRow( - `agent:main:archive-refresh-${index}`, - `Archive refresh ${index}`, - baseTime - (index + 1) * 1_000, - ), - ); - const selected = sessionRows[2]!; - const batchRows = [sessionRows[0]!, sessionRows[1]!, sessionRows[3]!]; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", baseTime), - ...sessionRows, - ]), - "sessions.patch": {}, - }, - sessionArchiveFiltering: true, - sessionKey: "agent:main:main", - }); - - const assertSelectedRoute = async () => { - await expect - .poll(() => new URL(page.url()).pathname) - .toBe(controlUiSessionPath(selected.key)); - const row = page.locator(`.sidebar-recent-session[data-session-key="${selected.key}"]`); - await row.waitFor({ state: "visible", timeout: 10_000 }); - await expect - .poll(() => row.getAttribute("class")) - .toContain("sidebar-recent-session--active"); - }; - - try { - await page.goto(`${server.baseUrl}chat`); - const sidebar = page.locator("openclaw-app-sidebar"); - const rowFor = (key: string) => - sidebar.locator(`.sidebar-recent-session[data-session-key="${key}"]`); - await rowFor(selected.key).waitFor({ state: "visible", timeout: 10_000 }); - await rowFor(selected.key).locator("a").first().click(); - await assertSelectedRoute(); - await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); - - for (const row of batchRows) { - await rowFor(row.key).click({ modifiers: ["Meta"] }); - } - await rowFor(batchRows[0]!.key).click({ button: "right" }); - const batchMenu = page.locator("openclaw-session-menu"); - await activateMenuItem( - batchMenu.getByRole("menuitem", { name: `Archive ${batchRows.length}` }), - ); - for (const row of batchRows) { - await waitForPatch(gateway, (params) => params.key === row.key && params.archived === true); - await gateway.emitGatewayEvent("sessions.changed", { - ...row, - archived: true, - reason: "update", - sessionKey: row.key, - }); - await assertSelectedRoute(); - } - - await gateway.setMethodResponse("sessions.describe", { - session: { ...selected, archived: true }, - }); - const selectedRow = rowFor(selected.key); - await selectedRow.hover(); - await selectedRow.getByRole("button", { name: "Open thread menu" }).click(); - await activateMenuItem( - page.locator("openclaw-session-menu").getByRole("menuitem", { - name: "Archive thread", - }), - ); - await waitForPatch( - gateway, - (params) => params.key === selected.key && params.archived === true, - ); - await gateway.emitGatewayEvent("sessions.changed", { - ...selected, - archived: true, - reason: "update", - sessionKey: selected.key, - }); - - await assertSelectedRoute(); - await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" }); - const archivedNotice = page.locator(".agent-chat__disabled-banner"); - await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived."); - await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0); - - await archivedNotice.getByRole("button", { name: "Unarchive" }).click(); - await waitForPatch( - gateway, - (params) => params.key === selected.key && params.archived === false, - ); - await gateway.emitGatewayEvent("sessions.changed", { - ...selected, - archived: false, - reason: "update", - sessionKey: selected.key, - }); - - await assertSelectedRoute(); - await archivedNotice.waitFor({ state: "detached", timeout: 10_000 }); - await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); - } finally { - await context.close(); - } - }); - - it("shows the archived notice when an archived session is cold-loaded outside the active list", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const archived = sessionRow( - "agent:main:dashboard:cold-archive", - "Archived planning", - Date.parse("2026-07-01T16:00:00.000Z"), - { archived: true }, - ); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.describe": { session: archived }, - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", archived.updatedAt + 1), - ]), - "sessions.patch": {}, - }, - sessionArchiveFiltering: true, - sessionKey: archived.key, - }); - - try { - await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(archived.key)}`); - - const selectedRow = page.locator( - `.sidebar-recent-session[data-session-key="${archived.key}"]`, - ); - await selectedRow.waitFor({ state: "visible", timeout: 10_000 }); - await expect - .poll(() => selectedRow.getAttribute("class")) - .toContain("sidebar-recent-session--active"); - await selectedRow.locator(".sidebar-session__archive-glyph").waitFor({ state: "visible" }); - await expect.poll(() => page.getByText("Archived planning", { exact: true }).count()).toBe(2); - - const archivedNotice = page.locator(".agent-chat__disabled-banner"); - await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => archivedNotice.textContent()).toContain("This session is archived."); - await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0); - - await gateway.setMethodResponse("sessions.describe", { - session: { ...archived, archived: false }, - }); - await archivedNotice.getByRole("button", { name: "Unarchive" }).click(); - await waitForPatch( - gateway, - (params) => params.key === archived.key && params.archived === false, - ); - await gateway.emitGatewayEvent("sessions.changed", { - ...archived, - archived: false, - reason: "update", - sessionKey: archived.key, - }); - - await archivedNotice.waitFor({ state: "detached", timeout: 10_000 }); - await page.locator(".agent-chat__input textarea").waitFor({ state: "visible" }); - } finally { - await context.close(); - } - }); - - it("keeps a session row when the Gateway reports no deletion", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const key = "agent:main:research"; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.delete": { ok: true, deleted: false }, - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z")), - ]), - }, - sessionKey: "agent:main:main", - }); - page.on("dialog", (dialog) => void dialog.accept()); - - try { - await page.goto(`${server.baseUrl}sessions`); - const row = page.locator(".session-data-row").filter({ hasText: "Research notes" }); - await row.waitFor({ state: "visible", timeout: 10_000 }); - - await row.getByRole("button", { name: "Open thread menu" }).click(); - await activateMenuItem( - page.locator("openclaw-session-menu").getByRole("menuitem", { name: "Delete…" }), - ); - - const request = await gateway.waitForRequest("sessions.delete"); - expect(requireRecord(request.params)).toMatchObject({ key }); - await row.waitFor({ state: "visible" }); - } finally { - await context.close(); - } - }); - - it("shows a rejected Sessions-page custom group instead of leaking a page error", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.groups.put"], - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - methodResponses: { - "sessions.list": sessionsListResponse([]), - }, - sessionKey: "agent:main:main", - }); - const pageErrors: string[] = []; - page.on("pageerror", (error) => pageErrors.push(error.message)); - - try { - await page.goto(`${server.baseUrl}sessions`); - await page.locator(".session-groupby__select").selectOption("category"); - page.once("dialog", (dialog) => void dialog.accept("X".repeat(513))); - await page.getByRole("button", { name: "New group…" }).click(); - await gateway.waitForRequest("sessions.groups.put"); - await gateway.rejectDeferred("sessions.groups.put", { - code: "INVALID_REQUEST", - message: "group name exceeds 512 characters", - }); - - const error = page.locator(".sessions-error"); - await error.waitFor({ state: "visible" }); - await expect.poll(() => error.textContent()).toContain("group name exceeds 512 characters"); - expect(pageErrors).toEqual([]); - } finally { - await context.close(); - } - }); - - it("keeps sidebar sessions visible through a same-client Gateway reconnect", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessionKey = "agent:main:disconnect-proof"; - const otherSessionKeys = ["agent:main:other-a", "agent:main:other-b"] as const; - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow(sessionKey, "Disconnect proof", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")), - sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")), - ]), - }, - sessionKey, - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const sidebarRow = page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`); - await sidebarRow.waitFor({ state: "visible", timeout: 10_000 }); - const sidebarRows = page.locator(".sidebar-recent-session"); - await expect.poll(() => sidebarRows.count()).toBe(3); - const initialListCount = (await gateway.getRequests("sessions.list")).length; - - await gateway.closeLatest(1006, "disconnect proof"); - await gateway.deferNext("sessions.list"); - await sidebarRow.waitFor({ state: "visible" }); - await captureUiProof(page, "sidebar-sessions-during-reconnect.png"); - - await expect.poll(() => gateway.getSocketCount(), { timeout: 15_000 }).toBeGreaterThan(1); - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 15_000 }) - .toBeGreaterThan(initialListCount); - await sidebarRow.waitFor({ state: "visible" }); - expect(await sidebarRows.count()).toBe(3); - for (const otherKey of otherSessionKeys) { - await page - .locator(`.sidebar-recent-session[data-session-key="${otherKey}"]`) - .waitFor({ state: "visible" }); - } - - const firstReconnectListCount = (await gateway.getRequests("sessions.list")).length; - const refreshedResponse = sessionsListResponse([ - sessionRow(sessionKey, "Reconnect refreshed", Date.parse("2026-07-01T16:01:00.000Z")), - sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")), - sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")), - ]); - // Reconnect can queue a second refresh behind session hydration. Hold both - // so each response carries the changed black-box fixture. - await gateway.deferNext("sessions.list"); - await gateway.resolveDeferred("sessions.list", refreshedResponse); - await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed"); - await expect.poll(() => sidebarRows.count()).toBe(3); - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 }) - .toBeGreaterThan(firstReconnectListCount); - await gateway.resolveDeferred("sessions.list", refreshedResponse); - await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed"); - } finally { - await context.close(); - } - }); - - it("renames, deletes, and toggles sidebar session groups", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": { - cases: [ - { - match: { archived: true }, - response: sessionsListResponse([ - sessionRow("agent:main:old-notes", "Old notes", baseTime - 300_000, { - archived: true, - category: "Research", - }), - ]), - }, - { - match: {}, - response: sessionsListResponse([ - sessionRow("agent:main:main", "Main", baseTime), - sessionRow("agent:main:apps", "Apps", baseTime - 30_000, { - category: "Apps", - }), - sessionRow("agent:main:paper-a", "Paper A", baseTime - 60_000, { - category: "Research", - }), - sessionRow("agent:main:paper-b", "Paper B", baseTime - 90_000, { - category: "Research", - }), - ]), - }, - ], - }, - "sessions.patch": {}, - }, - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - sessionKey: "agent:main:main", - sessionGroups: ["Apps", "Research"], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - // Categorized rows render as their own sidebar group section. - const groups = page.locator(".sidebar-recent-sessions__group"); - const researchGroup = groups.filter({ hasText: "Research" }); - await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(2); - await captureUiProof(page, "sidebar-session-groups.png"); - - // Rename group: the gateway renames the catalog entry and repoints every - // member session server-side (sessions.groups.rename), no per-member patches. - const groupMenuButton = researchGroup.getByRole("button", { - name: "Group options for Research", - }); - await researchGroup.locator(".sidebar-recent-sessions__head").hover(); - await groupMenuButton.click(); - await page.getByRole("menuitem", { name: "Rename group…" }).waitFor({ state: "visible" }); - await captureUiProof(page, "sidebar-group-menu.png"); - page.once("dialog", (dialog) => void dialog.accept("Projects")); - await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); - const renameRequest = await gateway.waitForRequest("sessions.groups.rename"); - expect(requireRecord(renameRequest.params)).toMatchObject({ - name: "Research", - to: "Projects", - }); - await expect - .poll(() => - page - .locator('[data-session-section^="category:"]') - .evaluateAll((elements) => - elements.map((element) => element.getAttribute("data-session-section")), - ), - ) - .toEqual(["category:Apps", "category:Projects"]); - const projectsGroup = groups.filter({ hasText: "Projects" }); - await expect.poll(() => projectsGroup.locator(".sidebar-recent-session").count()).toBe(2); - - // Delete group: the gateway drops the catalog entry and moves member - // sessions back to Chats server-side (sessions.groups.delete). - const projectsMenuButton = projectsGroup.getByRole("button", { - name: "Group options for Projects", - }); - await projectsGroup.locator(".sidebar-recent-sessions__head").hover(); - page.once("dialog", (dialog) => void dialog.accept()); - await projectsMenuButton.click(); - await activateMenuItem(page.getByRole("menuitem", { name: "Delete group…" })); - const deleteRequest = await gateway.waitForRequest("sessions.groups.delete"); - expect(requireRecord(deleteRequest.params)).toMatchObject({ name: "Projects" }); - await expect - .poll(() => - page - .locator('[data-session-section^="category:"]') - .evaluateAll((elements) => - elements.map((element) => element.getAttribute("data-session-section")), - ), - ) - .toEqual(["category:Apps"]); - await expect - .poll(() => - page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').count(), - ) - .toBe(2); - - // Group by "None" flattens the category sections into the plain list. - const sortSessionsButton = page.locator( - "button.sidebar-session-sort:not(.sidebar-session-new)", - ); - await sortSessionsButton.click(); - await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" }); - await captureUiProof(page, "sidebar-groupby-sort-menu.png"); - await sortSessionsButton.click(); - await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false"); - await expect.poll(() => page.getByRole("menuitemradio", { name: "None" }).count()).toBe(0); - await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png"); - - await sortSessionsButton.click(); - await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); - await expect.poll(() => groups.count()).toBe(1); - await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3); - } finally { - await context.close(); - } - }); - - it("preserves a collapsed sidebar group when its rename is rejected", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await page.addInitScript( - ({ key, value }) => { - try { - if (localStorage.getItem(key) === null) { - localStorage.setItem(key, value); - } - } catch { - // The opaque initial document has no storage; the app origin does. - } - }, - { - key: collapsedSessionSectionsStorageKey, - value: JSON.stringify(["category:Research"]), - }, - ); - const gateway = await installMockGateway(page, { - deferredMethods: ["sessions.groups.rename"], - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", baseTime), - sessionRow("agent:main:paper", "Paper", baseTime - 60_000, { - category: "Research", - }), - ]), - }, - sessionGroups: ["Research"], - sessionKey: "agent:main:main", - }); - const pageErrors: string[] = []; - page.on("pageerror", (error) => pageErrors.push(error.message)); - - try { - await page.goto(`${server.baseUrl}chat`); - const researchGroup = page.locator('[data-session-section="category:Research"]'); - await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); - await researchGroup.locator(".sidebar-recent-sessions__head").hover(); - await researchGroup.getByRole("button", { name: "Group options for Research" }).click(); - page.once("dialog", (dialog) => void dialog.accept("Projects")); - await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); - await gateway.waitForRequest("sessions.groups.rename"); - await gateway.rejectDeferred("sessions.groups.rename", { - code: "INVALID_REQUEST", - message: "rejected group rename", - }); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - - expect( - await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey), - ).toBe(JSON.stringify(["category:Research"])); - await researchGroup.waitFor({ state: "visible" }); - expect(await page.locator('[data-session-section="category:Projects"]').count()).toBe(0); - expect(pageErrors).toEqual([]); - - await page.reload(); - await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); - await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); - expect( - await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey), - ).toBe(JSON.stringify(["category:Research"])); - } finally { - await context.close(); - } - }); - - it("pages sidebar sessions and supports complete drag-managed groups", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const sessions = Array.from({ length: 13 }, (_, index) => - sessionRow(`agent:main:session-${index}`, `Session ${index}`, baseTime - index * 60_000, { - ...(index === 0 ? { category: "Alpha" } : {}), - ...(index === 1 ? { category: "Beta" } : {}), - }), - ); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse(sessions), - "sessions.patch": {}, - }, - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - sessionKey: "agent:main:session-0", - sessionGroups: ["Alpha", "Beta"], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const sidebarRows = page.locator(".sidebar-recent-session"); - // Category sections page independently: Alpha and Beta stay visible - // alongside the first ten rows in the ungrouped section. - await expect.poll(() => sidebarRows.count()).toBe(12); - await page.getByRole("button", { name: "Show more" }).click(); - await expect.poll(() => sidebarRows.count()).toBe(13); - await expect.poll(() => page.getByText("All threads", { exact: true }).count()).toBe(0); - await captureUiProof(page, "sidebar-all-sessions.png"); - - // New groups are created from a session's menu (Move to group → New group…), - // which files that session into the new group. - const sessionTen = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-10"]', - ); - await sessionTen.hover(); - await sessionTen.getByRole("button", { name: "Open thread menu" }).click(); - const moveToGroup = page.getByRole("menuitem", { name: "Move to group" }); - await expect.poll(() => moveToGroup.getAttribute("aria-haspopup")).toBe("menu"); - const moveToGroupIndex = await moveToGroup.evaluate((element) => - [...(element.parentElement?.children ?? [])] - .filter( - (item) => - item.localName === "wa-dropdown-item" && - item.getAttribute("slot") !== "submenu" && - !(item as HTMLElement & { disabled?: boolean }).disabled, - ) - .indexOf(element), - ); - expect(moveToGroupIndex).toBeGreaterThanOrEqual(0); - // Submenu ARIA is ready before Web Awesome finishes opening the dropdown. - // Wait for its focus contract so navigation keys cannot outrun the menu. - await expect - .poll(() => - page.locator("openclaw-session-menu > wa-dropdown > wa-dropdown-item:focus").count(), - ) - .toBe(1); - await page.keyboard.press("Home"); - for (let index = 0; index < moveToGroupIndex; index += 1) { - await page.keyboard.press("ArrowDown"); - } - await expect - .poll(() => moveToGroup.evaluate((element) => element === document.activeElement)) - .toBe(true); - await page.keyboard.press("ArrowRight"); - await expect.poll(() => moveToGroup.getAttribute("aria-expanded")).toBe("true"); - page.once("dialog", (dialog) => void dialog.accept("Gamma")); - await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); - const gamma = page.locator('[data-session-section="category:Gamma"]'); - await gamma.waitFor({ state: "visible" }); - const createdPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:session-10" && params.category === "Gamma", - ); - expect(requireRecord(createdPatch.params)).toMatchObject({ - category: "Gamma", - key: "agent:main:session-10", - }); - - const sessionEleven = page.locator( - '.sidebar-recent-session[data-session-key="agent:main:session-11"]', - ); - await sessionEleven.dragTo(gamma); - const groupedPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:session-11" && params.category === "Gamma", - ); - expect(requireRecord(groupedPatch.params)).toMatchObject({ - category: "Gamma", - key: "agent:main:session-11", - }); - await expect - .poll(() => gamma.locator(".sidebar-recent-session").count(), { timeout: 10_000 }) - .toBe(2); - await captureUiProof(page, "sidebar-session-dropped-into-group.png"); - - const ungrouped = page.locator('[data-session-section="ungrouped"]'); - await gamma - .locator('.sidebar-recent-session[data-session-key="agent:main:session-11"]') - .dragTo(ungrouped); - const ungroupedPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:session-11" && params.category === null, - ); - expect(requireRecord(ungroupedPatch.params)).toMatchObject({ - category: null, - key: "agent:main:session-11", - }); - await expect - .poll(() => ungrouped.locator(".sidebar-recent-session").count(), { timeout: 10_000 }) - .toBe(10); - - const alpha = page.locator('[data-session-section="category:Alpha"]'); - const alphaToggle = alpha.getByRole("button", { name: "Alpha", exact: true }); - await alphaToggle.click(); - await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0); - await captureUiProof(page, "sidebar-session-group-collapsed.png"); - await alphaToggle.click(); - await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(1); - await alphaToggle.click(); - await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0); - - // Header buttons intentionally keep their click behavior; reorder from - // the dedicated grip beside them. - await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, { - targetPosition: { x: 4, y: 2 }, - }); - const customGroupOrder = () => - page - .locator('[data-session-section^="category:"]') - .evaluateAll((elements) => - elements.map((element) => element.getAttribute("data-session-section")), - ); - await expect - .poll(customGroupOrder) - .toEqual(["category:Gamma", "category:Alpha", "category:Beta"]); - await captureUiProof(page, "sidebar-session-groups-reordered.png"); - - await page.reload(); - await expect - .poll(customGroupOrder) - .toEqual(["category:Gamma", "category:Alpha", "category:Beta"]); - await expect - .poll(() => - page - .locator('[data-session-section="category:Alpha"] .sidebar-session-group-toggle') - .getAttribute("aria-expanded"), - ) - .toBe("false"); - await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11); - - const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length; - const sortSessionsButton = page.getByRole("button", { name: "Sort threads" }); - await sortSessionsButton.locator("..").hover(); - await sortSessionsButton.click(); - await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); - const flatSection = page.locator('[data-session-section="ungrouped"]'); - await flatSection - .locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]') - .dragTo(flatSection); - expect((await gateway.getRequests("sessions.patch")).length).toBe(patchCountBeforeFlatDrag); - } finally { - await context.close(); - } - }); - - it("keeps a new empty group visible before the first saved session", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([]), - }, - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - sessionKey: "agent:main:main", - // Stored-but-empty catalog groups stay visible as sections/move targets. - sessionGroups: ["First group"], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const firstGroup = page.locator('[data-session-section="category:First group"]'); - await firstGroup.waitFor({ state: "visible" }); - - // A header-menu-created group starts empty and still gets a section. - await firstGroup.locator(".sidebar-recent-sessions__head").hover(); - await firstGroup.getByRole("button", { name: "Group options for First group" }).click(); - page.once("dialog", (dialog) => void dialog.accept("Second group")); - await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); - await page.locator('[data-session-section="category:Second group"]').waitFor({ - state: "visible", - }); - const putRequest = await gateway.waitForRequest("sessions.groups.put"); - expect(requireRecord(putRequest.params)).toMatchObject({ - names: ["First group", "Second group"], - }); - } finally { - await context.close(); - } - }); - - it("does not duplicate the active chat when its only session is pinned", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), { - pinned: true, - }), - ]), - "sessions.patch": {}, - }, - sessionKey: "agent:main:pinned", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]'); - const chatsGroup = page.locator('[data-session-section="ungrouped"]'); - await expect - .poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name"))) - .toEqual(["Pinned only"]); - await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0); - await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1); - // The empty Threads section only materializes after dragstart. Move the - // real pointer first so Playwright does not wait for a hidden target. - const pinnedRow = pinnedEntry.locator(".sidebar-recent-session"); - const sourceBox = await pinnedRow.boundingBox(); - if (!sourceBox) { - throw new Error("expected pinned session bounds"); - } - await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); - await page.mouse.down(); - await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 12, sourceBox.y + 12, { - steps: 4, - }); - const sessionList = page.locator(".sidebar-sessions"); - await sessionList.waitFor({ state: "visible" }); - const targetBox = await sessionList.boundingBox(); - if (!targetBox) { - throw new Error("expected session list bounds"); - } - await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { - steps: 8, - }); - await page.mouse.up(); - const unpinPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:pinned" && params.pinned === false, - ); - expect(requireRecord(unpinPatch.params)).toMatchObject({ - key: "agent:main:pinned", - pinned: false, - }); - await expect.poll(() => pinnedEntry.count()).toBe(0); - await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(1); - } finally { - await context.close(); - } - }); - - it("pins a session dropped into the interleaved sidebar zone", async () => { - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - recordVideo: captureUiProofEnabled - ? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } } - : undefined, - }); - const page = await context.newPage(); - const proofVideo = page.video(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow( - "agent:main:pinned", - "Already pinned", - Date.parse("2026-07-01T16:00:00.000Z"), - { - pinned: true, - }, - ), - sessionRow("agent:main:candidate", "Pin me", Date.parse("2026-07-01T15:59:00.000Z"), { - category: "Research", - }), - ]), - "sessions.patch": {}, - }, - featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], - sessionKey: "agent:main:candidate", - sessionGroups: ["Research"], - }); - - try { - await page.goto(`${server.baseUrl}chat`); - - const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]'); - const researchGroup = page.locator('[data-session-section="category:Research"]'); - await expect - .poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name"))) - .toEqual(["Already pinned"]); - await captureUiProof(page, "sidebar-session-before-pinned-drop.png"); - await researchGroup - .locator('.sidebar-recent-session[data-session-key="agent:main:candidate"]') - .dragTo(pinnedEntry); - - const pinPatch = await waitForPatch( - gateway, - (params) => params.key === "agent:main:candidate" && params.pinned === true, - ); - expect(requireRecord(pinPatch.params)).toMatchObject({ - key: "agent:main:candidate", - pinned: true, - }); - expect(requireRecord(pinPatch.params)).not.toHaveProperty("category"); - await expect - .poll(() => - trimmedTextContents( - page.locator('[data-sidebar-entry^="session:"] .sidebar-recent-session__name'), - ), - ) - .toEqual(["Already pinned", "Pin me"]); - await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); - await captureUiProof(page, "sidebar-session-dropped-into-pinned.png"); - } finally { - await context.close(); - if (proofVideo) { - await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-pinned-drop.webm")); - } - } - }); - - it("keeps raw ids out of work rows while their metadata grows in place", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const nodeHash = "11c38726acc6fac280357576c87acc6fac280357"; - const rows = (withWork: boolean) => { - const ts = baseTime + (withWork ? 5_000 : 0); - return [ - sessionRow("agent:main:main", "Main", ts), - sessionRow( - "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6d", - "", - ts - 60_000, - withWork ? { execNode: nodeHash } : {}, - ), - sessionRow( - "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e", - "", - ts - 120_000, - withWork - ? { - execNode: nodeHash, - worktree: { branch: "openclaw/wt-1", repoRoot: "/Users/dev/Projects/clawdbot" }, - } - : {}, - ), - sessionRow("agent:main:node-mcp-debug-4de003fbff138fcb9239c9378b2e", "", ts - 180_000), - ]; - }; - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse(rows(false)), - }, - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - await expect - .poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 }) - .toBeGreaterThan(0); - // Add work metadata only after first layout so the WebKit overlap - // regression still exercises in-place row growth. - const listRequests = (await gateway.getRequests("sessions.list")).length; - await gateway.setMethodResponse("sessions.list", sessionsListResponse(rows(true))); - await gateway.emitGatewayEvent("sessions.changed", { - reason: "update", - sessionKey: "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e", - }); - await expect - .poll(async () => (await gateway.getRequests("sessions.list")).length) - .toBeGreaterThan(listRequests); - const codingToggle = page.locator( - '[data-session-section="work"] .sidebar-session-group-toggle', - ); - await codingToggle.waitFor({ state: "visible" }); - await expect.poll(() => codingToggle.getAttribute("aria-expanded")).toBe("false"); - await codingToggle.click(); - const namesLocator = page.locator(".sidebar-recent-session__name"); - await expect - .poll(() => trimmedTextContents(namesLocator)) - .toContain("clawdbot ⎇ wt-1 · …0357"); - - // Names and subtitles never show raw node ids or raw agent keys. - const names = await trimmedTextContents(page.locator(".sidebar-recent-session__name")); - expect(names).toContain("New thread"); - expect(names).toContain("clawdbot ⎇ wt-1 · …0357"); - expect(names).toContain("node-mcp-debug-…8b2e"); - const subtitles = await trimmedTextContents( - page.locator(".sidebar-recent-session__subtitle"), - ); - expect(subtitles).toContain("…0357"); - for (const text of [...names, ...subtitles]) { - expect(text).not.toContain(nodeHash); - expect(text).not.toContain("agent:main:"); - } - - // Sections must lay out below the rows above them, not paint over them. - const overlaps = await page.evaluate(() => { - const rects = [ - ...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"), - ] - .map((element) => { - const rect = element.getBoundingClientRect(); - return { top: rect.top, bottom: rect.bottom }; - }) - .filter((rect) => rect.bottom > rect.top) - .toSorted((a, b) => a.top - b.top); - let bad = 0; - let previousBottom: number | undefined; - for (const rect of rects) { - if (previousBottom !== undefined && rect.top < previousBottom - 2) { - bad += 1; - } - previousBottom = rect.bottom; - } - return bad; - }); - expect(overlaps).toBe(0); - } finally { - await context.close(); - } - }); - - it("scrolls long session lists in short windows instead of squeezing sections", async () => { - const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); - const rows = [ - ...Array.from({ length: 8 }, (_, index) => - sessionRow(`agent:main:work-${index}`, `Work session ${index}`, baseTime - index * 60_000, { - worktree: { branch: `openclaw/wt-${index}`, repoRoot: "/Users/dev/Projects/clawdbot" }, - }), - ), - ...Array.from({ length: 30 }, (_, index) => - sessionRow(`agent:main:chat-${index}`, `Chat ${index}`, baseTime - (index + 10) * 60_000), - ), - ]; - const context = await browser.newContext({ - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 620, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse(rows), - }, - sessionKey: "agent:main:main", - }); - try { - await page.goto(`${server.baseUrl}chat`); - await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click(); - const loadMore = page - .locator('[data-session-section="ungrouped"]') - .getByRole("button", { name: "Show more" }); - for (let pageIndex = 0; pageIndex < 3 && (await loadMore.isVisible()); pageIndex += 1) { - await loadMore.click(); - } - await page.locator(".sidebar-shell__body").evaluate((element) => { - element.scrollTop = 0; - }); - await expect - .poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 }) - .toBe(rows.length); - await captureUiProof(page, "short-window-session-sections.png"); - - // Sections must stack below each other, not paint over the rows above. - const overlaps = await page.evaluate(() => { - const rects = [ - ...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"), - ] - .map((element) => { - const rect = element.getBoundingClientRect(); - return { top: rect.top, bottom: rect.bottom }; - }) - .filter((rect) => rect.bottom > rect.top) - .toSorted((a, b) => a.top - b.top); - let bad = 0; - let previousBottom: number | undefined; - for (const rect of rects) { - if (previousBottom !== undefined && rect.top < previousBottom - 2) { - bad += 1; - } - previousBottom = rect.bottom; - } - return bad; - }); - expect(overlaps).toBe(0); - - // The squeeze regression compressed sections into the viewport with no - // overflow; a healthy sidebar body is taller than its viewport and scrolls. - const scroll = await page.evaluate(() => { - const list = document.querySelector(".sidebar-shell__body"); - if (!list) { - return null; - } - list.scrollTop = list.scrollHeight; - return { - clientHeight: list.clientHeight, - scrollHeight: list.scrollHeight, - scrollTop: list.scrollTop, - }; - }); - expect(scroll).not.toBeNull(); - expect(scroll?.scrollHeight ?? 0).toBeGreaterThan(scroll?.clientHeight ?? 0); - expect(scroll?.scrollTop ?? 0).toBeGreaterThan(0); - } finally { - await context.close(); - } - }); - - it("keeps sidebar session controls reachable on touch pointers", async () => { - const context = await browser.newContext({ - hasTouch: true, - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 900, width: 1280 }, - }); - const page = await context.newPage(); - await installMockGateway(page, { - methodResponses: { - "sessions.list": sessionsListResponse([ - sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow( - "agent:main:research", - "Research notes", - Date.parse("2026-07-01T15:00:00.000Z"), - ), - ]), - }, - sessionKey: "agent:main:main", - }); - - try { - await page.goto(`${server.baseUrl}chat`); - const row = page - .locator(".sidebar-recent-sessions__list .sidebar-recent-session") - .filter({ hasText: "Research notes" }); - await row.waitFor({ state: "visible", timeout: 10_000 }); - const pin = row.getByRole("button", { name: "Pin thread" }); - const menu = row.getByRole("button", { name: "Open thread menu" }); - await expect.poll(() => actionOpacity(pin)).toBe("1"); - await expect.poll(() => actionPointerEvents(pin)).toBe("auto"); - await expect.poll(() => actionOpacity(menu)).toBe("1"); - await expect.poll(() => actionPointerEvents(menu)).toBe("auto"); - await menu.click(); - await page.getByRole("menuitem", { name: "Archive thread" }).waitFor({ state: "visible" }); - } finally { - await context.close(); - } - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/e2e/session-management.groups.e2e.test.ts b/ui/src/e2e/session-management.groups.e2e.test.ts new file mode 100644 index 000000000000..43c64c412af1 --- /dev/null +++ b/ui/src/e2e/session-management.groups.e2e.test.ts @@ -0,0 +1,790 @@ +import { expect, it } from "vitest"; +import { + actionOpacity, + activateMenuItem, + captureUiProof, + collapsedSessionSectionsStorageKey, + controlUiSessionPath, + createSessionManagementE2eSuite, + installMockGateway, + requireRecord, + sessionRow, + sessionsListResponse, + waitForPatch, +} from "./session-management.test-support.ts"; + +const suite = createSessionManagementE2eSuite(); + +suite.define(() => { + it("recovers an empty group catalog after a transient load failure", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.groups.list"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + methodResponses: { + "sessions.list": sessionsListResponse([]), + }, + sessionGroups: ["Recovered group"], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("sessions.groups.list"); + await gateway.rejectDeferred("sessions.groups.list", { + code: "UNAVAILABLE", + message: "temporary catalog failure", + retryable: true, + }); + + await expect + .poll(async () => (await gateway.getRequests("sessions.groups.list")).length, { + timeout: 10_000, + }) + .toBe(2); + await page.locator('[data-session-section="category:Recovered group"]').waitFor({ + state: "visible", + }); + } finally { + await context.close(); + } + }); + + it("keeps a rejected sidebar mutation visible until the user dismisses it", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.patch"], + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:rename-me", "Rename me", Date.now()), + ]), + }, + sessionKey: "agent:main:rename-me", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const row = page.locator('[data-session-key="agent:main:rename-me"]'); + await row.waitFor({ state: "visible", timeout: 10_000 }); + await row.hover(); + await row.getByRole("button", { name: "Open thread menu" }).click(); + page.once("dialog", (dialog) => void dialog.accept("Rejected rename")); + await page.getByRole("menuitem", { name: "Rename…" }).click(); + await gateway.waitForRequest("sessions.patch"); + await gateway.rejectDeferred("sessions.patch", { + code: "INVALID_REQUEST", + message: "sidebar rename rejected", + }); + + const error = page.locator("[data-sidebar-session-error]"); + await error.waitFor({ state: "visible" }); + await expect.poll(() => error.textContent()).toContain("sidebar rename rejected"); + expect( + await error + .locator("xpath=ancestor::*[contains(@class, 'sidebar-recent-sessions')]") + .count(), + ).toBe(0); + + await error.getByRole("button", { name: "Dismiss error" }).click(); + await expect.poll(() => error.count()).toBe(0); + } finally { + await context.close(); + } + }); + + it("manages sessions through the sidebar groups and command palette", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const context = await suite.browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.clock.install(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": { + cases: [ + ...[50, 100, 150].map((offset) => ({ + match: { offset, search: "helpers" }, + response: sessionsListResponse( + Array.from({ length: 50 }, (_, index) => + sessionRow( + `agent:main:hidden-helper-${offset + index}`, + `Hidden helper ${offset + index}`, + baseTime - offset - index, + { spawnedBy: "agent:main:main" }, + ), + ), + { hasMore: true, nextOffset: offset + 50, offset, totalCount: 250 }, + ), + })), + { + match: { search: "helpers" }, + response: sessionsListResponse( + Array.from({ length: 50 }, (_, index) => + sessionRow( + `agent:main:hidden-helper-${index}`, + `Hidden helper ${index}`, + baseTime - index, + { spawnedBy: "agent:main:main" }, + ), + ), + { hasMore: true, nextOffset: 50, totalCount: 250 }, + ), + }, + { + match: { offset: 50, search: "release" }, + response: sessionsListResponse( + [sessionRow("agent:main:release", "Release planning", baseTime - 60_000)], + { offset: 50, totalCount: 51 }, + ), + }, + { + match: { search: "release" }, + response: sessionsListResponse( + Array.from({ length: 50 }, (_, index) => + sessionRow( + `agent:main:release-helper-${index}`, + `Release helper ${index}`, + baseTime - index, + { spawnedBy: "agent:main:main" }, + ), + ), + { hasMore: true, nextOffset: 50, totalCount: 51 }, + ), + }, + { + match: {}, + response: sessionsListResponse([ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow("agent:main:release", "Release planning", baseTime - 60_000, { + pinned: true, + pinnedAt: baseTime - 30_000, + }), + sessionRow("agent:main:migration", "Data migration", baseTime - 90_000, { + hasActiveRun: true, + status: "running", + }), + sessionRow("agent:main:research", "Research notes", baseTime - 120_000), + ]), + }, + ], + }, + "sessions.patch": {}, + }, + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + // Sidebar: pinned rows join the ordered page zone while staying out of Threads. + const sidebarRows = page.locator(".sidebar-recent-session"); + await sidebarRows.first().waitFor({ state: "visible", timeout: 10_000 }); + const pinnedZoneRow = page.locator( + '[data-sidebar-entry="session:agent:main:release"] .sidebar-recent-session', + ); + await expect.poll(() => pinnedZoneRow.textContent()).toContain("Release planning"); + const groups = page.locator(".sidebar-recent-sessions__group"); + await expect.poll(() => groups.count()).toBe(1); + await expect + .poll(() => groups.first().getAttribute("data-session-section")) + .toBe("ungrouped"); + await expect.poll(() => page.locator('[data-session-section="pinned"]').count()).toBe(0); + + // Chats keep recency order with the open session highlighted in place — + // selecting a row must not reshuffle the list. + const chatRows = page.locator('[data-session-section="ungrouped"] .sidebar-recent-session'); + const rowNames = () => + chatRows.evaluateAll((rows) => + rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""), + ); + await expect.poll(rowNames).toEqual(["Data migration", "Research notes"]); + const sidebarMigration = sidebarRows.filter({ hasText: "Data migration" }); + await expect + .poll(() => sidebarMigration.locator(".session-run-spinner").isVisible()) + .toBe(true); + + // Hover-revealed management actions on sidebar rows. + const sidebarResearch = sidebarRows.filter({ hasText: "Research notes" }); + const sidebarResearchPin = sidebarResearch.getByRole("button", { name: "Pin thread" }); + await page.mouse.move(900, 500); + await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("0"); + const sidebarReleasePin = sidebarRows + .filter({ hasText: "Release planning" }) + .getByRole("button", { name: "Unpin thread" }); + await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("0"); + await sidebarResearch.hover(); + await expect.poll(() => actionOpacity(sidebarResearchPin)).toBe("1"); + await captureUiProof(page, "sidebar-sessions.png"); + + await sidebarRows.filter({ hasText: "Release planning" }).hover(); + await expect.poll(() => actionOpacity(sidebarReleasePin)).toBe("1"); + await sidebarReleasePin.click(); + const pinPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:release" && params.pinned === false, + ); + expect(requireRecord(pinPatch.params)).toMatchObject({ + key: "agent:main:release", + pinned: false, + }); + + // The current-main full context menu remains intact: active rows cannot + // archive, while an idle row can. + await sidebarMigration.hover(); + await sidebarMigration.getByRole("button", { name: "Open thread menu" }).click(); + await expect + .poll(() => page.getByRole("menuitem", { name: "Archive thread" }).isDisabled()) + .toBe(true); + await page.keyboard.press("Escape"); + await sidebarResearch.hover(); + await sidebarResearch.getByRole("button", { name: "Open thread menu" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Archive thread" })); + const archivePatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:research" && params.archived === true, + ); + expect(requireRecord(archivePatch.params)).toMatchObject({ + archived: true, + key: "agent:main:research", + }); + + // Selecting a visible row must not reshuffle the list: the highlight + // moves while every row keeps its slot. (The mocked gateway keeps + // returning the same list, so the archived row stays visible here.) + const researchLink = sidebarResearch.locator("a").first(); + await researchLink.click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath("agent:main:research")); + await expect.poll(rowNames).toEqual(["Release planning", "Data migration", "Research notes"]); + await expect + .poll(() => + chatRows + .filter({ hasText: "Research notes" }) + .first() + .evaluate((row) => row.classList.contains("sidebar-recent-session--active")), + ) + .toBe(true); + + // Command palette is the single search surface: querying lists matching + // chats from the gateway and selecting one navigates to it. + await page.getByRole("button", { name: "Open command palette" }).click(); + const paletteInput = page.locator(".cmd-palette__input"); + await paletteInput.waitFor({ state: "visible", timeout: 10_000 }); + // Automatic search is intentionally bounded: an all-hidden result set + // must not scan the entire session store from one palette query. + await paletteInput.fill("helpers"); + await expect + .poll(async () => { + const requests = await gateway.getRequests("sessions.list"); + return requests.filter((request) => requireRecord(request.params).search === "helpers") + .length; + }) + .toBe(4); + await page.clock.runFor(400); + const boundedSearchRequests = await gateway.getRequests("sessions.list"); + expect( + boundedSearchRequests.filter( + (request) => requireRecord(request.params).search === "helpers", + ), + ).toHaveLength(4); + + await paletteInput.fill("release"); + const paletteOption = page + .locator(".cmd-palette__item") + .filter({ hasText: "Release planning" }); + await paletteOption.waitFor({ state: "visible", timeout: 10_000 }); + // The first result page contains 50 hidden child sessions; search must + // follow nextOffset before exposing the visible chat on page two. + await expect + .poll(() => + page.locator(".cmd-palette__item").filter({ hasText: "Release helper" }).count(), + ) + .toBe(0); + const searchRequests = await gateway.getRequests("sessions.list"); + expect( + searchRequests.some((request) => { + const params = requireRecord(request.params); + return params.search === "release" && params.offset === 50; + }), + ).toBe(true); + await captureUiProof(page, "command-palette-session-search.png"); + await paletteOption.click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath("agent:main:release")); + } finally { + await context.close(); + } + }); + + it("shows a rejected Sessions-page custom group instead of leaking a page error", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.groups.put"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + methodResponses: { + "sessions.list": sessionsListResponse([]), + }, + sessionKey: "agent:main:main", + }); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + try { + await page.goto(`${suite.server.baseUrl}sessions`); + await page.locator(".session-groupby__select").selectOption("category"); + page.once("dialog", (dialog) => void dialog.accept("X".repeat(513))); + await page.getByRole("button", { name: "New group…" }).click(); + await gateway.waitForRequest("sessions.groups.put"); + await gateway.rejectDeferred("sessions.groups.put", { + code: "INVALID_REQUEST", + message: "group name exceeds 512 characters", + }); + + const error = page.locator(".sessions-error"); + await error.waitFor({ state: "visible" }); + await expect.poll(() => error.textContent()).toContain("group name exceeds 512 characters"); + expect(pageErrors).toEqual([]); + } finally { + await context.close(); + } + }); + + it("renames, deletes, and toggles sidebar session groups", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": { + cases: [ + { + match: { archived: true }, + response: sessionsListResponse([ + sessionRow("agent:main:old-notes", "Old notes", baseTime - 300_000, { + archived: true, + category: "Research", + }), + ]), + }, + { + match: {}, + response: sessionsListResponse([ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow("agent:main:apps", "Apps", baseTime - 30_000, { + category: "Apps", + }), + sessionRow("agent:main:paper-a", "Paper A", baseTime - 60_000, { + category: "Research", + }), + sessionRow("agent:main:paper-b", "Paper B", baseTime - 90_000, { + category: "Research", + }), + ]), + }, + ], + }, + "sessions.patch": {}, + }, + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + sessionKey: "agent:main:main", + sessionGroups: ["Apps", "Research"], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + // Categorized rows render as their own sidebar group section. + const groups = page.locator(".sidebar-recent-sessions__group"); + const researchGroup = groups.filter({ hasText: "Research" }); + await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(2); + await captureUiProof(page, "sidebar-session-groups.png"); + + // Rename group: the gateway renames the catalog entry and repoints every + // member session server-side (sessions.groups.rename), no per-member patches. + const groupMenuButton = researchGroup.getByRole("button", { + name: "Group options for Research", + }); + await researchGroup.locator(".sidebar-recent-sessions__head").hover(); + await groupMenuButton.click(); + await page.getByRole("menuitem", { name: "Rename group…" }).waitFor({ state: "visible" }); + await captureUiProof(page, "sidebar-group-menu.png"); + page.once("dialog", (dialog) => void dialog.accept("Projects")); + await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); + const renameRequest = await gateway.waitForRequest("sessions.groups.rename"); + expect(requireRecord(renameRequest.params)).toMatchObject({ + name: "Research", + to: "Projects", + }); + await expect + .poll(() => + page + .locator('[data-session-section^="category:"]') + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-session-section")), + ), + ) + .toEqual(["category:Apps", "category:Projects"]); + const projectsGroup = groups.filter({ hasText: "Projects" }); + await expect.poll(() => projectsGroup.locator(".sidebar-recent-session").count()).toBe(2); + + // Delete group: the gateway drops the catalog entry and moves member + // sessions back to Chats server-side (sessions.groups.delete). + const projectsMenuButton = projectsGroup.getByRole("button", { + name: "Group options for Projects", + }); + await projectsGroup.locator(".sidebar-recent-sessions__head").hover(); + page.once("dialog", (dialog) => void dialog.accept()); + await projectsMenuButton.click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Delete group…" })); + const deleteRequest = await gateway.waitForRequest("sessions.groups.delete"); + expect(requireRecord(deleteRequest.params)).toMatchObject({ name: "Projects" }); + await expect + .poll(() => + page + .locator('[data-session-section^="category:"]') + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-session-section")), + ), + ) + .toEqual(["category:Apps"]); + await expect + .poll(() => + page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').count(), + ) + .toBe(2); + + // Group by "None" flattens the category sections into the plain list. + const sortSessionsButton = page.locator( + "button.sidebar-session-sort:not(.sidebar-session-new)", + ); + await sortSessionsButton.click(); + await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" }); + await captureUiProof(page, "sidebar-groupby-sort-menu.png"); + await sortSessionsButton.click(); + await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false"); + await expect.poll(() => page.getByRole("menuitemradio", { name: "None" }).count()).toBe(0); + await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png"); + + await sortSessionsButton.click(); + await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); + await expect.poll(() => groups.count()).toBe(1); + await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3); + } finally { + await context.close(); + } + }); + + it("preserves a collapsed sidebar group when its rename is rejected", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await page.addInitScript( + ({ key, value }) => { + try { + if (localStorage.getItem(key) === null) { + localStorage.setItem(key, value); + } + } catch { + // The opaque initial document has no storage; the app origin does. + } + }, + { + key: collapsedSessionSectionsStorageKey, + value: JSON.stringify(["category:Research"]), + }, + ); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.groups.rename"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow("agent:main:paper", "Paper", baseTime - 60_000, { + category: "Research", + }), + ]), + }, + sessionGroups: ["Research"], + sessionKey: "agent:main:main", + }); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const researchGroup = page.locator('[data-session-section="category:Research"]'); + await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); + await researchGroup.locator(".sidebar-recent-sessions__head").hover(); + await researchGroup.getByRole("button", { name: "Group options for Research" }).click(); + page.once("dialog", (dialog) => void dialog.accept("Projects")); + await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); + await gateway.waitForRequest("sessions.groups.rename"); + await gateway.rejectDeferred("sessions.groups.rename", { + code: "INVALID_REQUEST", + message: "rejected group rename", + }); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + expect( + await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey), + ).toBe(JSON.stringify(["category:Research"])); + await researchGroup.waitFor({ state: "visible" }); + expect(await page.locator('[data-session-section="category:Projects"]').count()).toBe(0); + expect(pageErrors).toEqual([]); + + await page.reload(); + await researchGroup.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); + expect( + await page.evaluate((key) => localStorage.getItem(key), collapsedSessionSectionsStorageKey), + ).toBe(JSON.stringify(["category:Research"])); + } finally { + await context.close(); + } + }); + + it("pages sidebar sessions and supports complete drag-managed groups", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessions = Array.from({ length: 13 }, (_, index) => + sessionRow(`agent:main:session-${index}`, `Session ${index}`, baseTime - index * 60_000, { + ...(index === 0 ? { category: "Alpha" } : {}), + ...(index === 1 ? { category: "Beta" } : {}), + }), + ); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse(sessions), + "sessions.patch": {}, + }, + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + sessionKey: "agent:main:session-0", + sessionGroups: ["Alpha", "Beta"], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const sidebarRows = page.locator(".sidebar-recent-session"); + // Category sections page independently: Alpha and Beta stay visible + // alongside the first ten rows in the ungrouped section. + await expect.poll(() => sidebarRows.count()).toBe(12); + await page.getByRole("button", { name: "Show more" }).click(); + await expect.poll(() => sidebarRows.count()).toBe(13); + await expect.poll(() => page.getByText("All threads", { exact: true }).count()).toBe(0); + await captureUiProof(page, "sidebar-all-sessions.png"); + + // New groups are created from a session's menu (Move to group → New group…), + // which files that session into the new group. + const sessionTen = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-10"]', + ); + await sessionTen.hover(); + await sessionTen.getByRole("button", { name: "Open thread menu" }).click(); + const moveToGroup = page.getByRole("menuitem", { name: "Move to group" }); + await expect.poll(() => moveToGroup.getAttribute("aria-haspopup")).toBe("menu"); + const moveToGroupIndex = await moveToGroup.evaluate((element) => + [...(element.parentElement?.children ?? [])] + .filter( + (item) => + item.localName === "wa-dropdown-item" && + item.getAttribute("slot") !== "submenu" && + !(item as HTMLElement & { disabled?: boolean }).disabled, + ) + .indexOf(element), + ); + expect(moveToGroupIndex).toBeGreaterThanOrEqual(0); + // Submenu ARIA is ready before Web Awesome finishes opening the dropdown. + // Wait for its focus contract so navigation keys cannot outrun the menu. + await expect + .poll(() => + page.locator("openclaw-session-menu > wa-dropdown > wa-dropdown-item:focus").count(), + ) + .toBe(1); + await page.keyboard.press("Home"); + for (let index = 0; index < moveToGroupIndex; index += 1) { + await page.keyboard.press("ArrowDown"); + } + await expect + .poll(() => moveToGroup.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.press("ArrowRight"); + await expect.poll(() => moveToGroup.getAttribute("aria-expanded")).toBe("true"); + page.once("dialog", (dialog) => void dialog.accept("Gamma")); + await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); + const gamma = page.locator('[data-session-section="category:Gamma"]'); + await gamma.waitFor({ state: "visible" }); + const createdPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:session-10" && params.category === "Gamma", + ); + expect(requireRecord(createdPatch.params)).toMatchObject({ + category: "Gamma", + key: "agent:main:session-10", + }); + + const sessionEleven = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-11"]', + ); + await sessionEleven.dragTo(gamma); + const groupedPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:session-11" && params.category === "Gamma", + ); + expect(requireRecord(groupedPatch.params)).toMatchObject({ + category: "Gamma", + key: "agent:main:session-11", + }); + await expect + .poll(() => gamma.locator(".sidebar-recent-session").count(), { timeout: 10_000 }) + .toBe(2); + await captureUiProof(page, "sidebar-session-dropped-into-group.png"); + + const ungrouped = page.locator('[data-session-section="ungrouped"]'); + await gamma + .locator('.sidebar-recent-session[data-session-key="agent:main:session-11"]') + .dragTo(ungrouped); + const ungroupedPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:session-11" && params.category === null, + ); + expect(requireRecord(ungroupedPatch.params)).toMatchObject({ + category: null, + key: "agent:main:session-11", + }); + await expect + .poll(() => ungrouped.locator(".sidebar-recent-session").count(), { timeout: 10_000 }) + .toBe(10); + + const alpha = page.locator('[data-session-section="category:Alpha"]'); + const alphaToggle = alpha.getByRole("button", { name: "Alpha", exact: true }); + await alphaToggle.click(); + await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0); + await captureUiProof(page, "sidebar-session-group-collapsed.png"); + await alphaToggle.click(); + await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(1); + await alphaToggle.click(); + await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0); + + // Header buttons intentionally keep their click behavior; reorder from + // the dedicated grip beside them. + await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, { + targetPosition: { x: 4, y: 2 }, + }); + const customGroupOrder = () => + page + .locator('[data-session-section^="category:"]') + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-session-section")), + ); + await expect + .poll(customGroupOrder) + .toEqual(["category:Gamma", "category:Alpha", "category:Beta"]); + await captureUiProof(page, "sidebar-session-groups-reordered.png"); + + await page.reload(); + await expect + .poll(customGroupOrder) + .toEqual(["category:Gamma", "category:Alpha", "category:Beta"]); + await expect + .poll(() => + page + .locator('[data-session-section="category:Alpha"] .sidebar-session-group-toggle') + .getAttribute("aria-expanded"), + ) + .toBe("false"); + await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11); + + const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length; + const sortSessionsButton = page.getByRole("button", { name: "Sort threads" }); + await sortSessionsButton.locator("..").hover(); + await sortSessionsButton.click(); + await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); + const flatSection = page.locator('[data-session-section="ungrouped"]'); + await flatSection + .locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]') + .dragTo(flatSection); + expect((await gateway.getRequests("sessions.patch")).length).toBe(patchCountBeforeFlatDrag); + } finally { + await context.close(); + } + }); + + it("keeps a new empty group visible before the first saved session", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([]), + }, + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + sessionKey: "agent:main:main", + // Stored-but-empty catalog groups stay visible as sections/move targets. + sessionGroups: ["First group"], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const firstGroup = page.locator('[data-session-section="category:First group"]'); + await firstGroup.waitFor({ state: "visible" }); + + // A header-menu-created group starts empty and still gets a section. + await firstGroup.locator(".sidebar-recent-sessions__head").hover(); + await firstGroup.getByRole("button", { name: "Group options for First group" }).click(); + page.once("dialog", (dialog) => void dialog.accept("Second group")); + await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); + await page.locator('[data-session-section="category:Second group"]').waitFor({ + state: "visible", + }); + const putRequest = await gateway.waitForRequest("sessions.groups.put"); + expect(requireRecord(putRequest.params)).toMatchObject({ + names: ["First group", "Second group"], + }); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts new file mode 100644 index 000000000000..5512b8b9e803 --- /dev/null +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -0,0 +1,677 @@ +import path from "node:path"; +import { expect, it } from "vitest"; +import { + actionOpacity, + actionPointerEvents, + captureUiProof, + captureUiProofEnabled, + controlUiSessionPath, + controlUiSessionUrl, + createSessionManagementE2eSuite, + installMockGateway, + requireRecord, + sessionRow, + sessionsListResponse, + trimmedTextContents, + uiProofArtifactDir, + waitForPatch, +} from "./session-management.test-support.ts"; + +const suite = createSessionManagementE2eSuite(); + +suite.define(() => { + it("expands child sessions inline and opens a child chat", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const parentKey = "agent:main:release-plan"; + const childOneKey = "agent:main:research-sources"; + const childTwoKey = "agent:main:verify-tests"; + const context = await suite.browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": { + cases: [ + { + match: { spawnedBy: parentKey }, + response: sessionsListResponse([ + sessionRow(childOneKey, "Research sources", baseTime - 1_000, { + hasActiveRun: true, + spawnedBy: parentKey, + startedAt: baseTime - 61_000, + status: "running", + }), + sessionRow(childTwoKey, "Verify tests", baseTime - 2_000, { + endedAt: baseTime - 2_000, + spawnedBy: parentKey, + startedAt: baseTime - 62_000, + status: "done", + }), + ]), + }, + { + response: sessionsListResponse([ + sessionRow(parentKey, "Plan release", baseTime, { + childSessions: [childOneKey, childTwoKey], + }), + ]), + }, + ], + }, + }, + sessionKey: parentKey, + }); + + try { + await page.goto(controlUiSessionUrl(suite.server.baseUrl, parentKey)); + const parent = page.locator(`[data-session-key="${parentKey}"]`); + await parent.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => page.locator(".sidebar-recent-session--child").count()).toBe(0); + await captureUiProof(page, "child-sessions-collapsed.png"); + + await parent.getByRole("button", { name: "Show 2 child threads for Plan release" }).click(); + await page.getByText("Research sources", { exact: true }).waitFor({ state: "visible" }); + await page.getByText("Verify tests", { exact: true }).waitFor({ state: "visible" }); + await expect + .poll(async () => + (await gateway.getRequests("sessions.list")).some( + (request) => requireRecord(request.params).spawnedBy === parentKey, + ), + ) + .toBe(true); + + const childRows = page.locator(".sidebar-recent-session--child"); + await expect.poll(() => childRows.count()).toBe(2); + expect(await childRows.getByRole("button", { name: "Open thread menu" }).count()).toBe(0); + await childRows.nth(0).getByRole("img", { name: "Active run" }).waitFor(); + await childRows.nth(1).getByRole("img", { name: "Done" }).waitFor(); + await captureUiProof(page, "child-sessions-expanded.png"); + + await childRows.nth(1).getByRole("link").click(); + await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(childTwoKey)); + } finally { + await context.close(); + } + }); + + it("dismisses fixed session menus before the sidebar or drawer hides", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), + sessionRow( + "agent:main:research", + "Research notes", + Date.parse("2026-07-01T15:00:00.000Z"), + ), + ]), + "sessions.patch": {}, + }, + sessionKey: "agent:main:main", + }); + const dialogs: string[] = []; + page.on("dialog", (dialog) => { + dialogs.push(dialog.message()); + void dialog.dismiss(); + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const sidebar = page.locator("openclaw-app-sidebar"); + const row = sidebar.locator( + '.sidebar-recent-session[data-session-key="agent:main:research"]', + ); + const shell = page.locator(".shell"); + const shellNav = page.locator(".shell-nav"); + const collapseButton = page + .locator(".shell-chrome-controls") + .getByRole("button", { name: "Collapse sidebar" }); + const expandButton = page.locator(".shell-chrome-controls__nav-toggle"); + const drawerToggle = page + .locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible") + .first(); + const sessionMenu = page.getByRole("menu", { name: "Actions for Research notes" }); + await row.waitFor({ state: "visible", timeout: 10_000 }); + + const openSessionMenu = async () => { + await row.hover(); + await row.getByRole("button", { name: "Open thread menu" }).click(); + await page + .getByRole("menu", { name: "Actions for Research notes" }) + .waitFor({ state: "visible" }); + }; + const expectDesktopCollapsed = async () => { + await expect.poll(() => sidebar.isVisible()).toBe(false); + await expect.poll(() => expandButton.isVisible()).toBe(true); + await expect + .poll(() => expandButton.evaluate((element) => element === document.activeElement)) + .toBe(true); + }; + const expectDrawerClosed = async () => { + await expect + .poll(() => shell.getAttribute("class")) + .not.toContain("shell--nav-drawer-open"); + await expect + .poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().right)) + .toBeLessThanOrEqual(0); + }; + const hiddenActionCounts = async () => ({ + dialogs: dialogs.length, + patches: (await gateway.getRequests("sessions.patch")).length, + }); + const expectHiddenShortcutsInert = async ( + before: Awaited>, + ) => { + for (const shortcut of ["p", "a", "d"] as const) { + await page.keyboard.press(shortcut); + } + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + expect(await hiddenActionCounts()).toEqual(before); + }; + + // Keyboard collapse bypasses the menu's outside-pointer handler. The shell + // must explicitly unmount it before the sidebar becomes display:none. + await openSessionMenu(); + const beforeKeyboardCollapse = await hiddenActionCounts(); + await page.keyboard.press("Meta+B"); + await expectDesktopCollapsed(); + await expect.poll(() => sessionMenu.count()).toBe(0); + await expectHiddenShortcutsInert(beforeKeyboardCollapse); + + await expandButton.click(); + await expect.poll(() => sidebar.isVisible()).toBe(true); + + // The visible desktop control follows the same focus handoff contract. + await openSessionMenu(); + await collapseButton.click(); + await expectDesktopCollapsed(); + await expect.poll(() => sessionMenu.count()).toBe(0); + await expandButton.click(); + await expect.poll(() => sidebar.isVisible()).toBe(true); + + // Crossing into drawer layout hides the desktop sidebar without toggling + // persisted collapse state, so resize owns this dismissal and focus move. + await openSessionMenu(); + const beforeNarrowTransition = await hiddenActionCounts(); + await page.setViewportSize({ height: 900, width: 900 }); + await expectDrawerClosed(); + await expect.poll(() => sessionMenu.count()).toBe(0); + await expect + .poll(() => drawerToggle.evaluate((element) => element === document.activeElement)) + .toBe(true); + await expectHiddenShortcutsInert(beforeNarrowTransition); + + await drawerToggle.click(); + await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open"); + await expect + .poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().left)) + .toBe(0); + await openSessionMenu(); + const beforeDrawerCollapse = await hiddenActionCounts(); + await page.keyboard.press("Meta+B"); + await expectDrawerClosed(); + await expect.poll(() => sessionMenu.count()).toBe(0); + await expect + .poll(() => drawerToggle.evaluate((element) => element === document.activeElement)) + .toBe(true); + await expectHiddenShortcutsInert(beforeDrawerCollapse); + } finally { + await context.close(); + } + }); + + it("keeps sidebar sessions visible through a same-client Gateway reconnect", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:disconnect-proof"; + const otherSessionKeys = ["agent:main:other-a", "agent:main:other-b"] as const; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow(sessionKey, "Disconnect proof", Date.parse("2026-07-01T16:00:00.000Z")), + sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")), + sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")), + ]), + }, + sessionKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const sidebarRow = page.locator(`.sidebar-recent-session[data-session-key="${sessionKey}"]`); + await sidebarRow.waitFor({ state: "visible", timeout: 10_000 }); + const sidebarRows = page.locator(".sidebar-recent-session"); + await expect.poll(() => sidebarRows.count()).toBe(3); + const initialListCount = (await gateway.getRequests("sessions.list")).length; + + await gateway.closeLatest(1006, "disconnect proof"); + await gateway.deferNext("sessions.list"); + await sidebarRow.waitFor({ state: "visible" }); + await captureUiProof(page, "sidebar-sessions-during-reconnect.png"); + + await expect.poll(() => gateway.getSocketCount(), { timeout: 15_000 }).toBeGreaterThan(1); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 15_000 }) + .toBeGreaterThan(initialListCount); + await sidebarRow.waitFor({ state: "visible" }); + expect(await sidebarRows.count()).toBe(3); + for (const otherKey of otherSessionKeys) { + await page + .locator(`.sidebar-recent-session[data-session-key="${otherKey}"]`) + .waitFor({ state: "visible" }); + } + + const firstReconnectListCount = (await gateway.getRequests("sessions.list")).length; + const refreshedResponse = sessionsListResponse([ + sessionRow(sessionKey, "Reconnect refreshed", Date.parse("2026-07-01T16:01:00.000Z")), + sessionRow(otherSessionKeys[0], "Other A", Date.parse("2026-07-01T15:59:00.000Z")), + sessionRow(otherSessionKeys[1], "Other B", Date.parse("2026-07-01T15:58:00.000Z")), + ]); + // Reconnect can queue a second refresh behind session hydration. Hold both + // so each response carries the changed black-box fixture. + await gateway.deferNext("sessions.list"); + await gateway.resolveDeferred("sessions.list", refreshedResponse); + await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed"); + await expect.poll(() => sidebarRows.count()).toBe(3); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 10_000 }) + .toBeGreaterThan(firstReconnectListCount); + await gateway.resolveDeferred("sessions.list", refreshedResponse); + await expect.poll(() => sidebarRow.textContent()).toContain("Reconnect refreshed"); + } finally { + await context.close(); + } + }); + + it("does not duplicate the active chat when its only session is pinned", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), { + pinned: true, + }), + ]), + "sessions.patch": {}, + }, + sessionKey: "agent:main:pinned", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]'); + const chatsGroup = page.locator('[data-session-section="ungrouped"]'); + await expect + .poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name"))) + .toEqual(["Pinned only"]); + await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0); + await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1); + // The empty Threads section only materializes after dragstart. Move the + // real pointer first so Playwright does not wait for a hidden target. + const pinnedRow = pinnedEntry.locator(".sidebar-recent-session"); + const sourceBox = await pinnedRow.boundingBox(); + if (!sourceBox) { + throw new Error("expected pinned session bounds"); + } + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); + await page.mouse.down(); + await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 12, sourceBox.y + 12, { + steps: 4, + }); + const sessionList = page.locator(".sidebar-sessions"); + await sessionList.waitFor({ state: "visible" }); + const targetBox = await sessionList.boundingBox(); + if (!targetBox) { + throw new Error("expected session list bounds"); + } + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { + steps: 8, + }); + await page.mouse.up(); + const unpinPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:pinned" && params.pinned === false, + ); + expect(requireRecord(unpinPatch.params)).toMatchObject({ + key: "agent:main:pinned", + pinned: false, + }); + await expect.poll(() => pinnedEntry.count()).toBe(0); + await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(1); + } finally { + await context.close(); + } + }); + + it("pins a session dropped into the interleaved sidebar zone", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + recordVideo: captureUiProofEnabled + ? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } } + : undefined, + }); + const page = await context.newPage(); + const proofVideo = page.video(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow( + "agent:main:pinned", + "Already pinned", + Date.parse("2026-07-01T16:00:00.000Z"), + { + pinned: true, + }, + ), + sessionRow("agent:main:candidate", "Pin me", Date.parse("2026-07-01T15:59:00.000Z"), { + category: "Research", + }), + ]), + "sessions.patch": {}, + }, + featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"], + sessionKey: "agent:main:candidate", + sessionGroups: ["Research"], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + + const pinnedEntry = page.locator('[data-sidebar-entry="session:agent:main:pinned"]'); + const researchGroup = page.locator('[data-session-section="category:Research"]'); + await expect + .poll(() => trimmedTextContents(pinnedEntry.locator(".sidebar-recent-session__name"))) + .toEqual(["Already pinned"]); + await captureUiProof(page, "sidebar-session-before-pinned-drop.png"); + await researchGroup + .locator('.sidebar-recent-session[data-session-key="agent:main:candidate"]') + .dragTo(pinnedEntry); + + const pinPatch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:candidate" && params.pinned === true, + ); + expect(requireRecord(pinPatch.params)).toMatchObject({ + key: "agent:main:candidate", + pinned: true, + }); + expect(requireRecord(pinPatch.params)).not.toHaveProperty("category"); + await expect + .poll(() => + trimmedTextContents( + page.locator('[data-sidebar-entry^="session:"] .sidebar-recent-session__name'), + ), + ) + .toEqual(["Already pinned", "Pin me"]); + await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0); + await captureUiProof(page, "sidebar-session-dropped-into-pinned.png"); + } finally { + await context.close(); + if (proofVideo) { + await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-pinned-drop.webm")); + } + } + }); + + it("keeps raw ids out of work rows while their metadata grows in place", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const nodeHash = "11c38726acc6fac280357576c87acc6fac280357"; + const rows = (withWork: boolean) => { + const ts = baseTime + (withWork ? 5_000 : 0); + return [ + sessionRow("agent:main:main", "Main", ts), + sessionRow( + "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6d", + "", + ts - 60_000, + withWork ? { execNode: nodeHash } : {}, + ), + sessionRow( + "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e", + "", + ts - 120_000, + withWork + ? { + execNode: nodeHash, + worktree: { branch: "openclaw/wt-1", repoRoot: "/Users/dev/Projects/clawdbot" }, + } + : {}, + ), + sessionRow("agent:main:node-mcp-debug-4de003fbff138fcb9239c9378b2e", "", ts - 180_000), + ]; + }; + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse(rows(false)), + }, + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await expect + .poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 }) + .toBeGreaterThan(0); + // Add work metadata only after first layout so the WebKit overlap + // regression still exercises in-place row growth. + const listRequests = (await gateway.getRequests("sessions.list")).length; + await gateway.setMethodResponse("sessions.list", sessionsListResponse(rows(true))); + await gateway.emitGatewayEvent("sessions.changed", { + reason: "update", + sessionKey: "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e", + }); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length) + .toBeGreaterThan(listRequests); + const codingToggle = page.locator( + '[data-session-section="work"] .sidebar-session-group-toggle', + ); + await codingToggle.waitFor({ state: "visible" }); + await expect.poll(() => codingToggle.getAttribute("aria-expanded")).toBe("false"); + await codingToggle.click(); + const namesLocator = page.locator(".sidebar-recent-session__name"); + await expect + .poll(() => trimmedTextContents(namesLocator)) + .toContain("clawdbot ⎇ wt-1 · …0357"); + + // Names and subtitles never show raw node ids or raw agent keys. + const names = await trimmedTextContents(page.locator(".sidebar-recent-session__name")); + expect(names).toContain("New thread"); + expect(names).toContain("clawdbot ⎇ wt-1 · …0357"); + expect(names).toContain("node-mcp-debug-…8b2e"); + const subtitles = await trimmedTextContents( + page.locator(".sidebar-recent-session__subtitle"), + ); + expect(subtitles).toContain("…0357"); + for (const text of [...names, ...subtitles]) { + expect(text).not.toContain(nodeHash); + expect(text).not.toContain("agent:main:"); + } + + // Sections must lay out below the rows above them, not paint over them. + const overlaps = await page.evaluate(() => { + const rects = [ + ...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"), + ] + .map((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + }) + .filter((rect) => rect.bottom > rect.top) + .toSorted((a, b) => a.top - b.top); + let bad = 0; + let previousBottom: number | undefined; + for (const rect of rects) { + if (previousBottom !== undefined && rect.top < previousBottom - 2) { + bad += 1; + } + previousBottom = rect.bottom; + } + return bad; + }); + expect(overlaps).toBe(0); + } finally { + await context.close(); + } + }); + + it("scrolls long session lists in short windows instead of squeezing sections", async () => { + const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); + const rows = [ + ...Array.from({ length: 8 }, (_, index) => + sessionRow(`agent:main:work-${index}`, `Work session ${index}`, baseTime - index * 60_000, { + worktree: { branch: `openclaw/wt-${index}`, repoRoot: "/Users/dev/Projects/clawdbot" }, + }), + ), + ...Array.from({ length: 30 }, (_, index) => + sessionRow(`agent:main:chat-${index}`, `Chat ${index}`, baseTime - (index + 10) * 60_000), + ), + ]; + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 620, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse(rows), + }, + sessionKey: "agent:main:main", + }); + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click(); + const loadMore = page + .locator('[data-session-section="ungrouped"]') + .getByRole("button", { name: "Show more" }); + for (let pageIndex = 0; pageIndex < 3 && (await loadMore.isVisible()); pageIndex += 1) { + await loadMore.click(); + } + await page.locator(".sidebar-shell__body").evaluate((element) => { + element.scrollTop = 0; + }); + await expect + .poll(() => page.locator(".sidebar-recent-session").count(), { timeout: 15_000 }) + .toBe(rows.length); + await captureUiProof(page, "short-window-session-sections.png"); + + // Sections must stack below each other, not paint over the rows above. + const overlaps = await page.evaluate(() => { + const rects = [ + ...document.querySelectorAll(".sidebar-recent-session, .sidebar-recent-sessions__head"), + ] + .map((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + }) + .filter((rect) => rect.bottom > rect.top) + .toSorted((a, b) => a.top - b.top); + let bad = 0; + let previousBottom: number | undefined; + for (const rect of rects) { + if (previousBottom !== undefined && rect.top < previousBottom - 2) { + bad += 1; + } + previousBottom = rect.bottom; + } + return bad; + }); + expect(overlaps).toBe(0); + + // The squeeze regression compressed sections into the viewport with no + // overflow; a healthy sidebar body is taller than its viewport and scrolls. + const scroll = await page.evaluate(() => { + const list = document.querySelector(".sidebar-shell__body"); + if (!list) { + return null; + } + list.scrollTop = list.scrollHeight; + return { + clientHeight: list.clientHeight, + scrollHeight: list.scrollHeight, + scrollTop: list.scrollTop, + }; + }); + expect(scroll).not.toBeNull(); + expect(scroll?.scrollHeight ?? 0).toBeGreaterThan(scroll?.clientHeight ?? 0); + expect(scroll?.scrollTop ?? 0).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); + + it("keeps sidebar session controls reachable on touch pointers", async () => { + const context = await suite.browser.newContext({ + hasTouch: true, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), + sessionRow( + "agent:main:research", + "Research notes", + Date.parse("2026-07-01T15:00:00.000Z"), + ), + ]), + }, + sessionKey: "agent:main:main", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const row = page + .locator(".sidebar-recent-sessions__list .sidebar-recent-session") + .filter({ hasText: "Research notes" }); + await row.waitFor({ state: "visible", timeout: 10_000 }); + const pin = row.getByRole("button", { name: "Pin thread" }); + const menu = row.getByRole("button", { name: "Open thread menu" }); + await expect.poll(() => actionOpacity(pin)).toBe("1"); + await expect.poll(() => actionPointerEvents(pin)).toBe("auto"); + await expect.poll(() => actionOpacity(menu)).toBe("1"); + await expect.poll(() => actionPointerEvents(menu)).toBe("auto"); + await menu.click(); + await page.getByRole("menuitem", { name: "Archive thread" }).waitFor({ state: "visible" }); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/session-management.test-support.ts b/ui/src/e2e/session-management.test-support.ts new file mode 100644 index 000000000000..2434c6a5184b --- /dev/null +++ b/ui/src/e2e/session-management.test-support.ts @@ -0,0 +1,144 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { + controlUiSessionPath, + controlUiSessionUrl, + installMockGateway, + type MockGatewayControls, + type MockGatewayRequest, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +export { controlUiSessionPath, controlUiSessionUrl, installMockGateway }; + +export const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections"; +export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +export const uiProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "thread-management", +); + +export function createSessionManagementE2eSuite() { + return createControlUiE2eSuite({ + name: "Control UI session management mocked Gateway E2E", + unavailableMessage: (executablePath) => + `Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + }); +} + +export function sessionRow( + key: string, + label: string, + updatedAt: number, + options: { + archived?: boolean; + category?: string; + pinned?: boolean; + pinnedAt?: number; + hasActiveRun?: boolean; + status?: string; + spawnedBy?: string; + startedAt?: number; + endedAt?: number; + childSessions?: string[]; + execNode?: string; + worktree?: { branch?: string; repoRoot?: string }; + } = {}, +) { + return { + contextTokens: null, + displayName: label, + hasActiveRun: false, + key, + kind: "direct", + label, + model: "gpt-5.5", + modelProvider: "openai", + status: "done", + totalTokens: 0, + updatedAt, + ...options, + }; +} + +export function sessionsListResponse( + sessions: unknown[], + options: { + hasMore?: boolean; + nextOffset?: number | null; + offset?: number; + totalCount?: number; + } = {}, +) { + return { + count: sessions.length, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + hasMore: options.hasMore ?? false, + limitApplied: 50, + nextOffset: options.nextOffset ?? null, + offset: options.offset ?? 0, + path: "", + sessions, + totalCount: options.totalCount ?? sessions.length, + ts: Date.now(), + }; +} + +export function requireRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +export async function waitForPatch( + gateway: MockGatewayControls, + predicate: (params: Record) => boolean, +): Promise { + const deadline = Date.now() + 10_000; + let requests: MockGatewayRequest[] = []; + while (Date.now() < deadline) { + requests = await gateway.getRequests("sessions.patch"); + const match = requests.find((request) => predicate(requireRecord(request.params))); + if (match) { + return match; + } + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + throw new Error(`No matching sessions.patch request found: ${JSON.stringify(requests)}`); +} + +export async function activateMenuItem(item: Locator): Promise { + await item.evaluate((element) => (element as HTMLElement).click()); +} + +export function trimmedTextContents(locator: Locator): Promise { + return locator.evaluateAll((elements) => + elements.map((element) => element.textContent?.trim() ?? ""), + ); +} + +export function actionOpacity(button: Locator): Promise { + return button.evaluate((element) => globalThis.getComputedStyle(element).opacity); +} + +export function actionPointerEvents(button: Locator): Promise { + return button.evaluate((element) => globalThis.getComputedStyle(element).pointerEvents); +} + +export async function captureUiProof(page: Page, fileName: string) { + if (!captureUiProofEnabled) { + return; + } + await mkdir(uiProofArtifactDir, { recursive: true }); + await page.screenshot({ fullPage: true, path: path.join(uiProofArtifactDir, fileName) }); +}