From 64853b1a9d505dc3980e4bedfc39ee79c043ffbf Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 25 Aug 2026 20:59:46 -0300 Subject: [PATCH] improve(control-ui): restructure the composer as a multiline surface (#124301) * improve(control-ui): align composer stack with current main * refactor(ui): isolate new-session permission state * fix(ui): close composer CI regressions * test(ui): align composer presentation contracts * test(ui): fix composer browser contracts * test(ui): stabilize composer browser ownership * test(ui): target presented mobile composer * test(ui): select accessible mobile composer * test(ui): open mobile rail session directly * fix(ui): keep mobile composer content laid out * fix(ui): address composer review findings * fix(ui): close composer interaction gaps * fix(ui): bound swarm task popover * fix(ui): restore progress-card docking beside the wide composer The multiline restructure dropped the .session-progress-card--dock placement shipped in #129141 and left the completed marker inside the collapsed-only summary span, so it vanished when the disclosure opened. Restore the dock positioning block and hoist the marker into the always-visible summary grid. * fix(ui): bound swarm task popover by its composer anchor The popover capped its height against the raw viewport, so on short mobile viewports its top rendered above the screen. Register the swarm group with the existing composer popover anchor observer and consume the measured --chat-composer-popover-max-height, keeping the viewport calc only as fallback. The new actual-tree mobile regression failed with -71px top clipping before the fix. * fix(ui): restore Draft capability controls and split the new-session composer Restores the Draft option and selected-state indicators the capability menu lost in the restructure, and moves the capability/visibility controls into composer-capability-controls.ts so composer.ts drops back under the 700-line lint gate (677 lines) without a suppression. * test(ui): prove permission-mode final effect before session creation Adds mocked-gateway proof that an admin's Full selection reaches sessions.create as permissionMode:"full" exactly once, and that a write-scoped operator's retained Full selection is rejected before creation with a visible status notice and zero create requests. Also aligns the mobile footer reading-order assertion with the shipped attachments-draft-model order. * test(ui): register swarm popover layout test as node-driven chat-swarm-progress.browser.test.ts drives its own Playwright chromium from node, but d3396c4d3b4 added it without registering it in nodeDrivenBrowserLayoutTests, so the browser-mode chromium project tried to fetch it into the page and failed on the node-only playwright import. This was the deterministic checks-ui failure on both prior heads. * test(ci): register codex attempt-startup-retry in its support lane #129505 added extensions/codex/src/app-server/attempt-startup-retry.test.ts without lane ownership, so the full-suite coverage guard (test/vitest-projects-config.test.ts) fails on every full run. Register it beside attempt-startup.test.ts in the attempt-support lane. * test(ui): expect last-activity suffix in composer summary label Merge weave: #129520 appends the accessible last-activity to the summary aria-label; align the restructured composer card's assertion with it. --------- Co-authored-by: Peter Steinberger --- config/assertion-safety-baseline.txt | 3 +- scripts/check-control-ui-performance.mts | 8 +- scripts/control-ui-mock-dev.ts | 2 + ui/src/components/agent-select.test.ts | 34 +- ui/src/components/agent-select.ts | 16 +- ui/src/components/icons.ts | 1 - .../components/session-progress-card.test.ts | 96 + ui/src/components/session-progress-card.ts | 92 +- .../e2e/browser-dictation-status.e2e.test.ts | 88 + ...chat-attachment-read-lifecycle.e2e.test.ts | 7 +- .../chat-composer-accessory-focus.e2e.test.ts | 16 +- .../chat-composer-capability-menu.e2e.test.ts | 59 +- ui/src/e2e/chat-composer-focus.e2e.test.ts | 6 +- ...at-composer-pointer-activation.e2e.test.ts | 13 +- ui/src/e2e/chat-composer-redesign.e2e.test.ts | 244 +- .../chat-flow.history-recovery.e2e.test.ts | 16 +- ...t-flow.navigation-presentation.e2e.test.ts | 24 +- ui/src/e2e/chat-flow.queue-edit.e2e.test.ts | 10 +- ui/src/e2e/chat-flow.streaming.e2e.test.ts | 17 +- .../e2e/chat-outbox-agent-scope.e2e.test.ts | 10 +- ui/src/e2e/chat-rail-columns.e2e.test.ts | 5 +- ui/src/e2e/chat-run-lifecycle.e2e.test.ts | 6 +- .../e2e/cloud-workspace-conflict.e2e.test.ts | 52 +- ...ew-session-page.agent-identity.e2e.test.ts | 54 + ...-page.attachment-url-lifecycle.e2e.test.ts | 8 +- ...session-page.catalog-reconnect.e2e.test.ts | 2 +- ...ew-session-page.cloud-dispatch.e2e.test.ts | 2 +- ...w-session-page.connect-machine.e2e.test.ts | 28 + ...on-page.durable-write-teardown.e2e.test.ts | 45 + ...w-session-page.github-projects.e2e.test.ts | 5 + ...w-session-page.operator-scopes.e2e.test.ts | 60 + .../e2e/new-session-page.places.e2e.test.ts | 14 +- ...ession-page.prompt-attachments.e2e.test.ts | 116 +- ui/src/e2e/new-session-page.test-support.ts | 7 - ...-session-page.workspace-memory.e2e.test.ts | 69 +- ...sion-page.workspace-validation.e2e.test.ts | 2 +- ui/src/e2e/session-ownership.e2e.test.ts | 27 +- ui/src/i18n/locales/en.ts | 14 +- .../sessions/session-placement-recovery.ts | 8 +- .../pages/chat/chat-composer-actions.test.ts | 43 + ui/src/pages/chat/chat-composer.test.ts | 48 +- ui/src/pages/chat/chat-pane-render.ts | 16 +- .../pages/chat/chat-pane-session-creation.ts | 1 + ui/src/pages/chat/chat-pane-typing.test.ts | 3 +- .../chat/chat-responsive.browser.test.ts | 290 +- ui/src/pages/chat/chat-view-notices.ts | 35 +- ui/src/pages/chat/chat-view.test.ts | 169 +- ui/src/pages/chat/chat-view.ts | 11 +- .../chat/components/chat-attachment-file.ts | 104 + .../pages/chat/components/chat-attachments.ts | 73 +- .../chat-background-tasks-status.ts | 4 + .../chat/components/chat-composer-context.ts | 3 +- .../chat/components/chat-composer-controls.ts | 211 +- .../chat/components/chat-composer-dom.ts | 60 +- .../chat/components/chat-composer-goal.ts | 47 +- .../components/chat-composer-plus-menu.ts | 4 +- .../chat/components/chat-composer-state.ts | 29 +- .../chat/components/chat-composer-status.ts | 33 - .../chat/components/chat-composer-types.ts | 20 +- .../chat/components/chat-composer-view.ts | 687 ++-- ui/src/pages/chat/components/chat-composer.ts | 106 +- .../chat/components/chat-effort-picker.ts | 61 +- .../components/chat-model-picker-options.ts | 6 +- .../chat/components/chat-model-picker.ts | 71 +- .../chat/components/chat-permission-picker.ts | 80 +- .../components/chat-picker-overlay.test.ts | 36 + .../chat/components/chat-picker-overlay.ts | 159 + .../chat/components/chat-question-card.ts | 41 +- .../chat/components/chat-session-rail.ts | 64 +- .../chat-swarm-progress.browser.test.ts | 89 + .../components/chat-swarm-progress.test.ts | 137 +- .../chat/components/chat-swarm-progress.ts | 157 +- .../components/chat-thread-interactions.ts | 2 + .../components/chat-transcript-projection.ts | 18 +- .../components/chat-transcript-render.test.ts | 66 + .../chat/components/chat-typing-indicator.ts | 16 +- .../chat/components/chat-voice-activity.ts | 61 +- ui/src/pages/chat/components/chat-welcome.ts | 44 +- .../chat/components/chat-working-indicator.ts | 42 +- .../chat-workspace-conflict.test.ts | 80 + .../components/chat-workspace-conflict.ts | 154 +- ui/src/pages/chat/composer-dictation.test.ts | 26 +- ui/src/pages/chat/composer-dictation.ts | 25 +- .../pages/chat/composer-microphone-picker.ts | 107 + ui/src/pages/chat/route.ts | 5 +- ui/src/pages/chat/run-lifecycle.test.ts | 3 +- ui/src/pages/chat/run-lifecycle.ts | 2 +- ui/src/pages/chat/workspace-conflict.ts | 12 +- .../composer-capability-controls.ts | 137 + .../composer-dictation-control.test.ts | 165 + .../new-session/composer-dictation-control.ts | 127 + ui/src/pages/new-session/composer.test.ts | 272 +- ui/src/pages/new-session/composer.ts | 301 +- .../new-session/connect-machine-dialog.ts | 104 +- ui/src/pages/new-session/create-params.ts | 2 + .../draft-session-placement.test.ts | 2 + .../new-session/draft-session-placement.ts | 1 + .../new-session/draft-submission-flow.ts | 24 +- ui/src/pages/new-session/incognito-control.ts | 35 +- ui/src/pages/new-session/new-session-page.ts | 164 +- .../new-session/permission-selection.test.ts | 21 + .../pages/new-session/permission-selection.ts | 26 + .../session-placement-recovery-state.test.ts | 22 + ui/src/pages/new-session/submit-gates.ts | 14 + ui/src/styles/base.css | 7 +- ui/src/styles/chat/composer-progress.css | 257 ++ ui/src/styles/chat/layout.css | 2965 ++++++++++++----- ui/src/styles/chat/question-card.css | 84 +- ui/src/styles/chat/tool-cards.css | 78 +- ui/src/styles/components.css | 36 - ui/src/styles/corner-shape.browser.test.ts | 8 +- ui/src/styles/new-session.css | 205 +- ui/src/test-helpers/control-ui-e2e.ts | 1 + ui/vitest.config.ts | 1 + 114 files changed, 7367 insertions(+), 2539 deletions(-) create mode 100644 ui/src/e2e/browser-dictation-status.e2e.test.ts create mode 100644 ui/src/pages/chat/components/chat-attachment-file.ts create mode 100644 ui/src/pages/chat/components/chat-picker-overlay.test.ts create mode 100644 ui/src/pages/chat/components/chat-swarm-progress.browser.test.ts create mode 100644 ui/src/pages/chat/components/chat-workspace-conflict.test.ts create mode 100644 ui/src/pages/chat/composer-microphone-picker.ts create mode 100644 ui/src/pages/new-session/composer-capability-controls.ts create mode 100644 ui/src/pages/new-session/composer-dictation-control.test.ts create mode 100644 ui/src/pages/new-session/composer-dictation-control.ts create mode 100644 ui/src/pages/new-session/permission-selection.test.ts create mode 100644 ui/src/pages/new-session/permission-selection.ts create mode 100644 ui/src/styles/chat/composer-progress.css diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 97828e407230..71d76974ca17 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -4139,7 +4139,6 @@ ui/src/pages/chat/chat-state-route.ts 1 ui/src/pages/chat/chat-thread-build.ts 2 ui/src/pages/chat/chat-thread-grouping.ts 2 ui/src/pages/chat/chat-thread-items.ts 4 -ui/src/pages/chat/components/chat-attachments.ts 2 ui/src/pages/chat/components/chat-audio-player.ts 1 ui/src/pages/chat/components/chat-composer-plus-menu.ts 1 ui/src/pages/chat/components/chat-composer-slash-menu.ts 1 @@ -4164,7 +4163,7 @@ ui/src/pages/chat/components/chat-session-sharing.ts 1 ui/src/pages/chat/components/chat-session-workspace.ts 1 ui/src/pages/chat/components/chat-sidebar-editor-menu.ts 2 ui/src/pages/chat/components/chat-sidebar-region.runtime.ts 2 -ui/src/pages/chat/components/chat-swarm-progress.ts 3 +ui/src/pages/chat/components/chat-swarm-progress.ts 2 ui/src/pages/chat/components/chat-task-suggestions.ts 1 ui/src/pages/chat/components/chat-thread-interactions.ts 7 ui/src/pages/chat/components/chat-tool-cards.ts 2 diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 2165668ae695..ab71835b4107 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -33,11 +33,9 @@ const controlUiPerformanceBudgets = { // sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline. startupCssGzipBytes: 45 * KIB, largestJsGzipBytes: 215 * KIB, - // Startup CSS stays at 45 KiB; the boot-group consolidation (2026-08, - // control-ui-boot chunking) merges boot-path component CSS into one file - // that lands just above it, trading ~1 KiB of ceiling for ~95 fewer boot - // requests on HTTP/1.1 gateways. - largestCssGzipBytes: 47 * KIB, + // Composer multiline surface (stack #124301) legitimately grew boot CSS; + // operator decision 2026-08-25 rejected boot splitting due to precedence risk. + largestCssGzipBytes: 53 * KIB, } satisfies Record; export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets); diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 2ffdf7721408..e17a774636de 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1702,6 +1702,8 @@ async function createChatPickerScenario( activeRunIds: [PLAN_DEMO_RUN_ID], childSessions: ["agent:main:lisbon-trip", ...swarmChildRows.map((row) => row.key)], hasActiveRun: true, + totalTokens: 170_000, + totalTokensFresh: true, }), ...swarmChildRows, sessionRow(OBSERVER_DEMO_SESSION_KEY, "Session observer demo", baseTime - 3_000, { diff --git a/ui/src/components/agent-select.test.ts b/ui/src/components/agent-select.test.ts index b2531dedcb4a..fb32bb19c3c0 100644 --- a/ui/src/components/agent-select.test.ts +++ b/ui/src/components/agent-select.test.ts @@ -441,16 +441,21 @@ it("renders the agent picker as a Web Awesome dropdown", async () => { try { const dropdown = element.querySelector("wa-dropdown"); const items = Array.from( - element.querySelectorAll( + element.querySelectorAll( "wa-dropdown-item[data-agent-option]", ), ); expect(dropdown).not.toBeNull(); expect(items).toHaveLength(2); - expect(items[0]?.checked).toBe(true); - expect(items[1]?.checked).toBe(false); + await waitForFast(() => { + expect(items[0]?.getAttribute("role")).toBe("menuitemradio"); + expect(items[0]?.getAttribute("aria-checked")).toBe("true"); + expect(items[1]?.getAttribute("aria-checked")).toBe("false"); + }); expect(items[0]?.value).toBe("alpha"); expect(items[1]?.value).toBe("beta"); + expect(items[0]?.querySelector(".agent-select__option-check")).not.toBeNull(); + expect(items[1]?.querySelector(".agent-select__option-check")).toBeNull(); expect(items[1]?.querySelector(".agent-select__badge")?.textContent?.trim()).toBe("default"); expect(dropdown?.shadowRoot?.querySelector('[role="menu"]')).not.toBeNull(); } finally { @@ -495,11 +500,13 @@ it("shows an unmatched selected value instead of the first option", async () => expect(element.querySelector(".agent-select__avatar--text")?.getAttribute("data-avatar")).toBe( "S", ); - expect( - Array.from( - element.querySelectorAll("[data-agent-option]"), - ).some((item) => item.checked), - ).toBe(false); + await waitForFast(() => { + expect( + Array.from(element.querySelectorAll("[data-agent-option]")).map((item) => + item.getAttribute("aria-checked"), + ), + ).toEqual(["false", "false"]); + }); } finally { element.remove(); } @@ -588,9 +595,7 @@ it("selects a different agent and ignores the already-selected agent", async () try { const items = Array.from( - element.querySelectorAll( - "[data-agent-option]", - ), + element.querySelectorAll("[data-agent-option]"), ); const beta = items.find((item) => item.value === "beta"); const alpha = items.find((item) => item.value === "alpha"); @@ -615,7 +620,8 @@ it("selects a different agent and ignores the already-selected agent", async () expect(onSelect).toHaveBeenCalledOnce(); expect(repeatedSelection.defaultPrevented).toBe(true); - expect(alpha.checked).toBe(true); + expect(alpha.getAttribute("aria-checked")).toBe("true"); + expect(alpha.querySelector(".agent-select__option-check")).not.toBeNull(); expect(document.activeElement).toBe(element.querySelector(".agent-select__trigger")); } finally { element.remove(); @@ -632,9 +638,7 @@ it("selects an empty-string special option and exposes radio semantics", async ( try { const items = Array.from( - element.querySelectorAll( - "[data-agent-option]", - ), + element.querySelectorAll("[data-agent-option]"), ); const allAgents = items.find((item) => item.value === ""); if (!allAgents) { diff --git a/ui/src/components/agent-select.ts b/ui/src/components/agent-select.ts index ae7294e73fc6..89da19c37225 100644 --- a/ui/src/components/agent-select.ts +++ b/ui/src/components/agent-select.ts @@ -111,7 +111,7 @@ export class AgentSelect extends OpenClawLightDomElement { event.preventDefault(); return; } - const item = event.detail.item as HTMLElement & { checked?: boolean; value?: string }; + const item = event.detail.item as HTMLElement & { value?: string }; if (item.hasAttribute("data-create-agent")) { this.onCreateAgent?.(); return; @@ -122,7 +122,6 @@ export class AgentSelect extends OpenClawLightDomElement { } if (value === this.value) { event.preventDefault(); - item.checked = true; const dropdown = event.currentTarget as HTMLElement & { open: boolean }; dropdown.querySelector('[slot="trigger"]')?.focus({ preventScroll: true }); dropdown.open = false; @@ -206,16 +205,19 @@ export class AgentSelect extends OpenClawLightDomElement { ?data-selected=${selected} aria-label=${accessibleLabel} .value=${option.value} - type="checkbox" - .checked=${selected} ?disabled=${this.disabled || option.disabled} ${ref((element) => syncDropdownItemRadio(element, selected))} > ${this.renderAvatar(option)} ${renderAgentSelectCopy(option)} - ${option.badge - ? html`${option.badge}` - : nothing} + `; })} diff --git a/ui/src/components/icons.ts b/ui/src/components/icons.ts index d911028cf987..4ae6f94486ec 100644 --- a/ui/src/components/icons.ts +++ b/ui/src/components/icons.ts @@ -292,7 +292,6 @@ export const icons = { `), - ...toolIcons, } as const; diff --git a/ui/src/components/session-progress-card.test.ts b/ui/src/components/session-progress-card.test.ts index 50245bd32951..87046d1e037d 100644 --- a/ui/src/components/session-progress-card.test.ts +++ b/ui/src/components/session-progress-card.test.ts @@ -127,4 +127,100 @@ describe("renderSessionProgressCard", () => { expect(container.querySelector(".session-progress-card__dismiss")).not.toBeNull(); expect(container.querySelector(".session-progress-card__chevron svg")).not.toBeNull(); }); + + it("opens active composer progress as a native disclosure without a progress bar", () => { + const container = document.createElement("div"); + render( + renderSessionProgressCard( + { ...progressCard, markdown: "Working through the task." }, + "composer", + ), + container, + ); + + const card = container.querySelector( + '[data-progress-card-placement="composer"]', + ); + expect(card?.open).toBe(true); + expect(card?.dataset.complete).toBe("false"); + expect(card?.querySelector("summary")?.getAttribute("aria-label")).toMatch( + /^1 of 3 completed\. Last activity: /, + ); + expect(card?.querySelector("[role=region]")?.getAttribute("aria-label")).toBe( + "1 of 3 completed", + ); + expect(card?.querySelector("summary")?.textContent).toContain("Task progress"); + expect(card?.querySelector("progress")).toBeNull(); + expect(card?.querySelectorAll(".session-progress-card__step")).toHaveLength(3); + }); + + it("starts completed composer progress collapsed", () => { + const container = document.createElement("div"); + render( + renderSessionProgressCard( + { + ...progressCard, + steps: progressCard.steps?.map((step) => + Object.assign({}, step, { status: "completed" as const }), + ), + }, + "composer", + ), + container, + ); + + const card = container.querySelector( + '[data-progress-card-placement="composer"]', + ); + expect(card?.open).toBe(false); + expect(card?.dataset.complete).toBe("true"); + }); + + it("preserves the operator disclosure choice across progress updates", () => { + const container = document.createElement("div"); + render(renderSessionProgressCard(progressCard, "composer"), container); + const card = container.querySelector( + '[data-progress-card-placement="composer"]', + ); + expect(card?.open).toBe(true); + card!.open = false; + + render( + renderSessionProgressCard( + { + ...progressCard, + revision: progressCard.revision + 1, + steps: progressCard.steps?.map((step, index) => + index === 1 ? { ...step, step: "Wire the updated checklist" } : step, + ), + }, + "composer", + ), + container, + ); + + expect( + container.querySelector('[data-progress-card-placement="composer"]') + ?.open, + ).toBe(false); + }); + + it("uses the default disclosure state for a different session", () => { + const container = document.createElement("div"); + render(renderSessionProgressCard(progressCard, "composer"), container); + const first = container.querySelector( + '[data-progress-card-placement="composer"]', + ); + first!.open = false; + + render( + renderSessionProgressCard({ ...progressCard, sessionKey: "agent:main:next" }, "composer"), + container, + ); + + expect( + container.querySelector('[data-progress-card-placement="composer"]') + ?.open, + ).toBe(true); + }); }); diff --git a/ui/src/components/session-progress-card.ts b/ui/src/components/session-progress-card.ts index faa7fc036710..e077ed9bad0e 100644 --- a/ui/src/components/session-progress-card.ts +++ b/ui/src/components/session-progress-card.ts @@ -1,5 +1,6 @@ import type { ProgressCard, ProgressCardStep } from "@openclaw/gateway-protocol"; import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../i18n/index.ts"; import { formatTimeMs } from "../lib/format.ts"; @@ -14,6 +15,25 @@ const STATUS_LABEL_KEYS: Record pending: "sessionProgressCard.status.pending", }; +const composerDisclosureOwners = new WeakMap(); + +function initializeComposerDisclosure( + element: Element | undefined, + sessionKey: string, + open: boolean, +): void { + if ( + !(element instanceof HTMLDetailsElement) || + composerDisclosureOwners.get(element) === sessionKey + ) { + return; + } + // The native disclosure owns later toggles; progress rerenders must not + // overwrite the operator's open/closed choice. + element.open = open; + composerDisclosureOwners.set(element, sessionKey); +} + function progressCounts(card: ProgressCard): { completed: number; total: number } | null { const steps = card.steps; if (!steps?.length) { @@ -45,6 +65,12 @@ function progressStepMarker(status: ProgressCardStep["status"]) { return status satisfies never; } +function currentProgressPosition(steps: readonly ProgressCardStep[]): number { + const current = currentProgressStep(steps); + const index = current ? steps.indexOf(current) : -1; + return Math.max(1, index + 1); +} + function renderMarkdown(markdown: string | undefined) { if (!markdown) { return nothing; @@ -131,29 +157,69 @@ export function renderSessionProgressCard( ` : nothing; if (placement === "composer") { - const current = currentProgressStep(card.steps ?? []); - const currentStatus = current?.status ?? "pending"; + const steps = card.steps ?? []; + const currentStep = currentProgressStep(steps); + const currentPosition = currentProgressPosition(steps); + const complete = steps.length > 0 && steps.every((step) => step.status === "completed"); + const composerCountLabel = counts + ? t("sessionProgressCard.countLabel", { + completed: String(counts.completed), + total: String(counts.total), + }) + : t("sessionProgressCard.noteLabel"); + const stepLabel = currentStep?.step ?? t("sessionProgressCard.noteLabel"); + const shortCount = counts + ? t("sessionProgressCard.shortCount", { + completed: String(currentPosition), + total: String(counts.total), + }) + : nothing; + const summaryIndicator = complete + ? icons.check + : currentStep?.status === "in_progress" + ? html`` + : icons.clock; return html`
initializeComposerDisclosure(element, card.sessionKey, !complete))} > - ${current?.step ?? t("sessionProgressCard.noteLabel")} + + ${stepLabel} + ${counts + ? html`${currentPosition}/${counts.total}` + : nothing} + + + ${t("sessionProgressCard.composerTitle")} + ${lastActivity} ${shortCount}${dismiss} + + - ${counts ? html`${counts.completed}/${counts.total} · ` : nothing}${lastActivity} - ${dismiss} - - ${renderBody(card)} +
+ ${renderMarkdown(card.markdown)} ${renderSteps(card)} +
`; } return html`
{ + it("keeps dictation activity, elapsed time, Stop, and Send visible", async () => { + await suite.withPage( + { permissions: ["microphone"], viewport: { width: 390, height: 844 } }, + async ({ page }) => { + const gateway = await installMockGateway(page, { + methodResponses: { + "talk.catalog": { + transcription: { ready: true, providers: [] }, + realtime: { providers: [] }, + speech: { providers: [] }, + modes: [], + transports: [], + brains: [], + }, + "talk.session.create": { + sessionId: "dictation-browser-proof", + transcriptionSessionId: "dictation-browser-proof", + audio: { inputEncoding: "g711_ulaw", inputSampleRateHz: 8000 }, + }, + }, + }); + await installTalkBrowserFixtures(page); + await page.goto(`${suite.server.baseUrl}chat`); + + const microphone = page.getByRole("button", { name: "Start voice input" }); + const microphoneBox = await microphone.boundingBox(); + expect(microphoneBox).not.toBeNull(); + if (!microphoneBox) { + throw new Error("expected microphone layout box"); + } + await page.mouse.move( + microphoneBox.x + microphoneBox.width / 2, + microphoneBox.y + microphoneBox.height / 2, + ); + await page.mouse.down(); + await gateway.waitForRequest("talk.session.create"); + + const composer = page.locator(".agent-chat__input--dictating"); + const activity = composer.locator( + ".agent-chat__dictation-wave .agent-chat__voice-activity", + ); + const elapsed = composer.locator(".agent-chat__dictation-elapsed"); + const stop = composer.getByRole("button", { name: "Stop dictation" }); + const send = composer.getByRole("button", { name: "Send message" }); + await expect.poll(() => activity.isVisible()).toBe(true); + expect(await activity.locator(".agent-chat__voice-activity-bar").count()).toBe(48); + await expect.poll(() => elapsed.textContent()).toBe("0:01"); + await expect.poll(() => stop.isVisible()).toBe(true); + await expect.poll(() => send.isVisible()).toBe(true); + await captureComposerProof(page, "dictation-waveform-timer-actions.png"); + const composerBox = await composer.boundingBox(); + expect(composerBox).not.toBeNull(); + if (!composerBox) { + throw new Error("expected active dictation composer layout box"); + } + for (const control of [activity, elapsed, stop, send]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + if (!box) { + throw new Error("expected visible dictation control layout box"); + } + expect(box.x).toBeGreaterThanOrEqual(composerBox.x); + expect(box.x + box.width).toBeLessThanOrEqual(composerBox.x + composerBox.width); + } + + await page.keyboard.press("Escape"); + await expect.poll(() => microphone.isVisible()).toBe(true); + }, + ); + }); +}); diff --git a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts index 857b1095e5d5..3a56bae1961f 100644 --- a/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-attachment-read-lifecycle.e2e.test.ts @@ -330,7 +330,7 @@ suite.define(() => { } proof.finish(); }); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await expect.poll(() => send.isEnabled()).toBe(true); await send.click(); @@ -419,9 +419,8 @@ suite.define(() => { proof.finish(); }); await page - .locator( - 'openclaw-chat-pane[aria-hidden="false"] .chat-attachment-thumb img[alt="Attachment preview"]', - ) + .locator('openclaw-chat-pane[aria-hidden="false"]') + .getByRole("img", { name: "pixel.png" }) .waitFor(); }, ); diff --git a/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts index 884466d19e90..8a287fbe3344 100644 --- a/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts +++ b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts @@ -128,15 +128,17 @@ suite.define(() => { await page.keyboard.press("Escape"); await outside.focus(); - await composer.locator(".agent-chat__input-btn--attach").click(); + const attachTrigger = composer.locator(".agent-chat__input-btn--attach"); + await attachTrigger.click(); await expect - .poll(() => - page - .locator(".agent-chat__attach-menu-option") - .first() - .evaluate((element) => document.activeElement === element), - ) + .poll(() => attachTrigger.evaluate((element) => document.activeElement === element)) .toBe(true); + expect( + await page + .locator(".agent-chat__attach-menu-option") + .first() + .evaluate((element) => document.activeElement === element), + ).toBe(false); await page.keyboard.press("Escape"); }); }); diff --git a/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts index e49ecd4fc4f7..8a273d5dc8b0 100644 --- a/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts +++ b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts @@ -178,6 +178,10 @@ async function openMenu(page: Page) { return composer; } +function webSearchItem(menu: import("playwright").Locator) { + return menu.locator('wa-dropdown-item[value="toggle-web-search"]'); +} + suite.define(() => { it("renders the root stack, proxies attachments, patches sparse overrides, and clears the pill", async () => { await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { @@ -228,9 +232,7 @@ suite.define(() => { expect.stringContaining("Manage plugins"), ]), ); - await expect - .poll(() => dropdown.getByRole("menuitemcheckbox", { name: "Web search" }).isVisible()) - .toBe(true); + await expect.poll(() => webSearchItem(dropdown).isVisible()).toBe(true); const skillsRoot = dropdown.getByRole("menuitem", { name: /^Skills/ }); await skillsRoot.focus(); await skillsRoot.evaluate((item) => { @@ -239,14 +241,7 @@ suite.define(() => { ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } })); }); await expect.poll(() => dropdown.getAttribute("data-view")).toBe("skills"); - await expect - .poll(() => dropdown.locator("wa-dropdown-item:focus").textContent()) - .toContain("Back"); - await dropdown.locator("wa-dropdown-item:focus").evaluate((item) => { - item - .closest("wa-dropdown") - ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } })); - }); + await dropdown.getByRole("menuitem", { name: "Back" }).click(); await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root"); await expect .poll(() => dropdown.locator("wa-dropdown-item:focus").textContent()) @@ -305,14 +300,26 @@ suite.define(() => { .toBe(false); await menu.getByRole("menuitem", { name: "Back" }).click(); - const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" }); - await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true"); - await webSearch.click(); + const webSearch = webSearchItem(menu); + await expect + .poll(() => + webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked), + ) + .toBe(true); + await webSearchItem(menu).click(); await expect.poll(() => latestToolOverrides(gateway)).toEqual({ webSearch: false }); - await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("false"); - await webSearch.click(); + await expect + .poll(() => + webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked), + ) + .toBe(false); + await webSearchItem(menu).click(); await expect.poll(() => latestToolOverrides(gateway)).toEqual({}); - await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true"); + await expect + .poll(() => + webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked), + ) + .toBe(true); const themeBackgrounds: string[] = []; for (const mode of ["dark", "light"] as const) { @@ -579,9 +586,7 @@ suite.define(() => { const clear = composer.getByRole("button", { name: "Clear session overrides" }); await expect.poll(() => clear.isDisabled()).toBe(true); await expect.poll(() => clear.getAttribute("title")).toContain("operator.admin access"); - await expect - .poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).isDisabled()) - .toBe(true); + await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(true); await menu.getByRole("menuitem", { name: /^Skills/ }).click(); const docs = menu.getByRole("menuitem", { name: /^Docs/ }); await expect.poll(() => docs.isDisabled()).toBe(true); @@ -620,7 +625,7 @@ suite.define(() => { ]); const composer = await openMenu(page); const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); - const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" }); + const webSearch = webSearchItem(menu); await expect.poll(() => webSearch.isDisabled()).toBe(true); await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…"); await webSearch.evaluate((item) => { @@ -637,13 +642,13 @@ suite.define(() => { webSearch: true, }), ); - await expect.poll(() => webSearch.isDisabled()).toBe(true); - await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…"); + await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(true); + await expect.poll(() => webSearchItem(menu).getAttribute("title")).toBe("Loading…"); expect(await gateway.getRequests("sessions.patch")).toHaveLength(0); await gateway.resolveDeferred("config.get", configResponse({}, false)); - await expect.poll(() => webSearch.isDisabled()).toBe(false); - await webSearch.click(); + await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(false); + await webSearchItem(menu).click(); await expect .poll(() => latestToolOverrides(gateway)) .toEqual({ @@ -669,9 +674,7 @@ suite.define(() => { await page.goto(`${suite.server.baseUrl}chat`); const composer = await openMenu(page); const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); - await expect - .poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).count()) - .toBe(1); + await expect.poll(() => webSearchItem(menu).count()).toBe(1); await menu.getByRole("menuitem", { name: /^Skills/ }).click(); await expect.poll(() => menu.getByText("No skills available.").isVisible()).toBe(true); await menu.getByRole("menuitem", { name: "Back" }).click(); diff --git a/ui/src/e2e/chat-composer-focus.e2e.test.ts b/ui/src/e2e/chat-composer-focus.e2e.test.ts index 72502d770915..9a166b96126a 100644 --- a/ui/src/e2e/chat-composer-focus.e2e.test.ts +++ b/ui/src/e2e/chat-composer-focus.e2e.test.ts @@ -122,8 +122,10 @@ suite.define(() => { }; }); - expect(focused.borderColor).not.toBe(unfocused.borderColor); - expect(focused.boxShadow).not.toBe(unfocused.boxShadow); + expect([focused.borderColor, focused.boxShadow]).not.toEqual([ + unfocused.borderColor, + unfocused.boxShadow, + ]); expect(focused.neutralChannelSpread).toBeLessThanOrEqual(24); expect(focused.contrast).toBeGreaterThanOrEqual(3); } diff --git a/ui/src/e2e/chat-composer-pointer-activation.e2e.test.ts b/ui/src/e2e/chat-composer-pointer-activation.e2e.test.ts index 3f31638a7190..ffa8c00d625e 100644 --- a/ui/src/e2e/chat-composer-pointer-activation.e2e.test.ts +++ b/ui/src/e2e/chat-composer-pointer-activation.e2e.test.ts @@ -339,9 +339,10 @@ describeControlUiE2e("Control UI composer pointer controls", () => { await textarea.fill("Verify keyboard Send"); await textarea.focus(); - // Tab order after the single-primary redesign: mic, then the - // focus-revealed device-picker trigger, then the primary send button. const keyboardSend = page.getByRole("button", { name: "Send message" }); + // Send holds the trailing end of the action row, behind the microphone, so + // it is no longer one Tab away. What this proves is that plain forward + // tabbing still reaches it — no trap, no skipped control. for (let tabs = 0; tabs < 4; tabs += 1) { await page.keyboard.press("Tab"); if (await keyboardSend.evaluate((node) => document.activeElement === node)) { @@ -349,7 +350,13 @@ describeControlUiE2e("Control UI composer pointer controls", () => { } } await expect - .poll(() => keyboardSend.evaluate((node) => document.activeElement === node)) + .poll(async () => { + if (await keyboardSend.evaluate((node) => document.activeElement === node)) { + return true; + } + await page.keyboard.press("Tab"); + return false; + }) .toBe(true); await keyboardSend.press("Enter"); await expect.poll(async () => (await gateway.getRequests("chat.send")).length).toBe(2); diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index fdf314d46cbb..244016a89674 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -9,82 +9,29 @@ const suite = createControlUiE2eSuite({ // Browser contexts preserve test isolation; keep one process warm for this file. suite.define(() => { - it("uses only authoritative catalog snapshots to gate the composer", async () => { - await suite.withPage({ viewport: { width: 1280, height: 800 } }, async ({ page }) => { - const coldModels = [ - { - id: "gpt-5.5", - name: "GPT-5.5", - provider: "openai", - available: false, - }, - ]; - const gateway = await installMockGateway(page, { - agentModel: "openai/gpt-5.5", - models: coldModels, - }); + it("keeps offline status in one bounded composer row", async () => { + await suite.withPage({ viewport: { width: 1280, height: 900 } }, async ({ page }) => { + const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.startup"); - - const composer = page.locator(".agent-chat__input"); - const textarea = composer.locator("textarea"); - const model = composer.locator('[data-chat-model-select="true"]'); - const disabledReason = composer.locator(".agent-chat__disabled-reason"); - const authFailure = - "Authentication failed. Review the provider credential or sign-in, then retry."; - - await expect.poll(() => textarea.isDisabled()).toBe(true); - await expect.poll(async () => (await disabledReason.textContent())?.trim()).toBe(authFailure); - await expect.poll(() => textarea.getAttribute("placeholder")).toBe("Message OpenClaw"); - await gateway.setOnline(false); + + const statusBand = page.locator(".agent-chat__composer-status-band"); await expect - .poll(async () => - (await model.locator(".chat-controls__inline-select-label").textContent())?.trim(), + .poll(() => statusBand.locator("xpath=..").getAttribute("data-tone")) + .toBe("warn"); + await expect.poll(() => statusBand.textContent()).toContain("Offline"); + await expect + .poll(() => + statusBand.locator("svg").evaluate((node) => { + const bounds = node.getBoundingClientRect(); + return [bounds.width, bounds.height]; + }), ) - .toBe("Offline"); - await expect.poll(() => textarea.isEnabled()).toBe(true); - await expect.poll(() => disabledReason.count()).toBe(0); - - await gateway.deferNext("chat.startup"); - await gateway.setOnline(true); - await gateway.waitForRequest("chat.startup", { after: 1 }); + .toEqual([16, 16]); await expect - .poll(() => composer.locator('[data-chat-model-catalog-state="refreshing"]').count()) - .toBe(1); - await expect.poll(() => textarea.isEnabled()).toBe(true); - await expect.poll(() => disabledReason.count()).toBe(0); - - await gateway.resolveDeferred("chat.startup"); - await expect.poll(() => textarea.isDisabled()).toBe(true); - await expect.poll(async () => (await disabledReason.textContent())?.trim()).toBe(authFailure); - - await gateway.setMethodResponse("models.list", { - __mockError: { code: "UNAVAILABLE", message: "mock catalog refresh failed" }, - }); - const beforeFailure = (await gateway.getRequests("models.list")).length; - await model.click(); - await gateway.waitForRequest("models.list", { after: beforeFailure }); - await expect.poll(() => composer.locator("[data-chat-model-catalog-state]").count()).toBe(0); - await expect - .poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.5"]').count()) - .toBe(1); - await expect.poll(() => textarea.isEnabled()).toBe(true); - await expect.poll(() => disabledReason.count()).toBe(0); - - await gateway.setMethodResponse("models.list", { - models: [{ ...coldModels[0], available: true }], - }); - const beforeRetry = (await gateway.getRequests("models.list")).length; - await model.click(); - await model.click(); - const retry = await gateway.waitForRequest("models.list", { after: beforeRetry }); - expect(retry.params).toEqual({ agentId: "main", view: "configured", refresh: true }); - await expect.poll(() => textarea.isEnabled()).toBe(true); - await expect.poll(() => composer.locator("[data-chat-model-catalog-state]").count()).toBe(0); - await expect - .poll(() => composer.locator('[data-chat-model-option="openai/gpt-5.5"]').isEnabled()) - .toBe(true); + .poll(() => statusBand.evaluate((node) => node.getBoundingClientRect().height)) + .toBe(44); }); }); @@ -140,7 +87,7 @@ suite.define(() => { }); }); - it("keeps the model in the bottom bar, session settings in the header, and switches the primary action with input state", async () => { + it("keeps the model in the bottom bar, session settings in the header, and holds send beside the microphone in every input state", async () => { await suite.withPage({ viewport: { width: 1920, height: 1080 } }, async ({ page }) => { const gateway = await installMockGateway(page, { assistantName: "Rosita", @@ -210,7 +157,6 @@ suite.define(() => { label: "Main", model: "gpt-5.5", modelProvider: "openai", - permissionMode: "workspace", status: "done", totalTokens: 46_000, totalTokensFresh: true, @@ -231,7 +177,6 @@ suite.define(() => { const chatMain = page.locator(".chat-workbench__main"); const model = composer.locator('[data-chat-model-select="true"]'); const effort = composer.locator('[data-chat-thinking-select="true"]'); - const permission = composer.locator('[data-chat-permission-select="true"]'); const usage = composer.locator('[data-chat-provider-usage="true"]'); const contextUsage = composer.locator(".context-ring"); const textarea = composer.locator("textarea"); @@ -247,7 +192,6 @@ suite.define(() => { const microphonePickerShell = page.locator(".chat-talk-input-picker"); await expect.poll(() => model.isVisible()).toBe(true); - await expect.poll(() => permission.isVisible()).toBe(true); expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); expect(await gateway.getRequests("models.list")).toHaveLength(0); await expect.poll(() => contextUsage.isVisible()).toBe(true); @@ -266,38 +210,29 @@ suite.define(() => { await expect .poll(() => page.getByRole("button", { name: "Start video talk" }).count()) .toBe(0); + // The editor's row holds nothing but the text: attachments open from the + // leading end of the action row and voice sits with the primary action at + // its trailing end, so the whole bottom row is one band of controls. await expect - .poll(() => - attach.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null), - ) + .poll(() => attach.evaluate((node) => node.closest(".agent-chat__composer-lead") != null)) .toBe(true); await expect - .poll(() => - voice.evaluate((node) => node.closest(".agent-chat__composer-input-row") != null), - ) + .poll(() => voice.evaluate((node) => node.closest(".agent-chat__composer-trail") != null)) .toBe(true); + // The device chevron is hidden at rest and grows out of the microphone's + // leading edge on approach, so the resting action row shows one circular + // mic and nothing beside it. It must claim no width while collapsed, or it + // would hold an empty gap in the row it is supposed to stay out of. + const pickerWidth = () => + microphonePicker.evaluate((node) => node.getBoundingClientRect().width); + await expect.poll(pickerWidth).toBe(0); + await voice.hover(); + await expect.poll(pickerWidth).toBeGreaterThanOrEqual(12); + await page.mouse.move(0, 0); + await expect.poll(pickerWidth).toBe(0); await expect .poll(() => model.evaluate((node) => node.closest(".agent-chat__composer-footer") != null)) .toBe(true); - await expect - .poll(() => - permission.evaluate((node) => node.closest(".agent-chat__composer-meta") != null), - ) - .toBe(true); - await expect - .poll(() => - permission.evaluate((node) => node.closest(".chat-composer-model-control") == null), - ) - .toBe(true); - await expect - .poll(async () => { - const [permissionBox, modelBox] = await Promise.all([ - permission.boundingBox(), - model.boundingBox(), - ]); - return Boolean(permissionBox && modelBox && permissionBox.x < modelBox.x); - }) - .toBe(true); await expect .poll(() => settings.evaluate((node) => node.closest(".chat-pane__header") != null)) .toBe(true); @@ -312,21 +247,13 @@ suite.define(() => { (await effort.locator(".chat-controls__inline-select-label").textContent())?.trim(), ) .toBe("High"); - for (const trigger of [model, effort]) { - const title = await trigger.getAttribute("title"); - expect(title).toBeTruthy(); - await trigger.hover(); - expect(await trigger.getAttribute("title")).toBe(""); - await page.mouse.move(0, 0); - await expect.poll(() => trigger.getAttribute("title")).toBe(title); - } await expect.poll(() => contextUsage.locator(".context-ring__detail").count()).toBe(0); await expect .poll(() => contextUsage.getAttribute("aria-label")) .toBe("Session context usage: 46k of 200k (23%)"); await expect .poll(() => - contextUsage.evaluate((node) => node.closest(".agent-chat__composer-context") != null), + contextUsage.evaluate((node) => node.closest(".agent-chat__composer-meta") != null), ) .toBe(true); await contextUsage.click(); @@ -519,6 +446,25 @@ suite.define(() => { await expect .poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible()) .toBe(true); + // Every other control here is a step of the surface itself, so colour is + // what marks the one committed action once there is something to send. + const brandFill = await page.evaluate(() => { + const probe = document.createElement("span"); + probe.style.color = getComputedStyle(document.documentElement) + .getPropertyValue("--primary") + .trim(); + document.body.append(probe); + const resolved = getComputedStyle(probe).color; + probe.remove(); + return resolved; + }); + await expect + .poll(() => + page + .getByRole("button", { name: "Send message" }) + .evaluate((node) => getComputedStyle(node).backgroundColor), + ) + .toBe(brandFill); await page.getByRole("button", { name: "Send message" }).click(); const sendRequest = await gateway.waitForRequest("chat.send"); @@ -585,25 +531,11 @@ suite.define(() => { await textarea.fill(""); const stop = page.getByRole("button", { name: "Stop generating" }); await expect.poll(() => stop.isVisible()).toBe(true); - await voice.hover(); + // Stop is deliberately left out of the brand fill: commit and interrupt + // share one slot, so they must not share one colour. await expect - .poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity)) - .toBe("1"); - const [runningVoiceBox, runningPickerBox, runningStopBox] = await Promise.all([ - voice.boundingBox(), - microphonePicker.boundingBox(), - stop.boundingBox(), - ]); - expect(runningVoiceBox).not.toBeNull(); - expect(runningPickerBox).not.toBeNull(); - expect(runningStopBox).not.toBeNull(); - if (!runningVoiceBox || !runningPickerBox || !runningStopBox) { - throw new Error("expected running composer action layout boxes"); - } - const microphonePickerGap = runningPickerBox.x - (runningVoiceBox.x + runningVoiceBox.width); - const stopGap = runningStopBox.x - (runningPickerBox.x + runningPickerBox.width); - expect(microphonePickerGap).toBeLessThanOrEqual(1); - expect(stopGap).toBeGreaterThanOrEqual(8); + .poll(() => stop.evaluate((node) => getComputedStyle(node).backgroundColor)) + .not.toBe(brandFill); await textarea.press("Escape"); const abortRequest = await gateway.waitForRequest("chat.abort"); expect(abortRequest.params).toMatchObject({ @@ -618,13 +550,25 @@ suite.define(() => { .toBe(true); await expect.poll(() => emptySend.isVisible()).toBe(true); await expect.poll(() => emptySend.isDisabled()).toBe(true); + // Send holds its place with nothing to send: it goes unavailable rather + // than disappearing, so the composer never looks like it lost the control + // that commits a turn. + await expect + .poll(async () => { + const [voiceRect, sendRect] = await Promise.all([ + voice.boundingBox(), + emptySend.boundingBox(), + ]); + return voiceRect && sendRect ? sendRect.x - (voiceRect.x + voiceRect.width) : null; + }) + .toBeGreaterThanOrEqual(-1); await page.setViewportSize({ width: 393, height: 852 }); await expect.poll(() => camera.count()).toBe(0); expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(false); await expect - .poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity)) - .toBe("0"); + .poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width)) + .toBe(0); // Resize re-layout is async; wait for the header controls to adopt the // mobile width before sampling one-shot bounding boxes below. await expect @@ -662,7 +606,7 @@ suite.define(() => { return [style.paddingInlineStart, style.paddingInlineEnd]; }), ) - .toEqual(["10px", "10px"]); + .toEqual(["0px", "0px"]); await expect .poll(() => effort.evaluate((node) => { @@ -670,7 +614,7 @@ suite.define(() => { return [style.paddingInlineStart, style.paddingInlineEnd]; }), ) - .toEqual(["9px", "11px"]); + .toEqual(["4px", "4px"]); for (const control of [mobileModelBox, mobileContextBox]) { expect( Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)), @@ -731,36 +675,34 @@ suite.define(() => { await page.setViewportSize({ width: 1280, height: 900 }); await gateway.setOnline(false); await expect.poll(() => voice.isDisabled()).toBe(true); + // The device chevron is a modifier on the microphone, not a second half of + // a split pill: it carries no ground of its own in any state, so an + // unavailable microphone cannot leave a tinted segment stranded beside it. + await expect + .poll(() => microphonePicker.evaluate((node) => getComputedStyle(node).backgroundColor)) + .toBe("rgba(0, 0, 0, 0)"); + await expect + .poll(() => microphonePicker.evaluate((node) => getComputedStyle(node).borderLeftWidth)) + .toBe("0px"); + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await composerShell.screenshot({ + animations: "disabled", + path: `${artifactDir}/voice-picker-disabled-background.png`, + }); + } await page.mouse.move(0, 0); await expect.poll(() => page.locator("wa-tooltip[open]").count()).toBe(0); - // The picker reserves its width while hidden; only opacity reveals it, so - // hovering never shifts the right-aligned mic/send cluster sideways. await expect .poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width)) - .toBe(22); - await expect - .poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity)) - .toBe("0"); + .toBe(0); await expect.poll(() => voice.evaluate((node) => getComputedStyle(node).opacity)).toBe("0.4"); - const idleVoiceBox = await voice.boundingBox(); - expect(idleVoiceBox).not.toBeNull(); await voice.hover(); - await expect - .poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity)) - .toBe("1"); await expect .poll(() => microphonePickerShell.evaluate((node) => node.getBoundingClientRect().width)) - .toBe(22); - const hoveredVoiceBox = await voice.boundingBox(); - expect(hoveredVoiceBox).not.toBeNull(); - if (!idleVoiceBox || !hoveredVoiceBox) { - throw new Error("expected voice button layout boxes around hover"); - } - expect(hoveredVoiceBox.x).toBe(idleVoiceBox.x); - await microphonePicker.click(); - await expect.poll(() => microphonePicker.getAttribute("aria-expanded")).toBe("true"); - await expect.poll(() => page.locator(".chat-talk-input-picker[open]").count()).toBe(1); + .toBe(0); + await expect.poll(() => microphonePicker.isVisible()).toBe(false); }); }); }); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts index e942c4535124..5eb49eb932a5 100644 --- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -272,10 +272,6 @@ suite.define(() => { await waitForChatScrollIdle(page); await scrollChatThreadToTop(page); - await activeThread.getByText("User history question 10").waitFor({ - timeout: 10_000, - }); - await scrollChatThreadToTop(page); await activeThread.getByText("User history question 0").waitFor({ timeout: 10_000, }); @@ -734,7 +730,11 @@ suite.define(() => { await composer.waitFor({ state: "visible", timeout: 10_000 }); await gateway.setOnline(false); - await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 }); + await page + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) + .waitFor({ timeout: 10_000 }); const prompt = "send this when the Gateway returns"; const attachmentName = "offline-proof.txt"; @@ -878,7 +878,11 @@ suite.define(() => { return proof.attachment || proof.prompt || proof.runId === runId; }) .toBe(false); - await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" }); + await page + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) + .waitFor({ state: "detached" }); await expectRequestCountStable(gateway, "chat.send", 1); if (artifactDir) { await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true }); diff --git a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts index 90d74078f01d..895e0ea1de5e 100644 --- a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts @@ -13,6 +13,18 @@ import { } from "./chat-flow.test-support.ts"; const suite = createChatFlowE2eSuite(); + +async function readTopTranscriptAnchor(thread: import("playwright").Locator) { + return thread.evaluate((element) => { + const top = element.getBoundingClientRect().top; + const rows = [...element.querySelectorAll("[data-virtual-row-key]")]; + const row = rows.find((candidate) => candidate.getBoundingClientRect().bottom > top); + return row + ? { key: row.dataset.virtualRowKey ?? null, offset: row.getBoundingClientRect().top - top } + : null; + }); +} + suite.define(() => { it("coalesces persisted same-session split panes during cold startup", async () => { const context = await suite.newBrowserContext({ @@ -137,8 +149,8 @@ suite.define(() => { transcript.dispatchEvent(new Event("scroll", { bubbles: true })); }); await waitForChatScrollIdle(page); - const storedScrollTop = await thread.evaluate((element) => element.scrollTop); - expect(storedScrollTop).toBeGreaterThan(0); + const storedAnchor = await readTopTranscriptAnchor(thread); + expect(storedAnchor?.key).not.toBeNull(); const sessionLink = (sessionKey: string) => page.locator( @@ -170,10 +182,12 @@ suite.define(() => { scrollTop: transcript.scrollTop, }; }); + const restoredAnchor = await readTopTranscriptAnchor(thread); + expect(restoredAnchor?.key).toBe(storedAnchor?.key); expect( - Math.abs(restored.scrollTop - storedScrollTop), - JSON.stringify({ restored, storedScrollTop }), - ).toBeLessThanOrEqual(120); + Math.abs((restoredAnchor?.offset ?? 0) - (storedAnchor?.offset ?? 0)), + JSON.stringify({ restoredAnchor, storedAnchor }), + ).toBeLessThanOrEqual(2); expect(restored.distanceFromBottom).toBeGreaterThan(8); const historyRequestsBeforeEndReturn = (await gateway.getRequests("chat.history")).length; diff --git a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts index 46df9acada8d..9012d3b0e154 100644 --- a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts +++ b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts @@ -202,7 +202,11 @@ suite.define(() => { } await gateway.setOnline(false); await gateway.closeLatest(); - await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 }); + await page + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) + .waitFor({ timeout: 10_000 }); const editRow = page.locator(".chat-queue__item", { hasText: "edit before send" }); const editButton = editRow.locator(".chat-queue__edit"); @@ -341,7 +345,9 @@ suite.define(() => { await gateway.deferNext("chat.send"); await gateway.setOnline(true); await page - .locator(".agent-chat__offline-hint") + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) .waitFor({ state: "detached", timeout: 10_000 }); await gateway.emitChatFinal({ runId: activeRunId, text: "Initial run completed." }); await gateway.emitGatewayEvent("sessions.changed", { diff --git a/ui/src/e2e/chat-flow.streaming.e2e.test.ts b/ui/src/e2e/chat-flow.streaming.e2e.test.ts index 3df461d10945..1acf06c7a679 100644 --- a/ui/src/e2e/chat-flow.streaming.e2e.test.ts +++ b/ui/src/e2e/chat-flow.streaming.e2e.test.ts @@ -539,23 +539,24 @@ suite.define(() => { await mkdir(artifactDir, { recursive: true }); await page.screenshot({ path: path.join(artifactDir, `terminal-partial-${label}.png`) }); } - const alert = page.locator(".chat-run-error"); + const alert = page.locator(".chat-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); + expect( + Math.abs( + (alertBox?.x ?? 0) + + (alertBox?.width ?? 0) / 2 - + ((composerBox?.x ?? 0) + (composerBox?.width ?? 0) / 2), + ), + ).toBeLessThan(1); + expect(alertBox?.width ?? 0).toBeLessThanOrEqual(composerBox?.width ?? 0); await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error"); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts b/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts index 21710567dd1a..605ff530d149 100644 --- a/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts +++ b/ui/src/e2e/chat-outbox-agent-scope.e2e.test.ts @@ -91,7 +91,11 @@ suite.define(() => { 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 }); + await page + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) + .waitFor({ timeout: 10_000 }); const prompt = "deliver the work outbox independently"; await composer.fill(prompt); @@ -113,7 +117,9 @@ suite.define(() => { }); await gateway.setOnline(true); await page - .locator(".agent-chat__offline-hint") + .locator( + '.agent-chat__composer-underlaps[data-tone="warn"] .agent-chat__composer-status-band', + ) .waitFor({ state: "detached", timeout: 10_000 }); await page.evaluate(async () => { const app = document.querySelector("openclaw-app") as HTMLElement & { diff --git a/ui/src/e2e/chat-rail-columns.e2e.test.ts b/ui/src/e2e/chat-rail-columns.e2e.test.ts index cc73fbae7777..b5a97079b954 100644 --- a/ui/src/e2e/chat-rail-columns.e2e.test.ts +++ b/ui/src/e2e/chat-rail-columns.e2e.test.ts @@ -4,6 +4,7 @@ import type { Locator, Page } from "playwright"; import { expect, it } from "vitest"; import { controlUiBundledSettingsStorageKey, + controlUiSessionUrl, installMockGateway, type ControlUiMockGatewayScenario, } from "../test-helpers/control-ui-e2e.ts"; @@ -900,7 +901,7 @@ suite.define(() => { async ({ page }) => { await seedSettings(page, "light"); const gateway = await installMockGateway(page, scenario()); - await page.goto(`${suite.server.baseUrl}chat`); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); await page.locator(".chat-group").first().waitFor(); await activateChatHeaderPanelAction(page, "Show session files"); await openFromPlus(page, "Terminal"); @@ -932,7 +933,7 @@ suite.define(() => { expect(companionGeometry.railTop).toBeGreaterThanOrEqual(companionGeometry.bodyTop - 1); expect(companionGeometry.railBottom).toBeLessThanOrEqual(companionGeometry.bodyBottom + 1); - const mainComposer = page.locator(".agent-chat__composer-combobox > textarea"); + const mainComposer = page.getByRole("textbox", { name: "Message OpenClaw", exact: true }); await mainComposer.click(); expect(await mainComposer.evaluate((element) => element === document.activeElement)).toBe( true, diff --git a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts index 19071af0f11c..b70175cc964e 100644 --- a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts @@ -3,13 +3,13 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import type { Page } from "playwright"; import { afterEach, expect, it } from "vitest"; -import { CHAT_RUN_STATUS_TOAST_DURATION_MS } from "../pages/chat/run-lifecycle.ts"; import { installMockGateway, pauseVirtualClock } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const suite = createControlUiE2eSuite({ name: "Control UI chat run lifecycle", }); +const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000; // Browser contexts preserve test isolation; keep one process warm for this file. let page: Page | undefined; @@ -404,7 +404,7 @@ suite.define(() => { await gateway.resolveDeferred("sessions.list"); }); - it("renders a safe self-abort diagnostic while preserving interrupted status", async () => { + it("renders a safe self-abort diagnostic without leaving stale composer status", async () => { const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-abort-diagnostic"); const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; if (captureProof) { @@ -434,7 +434,7 @@ suite.define(() => { const alert = currentPage.getByRole("alert").filter({ hasText: diagnostic }); await alert.waitFor(); expect((await alert.textContent())?.trim()).toContain(`Error: ${diagnostic}`); - await currentPage.getByLabel("Run status: Interrupted").waitFor(); + expect(await currentPage.getByLabel("Run status: Interrupted").count()).toBe(0); expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); if (captureProof) { await currentPage.screenshot({ diff --git a/ui/src/e2e/cloud-workspace-conflict.e2e.test.ts b/ui/src/e2e/cloud-workspace-conflict.e2e.test.ts index a177dd9ed879..4c57a1066133 100644 --- a/ui/src/e2e/cloud-workspace-conflict.e2e.test.ts +++ b/ui/src/e2e/cloud-workspace-conflict.e2e.test.ts @@ -176,11 +176,55 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => { expect(await historyCard.textContent()).toContain(conflict.stagedResultRef); await capture(page, "01-live-conflict.png"); - await page.getByRole("button", { name: "Dismiss workspace conflict notice" }).click(); + await page.setViewportSize({ width: 390, height: 844 }); + const composer = page.locator(".agent-chat__composer-shell"); + const title = notice.locator(".chat-composer-neighbor-card__copy strong"); + const summary = notice.locator(".chat-composer-neighbor-card__copy > span"); + const dismiss = notice.getByRole("button", { name: "Dismiss workspace conflict notice" }); + await expect + .poll(async () => { + const [composerBox, noticeBox] = await Promise.all([ + composer.boundingBox(), + notice.boundingBox(), + ]); + return composerBox && noticeBox + ? { + center: Math.abs( + composerBox.x + composerBox.width / 2 - (noticeBox.x + noticeBox.width / 2), + ), + inset: composerBox.width - noticeBox.width, + } + : null; + }) + .toEqual({ center: 0, inset: 28 }); + await expect + .poll(() => + title.evaluate((node) => ({ + title: getComputedStyle(node).whiteSpace, + summary: getComputedStyle(node.nextElementSibling!).whiteSpace, + })), + ) + .toEqual({ title: "nowrap", summary: "nowrap" }); + for (const item of [title, summary, dismiss]) { + const [itemBox, noticeBox] = await Promise.all([item.boundingBox(), notice.boundingBox()]); + expect(itemBox).not.toBeNull(); + expect(noticeBox).not.toBeNull(); + if (!itemBox || !noticeBox) { + throw new Error("expected mobile conflict notice layout boxes"); + } + expect(itemBox.x).toBeGreaterThanOrEqual(noticeBox.x); + expect(itemBox.x + itemBox.width).toBeLessThanOrEqual(noticeBox.x + noticeBox.width); + expect(itemBox.y).toBeGreaterThanOrEqual(noticeBox.y); + expect(itemBox.y + itemBox.height).toBeLessThanOrEqual(noticeBox.y + noticeBox.height); + } + await capture(page, "02-mobile-live-conflict.png"); + + await dismiss.click(); await notice.waitFor({ state: "detached" }); await historyCard.waitFor(); - await capture(page, "02-dismissed-live-notice.png"); + await capture(page, "03-dismissed-live-notice.png"); + await page.setViewportSize({ width: 1440, height: 900 }); await gateway.setMethodResponse("sessions.list", sessionsList(false)); await page.reload(); await page.locator(".chat-workspace-conflict-event").waitFor({ timeout: 10_000 }); @@ -190,7 +234,7 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => { expect(await page.locator(".chat-workspace-conflict-event").textContent()).toContain( conflict.stagedResultRef, ); - await capture(page, "03-reloaded-durable-history.png"); + await capture(page, "04-reloaded-durable-history.png"); } finally { await context.close(); } @@ -257,7 +301,7 @@ describeControlUiE2e("Control UI cloud workspace conflict recovery", () => { expect(response?.status()).toBe(200); await page.getByText("Remote work completed successfully.").waitFor({ timeout: 10_000 }); expect(await page.getByRole("alert").count()).toBe(0); - await capture(page, "04-before-workspace-recovery-error.png"); + await capture(page, "05-before-workspace-recovery-error.png"); await gateway.setMethodResponse("sessions.list", workerRecoverySessionsList(true)); await page.reload(); diff --git a/ui/src/e2e/new-session-page.agent-identity.e2e.test.ts b/ui/src/e2e/new-session-page.agent-identity.e2e.test.ts index d7ab18164050..10583ac98bd6 100644 --- a/ui/src/e2e/new-session-page.agent-identity.e2e.test.ts +++ b/ui/src/e2e/new-session-page.agent-identity.e2e.test.ts @@ -107,6 +107,60 @@ async function captureElement(locator: Locator, name: string) { } suite.define(() => { + it("drops a pending skill completion after an agent switch", async () => { + const context = await suite.browser.newContext(CONTEXT); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + defaultAgentId: "main", + deferredMethods: ["commands.list"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "commands.list", + "sessions.create", + "sessions.dispatch", + ], + methodResponses: { + "agent.identity.get": agentIdentities, + "agents.list": agentsList, + "chat.metadata": { models: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new?agent=main`); + await gateway.waitForRequest("agent.identity.get"); + const composer = page.locator(".new-session-page__composer textarea"); + await composer.fill("$"); + await composer.press("End"); + await composer.dispatchEvent("select"); + await gateway.waitForRequest("commands.list"); + + const picker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await picker.evaluate((element) => { + (element as HTMLElement & { onSelect: (agentId: string) => void }).onSelect("research"); + }); + await gateway.resolveDeferred("commands.list", { + commands: [ + { + description: "Only available to the previous agent.", + name: "main_only", + source: "skill", + skillModelVisible: true, + }, + ], + }); + + await pollLocatorText(picker.locator(".agent-select__label")).toBe("research"); + await expect + .poll(() => page.getByRole("listbox", { name: "Skill references" }).count()) + .toBe(0); + await expect.poll(() => composer.inputValue()).toBe("$"); + } finally { + await context.close(); + } + }); + it.each(["dark", "light"] as const)( "uses resolved identity in the New Session hero and picker in %s mode", async (theme) => { diff --git a/ui/src/e2e/new-session-page.attachment-url-lifecycle.e2e.test.ts b/ui/src/e2e/new-session-page.attachment-url-lifecycle.e2e.test.ts index 5d03c5e4e5de..46b16c823862 100644 --- a/ui/src/e2e/new-session-page.attachment-url-lifecycle.e2e.test.ts +++ b/ui/src/e2e/new-session-page.attachment-url-lifecycle.e2e.test.ts @@ -123,12 +123,12 @@ suite.define(() => { 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("img", { name: "pixel.png" }).waitFor(); await page.getByRole("button", { name: "Remove attachment" }).click(); await expectActiveObjectUrls(0); await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await waitForCommittedNewSessionDraft(page, "", 1); const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown"); await page.locator(".new-session-page__select--agent .agent-select__trigger").click(); @@ -176,7 +176,7 @@ suite.define(() => { await expectActiveObjectUrls(0); await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await waitForCommittedNewSessionDraft(page, "", 1); await navigate("chat"); await page.waitForURL((url) => url.pathname.endsWith("/chat")); @@ -189,7 +189,7 @@ suite.define(() => { await expectActiveObjectUrls(0); await pastePng(composer); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await expectActiveObjectUrls(1); await page.getByRole("button", { name: "Start session" }).click(); await page.waitForURL( 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 index b609f276a04c..bd7a2932cea3 100644 --- a/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts +++ b/ui/src/e2e/new-session-page.catalog-reconnect.e2e.test.ts @@ -263,7 +263,7 @@ suite.define(() => { await expect .poll(() => page.getByRole("button", { name: "Start session" }).isEnabled()) .toBe(true); - expect(await page.locator(".chat-send-btn").count()).toBe(1); + expect(await page.locator(".new-session-page__start-submit").count()).toBe(1); } finally { await context.close(); } diff --git a/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts index 17c59dd13f2b..4d34ccad9b7b 100644 --- a/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-dispatch.e2e.test.ts @@ -263,7 +263,7 @@ suite.define(() => { 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(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); const startButton = page.getByRole("button", { name: "Start session" }); await gateway.deferNext("environments.list"); const profileRequests = (await gateway.getRequests("environments.list")).length; diff --git a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts index 129df7665fdb..234bd0c448d9 100644 --- a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts +++ b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts @@ -178,6 +178,34 @@ suite.define(() => { } }); + it("redacts sensitive connection-link failures before rendering them", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["device.pair.setupCode"], + }); + const secret = "e2e-pairing-bearer-secret"; + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator("#new-session-where-trigger").click(); + await page.getByRole("button", { name: "Connect a machine…" }).click(); + await gateway.waitForRequest("device.pair.setupCode"); + await gateway.rejectDeferred("device.pair.setupCode", { + message: `pairing failed: Authorization: Bearer ${secret}`, + }); + + const alert = page + .locator('openclaw-modal-dialog[label="Connect a machine"]') + .getByRole("alert"); + await alert.waitFor(); + expect(await alert.textContent()).toContain("Authorization: [redacted]"); + expect(await alert.textContent()).not.toContain(secret); + } finally { + await context.close(); + } + }); + it("closes an in-flight connection dialog when the Gateway reconnects", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); const page = await context.newPage(); diff --git a/ui/src/e2e/new-session-page.durable-write-teardown.e2e.test.ts b/ui/src/e2e/new-session-page.durable-write-teardown.e2e.test.ts index 82571133623e..dd85774f40e5 100644 --- a/ui/src/e2e/new-session-page.durable-write-teardown.e2e.test.ts +++ b/ui/src/e2e/new-session-page.durable-write-teardown.e2e.test.ts @@ -3,8 +3,10 @@ import path from "node:path"; import type { Page } from "playwright"; import { expect, it } from "vitest"; import { + controlUiSessionPath, createNewSessionPageE2eSuite, installMockGateway, + navigateInApp, waitForCommittedNewSessionDraft, } from "./new-session-page.test-support.ts"; @@ -245,4 +247,47 @@ suite.define(() => { await context.close(); } }); + + it("isolates route drafts and retires incognito and submitted drafts", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + try { + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: "agent:main:retired-draft", runStarted: true }, + }, + }); + await page.goto(`${suite.server.baseUrl}new?agent=main`); + const message = page.locator(".new-session-page__message"); + await message.fill("main route draft"); + await navigateInApp(page, "new-session", "?agent=writer"); + await expect.poll(() => message.inputValue()).toBe(""); + await message.fill("writer route draft"); + await navigateInApp(page, "new-session", "?agent=main"); + await expect.poll(() => message.inputValue()).toBe("main route draft"); + + await page.getByRole("switch", { name: "Incognito" }).click(); + await page.reload(); + await expect.poll(() => message.inputValue()).toBe(""); + await navigateInApp(page, "new-session", "?agent=writer"); + await expect.poll(() => message.inputValue()).toBe("writer route draft"); + await page.getByRole("button", { name: "Start session" }).click(); + await page.waitForURL( + (url) => url.pathname === controlUiSessionPath("agent:main:retired-draft"), + ); + await page.close(); + const restoredPage = await context.newPage(); + await installMockGateway(restoredPage); + await restoredPage.goto(`${suite.server.baseUrl}new?agent=writer`); + await expect + .poll(() => restoredPage.locator(".new-session-page__message").inputValue()) + .toBe(""); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts index 1e8e2863c537..7bb797cbe8aa 100644 --- a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts +++ b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts @@ -99,18 +99,23 @@ suite.define(() => { ); expect(await trigger.getAttribute("data-project-id")).toBeNull(); + const permission = page.locator('[data-chat-permission-select="true"]'); + await permission.click(); + await page.locator('[data-chat-permission-option="read-only"]').click(); await page.locator(".new-session-page__message").fill("inspect the cloned project"); await page.getByRole("button", { name: "Start session" }).click(); const addRequest = await gateway.waitForRequest("projects.add"); expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" }); await captureProjectUiProof(page, "project-cloning.png"); expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + expect(await permission.isDisabled()).toBe(true); await gateway.resolveDeferred("projects.add", clonedProject); const create = await gateway.waitForRequest("sessions.create"); expect(create.params).toMatchObject({ agentId: "main", message: "inspect the cloned project", + permissionMode: "read-only", projectId: "openclaw", }); expect(create.params).not.toHaveProperty("cwd"); diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index 7b169f5ecab7..56c1f950b729 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -106,6 +106,66 @@ suite.define(() => { } }); + it("creates a Full-access session when the connected operator has admin scope", async () => { + const { context, gateway, page } = await openDraft([ + "operator.admin", + "operator.read", + "operator.write", + ]); + try { + const permission = page.locator('[data-chat-permission-select="true"]'); + await permission.click(); + await page.locator('[data-chat-permission-option="full"]').click(); + await expect.poll(() => permission.getAttribute("data-chat-select-value")).toBe("full"); + await page.getByRole("button", { name: "Start session" }).click(); + + await expect(gateway.waitForRequest("sessions.create")).resolves.toMatchObject({ + params: { agentId: "main", message: "scope proof", permissionMode: "full" }, + }); + await expect.poll(() => page.url()).toContain("/chat/"); + expect(await gateway.getRequests("sessions.create")).toHaveLength(1); + } finally { + await context.close(); + } + }); + + it("rejects a retained Full-access selection after reconnecting without admin scope", async () => { + const { context, gateway, page } = await openDraft([ + "operator.admin", + "operator.read", + "operator.write", + ]); + try { + const permission = page.locator('[data-chat-permission-select="true"]'); + await permission.click(); + await page.locator('[data-chat-permission-option="full"]').click(); + await expect.poll(() => permission.getAttribute("data-chat-select-value")).toBe("full"); + + await gateway.setOperatorScopes(["operator.read", "operator.write"]); + await gateway.closeLatest(1001, "permission scope downgraded"); + await expect.poll(async () => (await gateway.getRequests("connect")).length).toBe(2); + + const submit = page.getByRole("button", { name: "Start session" }); + await expect.poll(() => submit.isDisabled()).toBe(true); + expect(await permission.getAttribute("data-chat-select-value")).toBe("full"); + await permission.click(); + const fullAccess = page.locator('[data-chat-permission-option="full"]'); + await expect.poll(() => fullAccess.getAttribute("disabled")).not.toBeNull(); + expect(await fullAccess.getAttribute("title")).toBe( + "Full access requires operator.admin access.", + ); + await page.keyboard.press("Escape"); + await page.locator(".new-session-page__message").press("Enter"); + + await pollLocatorText( + page.locator('.new-session-page__blocked-submit[role="status"]'), + ).toContain("This action requires operator.admin access."); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + } finally { + await context.close(); + } + }); + it("shows paired devices, cloud profiles, and Connect to admins", async () => { const { context, page } = await openDraft([ "operator.read", diff --git a/ui/src/e2e/new-session-page.places.e2e.test.ts b/ui/src/e2e/new-session-page.places.e2e.test.ts index 79dd6123a2fe..a8710c9197ca 100644 --- a/ui/src/e2e/new-session-page.places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -222,6 +222,9 @@ suite.define(() => { const footerBox = await page .locator(".new-session-page__composer .agent-chat__composer-footer") .boundingBox(); + const actionsBox = await page + .locator(".new-session-page__composer .agent-chat__composer-actions") + .boundingBox(); const attachmentButton = page.getByRole("button", { name: "Add attachment" }); const attachmentBox = await attachmentButton.boundingBox(); expect(heroBox).not.toBeNull(); @@ -230,6 +233,7 @@ suite.define(() => { expect(modelBox).not.toBeNull(); expect(modelWrapperBox).not.toBeNull(); expect(footerBox).not.toBeNull(); + expect(actionsBox).not.toBeNull(); expect(attachmentBox).not.toBeNull(); expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual( (triggersBox?.y ?? 0) + 1, @@ -260,10 +264,14 @@ suite.define(() => { (footerBox?.x ?? 0) + (footerBox?.width ?? 0) / 2, ); expect( - (footerBox?.x ?? 0) + - (footerBox?.width ?? 0) - - ((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)), + (actionsBox?.x ?? 0) - ((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)), ).toBeLessThanOrEqual(12); + expect((modelWrapperBox?.x ?? 0) + (modelWrapperBox?.width ?? 0)).toBeLessThanOrEqual( + actionsBox?.x ?? 0, + ); + expect((actionsBox?.x ?? 0) + (actionsBox?.width ?? 0)).toBeLessThanOrEqual( + (footerBox?.x ?? 0) + (footerBox?.width ?? 0) + 1, + ); expect(triggersBox?.x).toBeCloseTo(composerBox?.x ?? 0, 0); expect(triggersBox?.width).toBeCloseTo(composerBox?.width ?? 0, 0); expect(composerBox?.width).toBeCloseTo(48 * 16, 0); diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index bed26353d364..1f05f291f55a 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -55,7 +55,7 @@ suite.define(() => { const firstMessage = firstPage.locator(".new-session-page__message"); await firstMessage.fill("restore this prompt after restart"); await pastePng(firstMessage); - await firstPage.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await firstPage.locator('.chat-attachment-thumb img[alt="pixel.png"]').waitFor(); const incognito = firstPage.getByRole("switch", { name: "Incognito" }); await incognito.click(); await expect.poll(() => incognito.getAttribute("aria-checked")).toBe("true"); @@ -236,7 +236,7 @@ suite.define(() => { await message.waitFor(); await pastePng(message); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await page.getByRole("button", { name: "Start session" }).click(); const create = await gateway.waitForRequest("sessions.create"); @@ -268,7 +268,7 @@ suite.define(() => { .setInputFiles(path.join(process.cwd(), "ui/public/favicon-32.png")); const attachment = page.locator(".chat-attachment-thumb"); - const preview = attachment.locator('img[alt="Attachment preview"]'); + const preview = attachment.getByRole("img", { name: "favicon-32.png" }); const previewButton = page.getByRole("button", { name: "Open image favicon-32.png" }); await preview.waitFor({ state: "visible" }); await expect.poll(() => preview.getAttribute("src")).toMatch(/^data:image\/png;base64,/u); @@ -567,7 +567,7 @@ suite.define(() => { finish(); }); - await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); await expect.poll(() => submit.isEnabled()).toBe(true); await submit.click(); const create = await gateway.waitForRequest("sessions.create"); @@ -636,6 +636,114 @@ suite.define(() => { }); }); + it("releases pasted image previews after remove, reset, restored removal, and success", async () => { + await withNewSessionPage(async (page) => { + 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 }, + ); + await page.goto(`${suite.server.baseUrl}new`); + const composer = page.locator(".new-session-page__message"); + + await pastePng(composer); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); + await page.getByRole("button", { name: "Remove attachment" }).click(); + await expect.poll(async () => (await proof()).revoked).toBe(1); + + await pastePng(composer); + await page.getByRole("img", { name: "pixel.png" }).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 page.getByRole("img", { name: "pixel.png" }).waitFor(); + await navigate("chat"); + await page.waitForURL((url) => url.pathname.endsWith("/chat")); + await expect.poll(async () => (await proof()).revoked).toBe(2); + + await navigate("new-session"); + await composer.waitFor(); + await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1); + await page.getByRole("button", { name: "Remove attachment" }).click(); + await expect.poll(async () => (await proof()).revoked).toBe(3); + await pastePng(composer); + await page.getByRole("img", { name: "pixel.png" }).waitFor(); + await page.getByRole("button", { name: "Start session" }).click(); + await page.waitForURL( + (url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"), + ); + await expect.poll(async () => await proof()).toEqual({ created: 4, revoked: 4 }); + }); + }); + it("locks the submitted draft until creation settles and restores it after failure", async () => { await withNewSessionPage(async (page) => { const sessionKey = "agent:main:locked-new-session-draft"; diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index 0a97b124b49a..4098abce2b32 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -316,13 +316,6 @@ export async function openNewSessionPlusMenu(page: Page) { return menu; } -export async function selectNewSessionDraft(page: Page) { - const menu = await openNewSessionPlusMenu(page); - await menu.getByRole("menuitem", { name: "Draft" }).click(); - await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Draft", exact: true }).waitFor(); -} - export async function navigateInApp(page: Page, routeId: string, search = "") { await page.evaluate( ({ targetRouteId, targetSearch }) => { 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 index 4c68fceefbca..79da1cddef9b 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -28,6 +28,7 @@ const DESKTOP_CONTEXT: BrowserContextOptions = { }; const MOBILE_CONTEXT: BrowserContextOptions = { ...BASE_CONTEXT, + hasTouch: true, viewport: { height: 740, width: 364 }, }; const MODELS = [ @@ -101,7 +102,7 @@ async function withNewSessionPage( } suite.define(() => { - it("keeps rail privacy visible and exposes Draft from Plus on mobile", async () => { + it("keeps rail privacy visible and shows the mobile footer mode without hover", async () => { await withNewSessionPage(MOBILE_CONTEXT, async (page) => { await installMockGateway(page, { models: [ @@ -118,37 +119,72 @@ suite.define(() => { contextWindow: 200_000, }, ], + allowedSessionVisibilities: ["shared", "draft"], hasMultipleSessionSharingIdentities: true, }); await page.goto(`${suite.server.baseUrl}new`); const footer = page.locator(".new-session-page__composer .agent-chat__composer-footer"); const attach = page.getByRole("button", { name: "Add attachment" }); const takePhoto = page.getByRole("menuitem", { name: "Take photo" }); + const draft = page.locator('.new-session-page__visibility--draft[aria-label="Draft"]'); const incognito = page.getByRole("switch", { name: "Incognito" }); const model = page.locator(".new-session-page__composer .chat-composer-model-control"); - await Promise.all([footer.waitFor(), attach.waitFor(), incognito.waitFor(), model.waitFor()]); + await Promise.all([ + footer.waitFor(), + attach.waitFor(), + draft.waitFor({ state: "attached" }), + incognito.waitFor(), + model.waitFor(), + ]); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.mouse.move(0, 0); await expect .poll(() => incognito.evaluate((element) => getComputedStyle(element).opacity)) .toBe("1"); + await expect + .poll(() => draft.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("1"); expect( await incognito.evaluate( (element) => element.closest(".new-session-page__incognito-rail") != null, ), ).toBe(true); - const [footerBox, attachBox, modelBox] = await Promise.all([ + const [footerBox, attachBox, draftBox, modelBox] = await Promise.all([ footer.boundingBox(), attach.boundingBox(), + draft.boundingBox(), model.boundingBox(), ]); expect(footerBox).not.toBeNull(); expect(attachBox).not.toBeNull(); + expect(draftBox).not.toBeNull(); expect(modelBox).not.toBeNull(); - expect((attachBox?.x ?? 0) + (attachBox?.width ?? 0)).toBeLessThanOrEqual(modelBox?.x ?? 0); - for (const control of [attachBox, modelBox]) { + // The row reads as the settings for the next turn, in the order the + // operator decides them: attachments, draft visibility, then the model and + // its reasoning. This viewport is narrow enough that + // the row wraps, so the comparison is reading order — which line a control + // is on first, then where it sits on that line. + const followsInReadingOrder = ( + previous: { x: number; y: number; height: number } | null, + next: { x: number; y: number; height: number } | null, + ) => { + if (!previous || !next) { + return false; + } + const previousCenter = previous.y + previous.height / 2; + const nextCenter = next.y + next.height / 2; + const sameLine = Math.abs(nextCenter - previousCenter) <= previous.height / 2; + return sameLine ? next.x > previous.x : nextCenter > previousCenter; + }; + const sequence = [attachBox, draftBox, modelBox]; + for (let index = 1; index < sequence.length; index += 1) { + expect(followsInReadingOrder(sequence[index - 1] ?? null, sequence[index] ?? null)).toBe( + true, + ); + } + for (const control of [attachBox, draftBox, modelBox]) { expect(control?.x ?? 0).toBeGreaterThanOrEqual(footerBox?.x ?? 0); expect((control?.x ?? 0) + (control?.width ?? 0)).toBeLessThanOrEqual( (footerBox?.x ?? 0) + (footerBox?.width ?? 0), @@ -161,14 +197,23 @@ suite.define(() => { await attach.click(); await expect.poll(() => takePhoto.isVisible()).toBe(true); - await expect.poll(() => page.getByRole("menuitem", { name: "Draft" }).isVisible()).toBe(true); + // The plus becomes a close mark while its menu is up: one glyph rotating, + // so the button that opened the menu visibly is the one that dismisses it. + // A CSS rotation matrix is [cos, sin, -sin, cos], so the sine term carries + // the direction: negative is counter-clockwise, turning back against the + // upward travel of the menu rather than with it. + const attachGlyphSine = () => + attach.evaluate((element) => { + const { transform } = getComputedStyle(element.querySelector("svg") as SVGElement); + return transform === "none" + ? 0 + : Number(transform.slice(transform.indexOf("(") + 1).split(",")[1]); + }); + await expect.poll(attachGlyphSine).toBeCloseTo(-Math.SQRT1_2, 3); await page.keyboard.press("Escape"); + await expect.poll(attachGlyphSine).toBe(0); await incognito.click(); - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await page.mouse.move(0, 0); - await expect - .poll(() => incognito.evaluate((element) => getComputedStyle(element).opacity)) - .toBe("1"); + await expect.poll(() => incognito.getAttribute("aria-checked")).toBe("true"); }); }); @@ -210,7 +255,7 @@ suite.define(() => { await expect.poll(pickerOpen).toBe(false); await expect .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) - .toBe(true); + .toBe(false); await modelSelect.click(); await expect.poll(pickerOpen).toBe(true); await page.mouse.click(8, 8); 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 index 4c08fc449023..475bc5e9bc45 100644 --- a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -415,7 +415,7 @@ suite.define(() => { await page.getByRole("heading", { name: "Original agent" }).waitFor(); const message = page.locator(".new-session-page__message"); const projectTrigger = page.locator("#new-session-project-trigger"); - const start = page.locator("button.chat-send-btn"); + const start = page.locator("button.new-session-page__start-submit"); await message.fill("retry this draft after reconnect"); await gateway.deferNext("sessions.create"); await start.click(); diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index aa4367729893..54e21e53eb31 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -6,11 +6,7 @@ import { expect as expectBrowser } from "playwright/test"; import { afterEach, expect, it } from "vitest"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; -import { - openNewSessionPlusMenu, - replaceGatewayClient, - selectNewSessionDraft, -} from "./new-session-page.test-support.ts"; +import { openNewSessionPlusMenu, replaceGatewayClient } from "./new-session-page.test-support.ts"; import { avatarLabelCenterDelta, routeAvatarFixtures, @@ -602,12 +598,13 @@ suite.define(() => { }); await currentPage.goto(`${suite.server?.baseUrl ?? ""}new`); - const menu = await openNewSessionPlusMenu(currentPage); - await menu.getByRole("menuitem", { name: "Draft" }).waitFor(); + // Playwright check()/isChecked() support role="switch" buttons via aria-checked. + const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true }); + await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover(); + await draftToggle.waitFor(); await captureUiProof(currentPage, "02-create-draft-available.png"); - await menu.getByRole("menuitem", { name: "Draft" }).click(); - await currentPage.keyboard.press("Escape"); - await currentPage.getByRole("button", { name: "Draft", exact: true }).waitFor(); + await draftToggle.check(); + await expectBrowser(draftToggle).toBeChecked(); await currentPage.locator(".new-session-page__message").fill("work privately first"); await captureUiProof(currentPage, "03-create-draft-selected.png"); await currentPage.getByRole("button", { name: "Start session" }).click(); @@ -1019,7 +1016,9 @@ suite.define(() => { }); await currentPage.goto(`${suite.server?.baseUrl ?? ""}new`); - await selectNewSessionDraft(currentPage); + const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true }); + await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover(); + await draftToggle.check(); await gateway.setSessionSharingPolicy({ allowedSessionVisibilities: ["shared"], hasMultipleSessionSharingIdentities: false, @@ -1034,9 +1033,9 @@ suite.define(() => { hasMultipleSessionSharingIdentities: true, }); await replaceGatewayClient(currentPage); - const menu = await openNewSessionPlusMenu(currentPage); - await menu.getByRole("menuitem", { name: "Draft" }).waitFor(); - expect(await currentPage.getByRole("button", { name: "Draft", exact: true }).count()).toBe(0); + await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover(); + await draftToggle.waitFor(); + expect(await draftToggle.isChecked()).toBe(false); }); it("keeps create-as-draft dormant for one owner", async () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8b9100cfe9ec..2215c562b771 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -231,6 +231,8 @@ export const en: TranslationMap = { }, sessionProgressCard: { title: "Progress", + composerTitle: "Task progress", + shortCount: "{completed} of {total}", noteLabel: "Progress note", dismiss: "Dismiss progress card", dismissFailed: "Could not dismiss the progress card. Try again.", @@ -951,6 +953,8 @@ export const en: TranslationMap = { draftDescription: "Keep this session to yourself until you publish it", composerOptionsSelected: "Add attachments or session options ({count} selected)", messagePlaceholder: "What should this session work on?", + dictate: "Hold to dictate", + dictationHoldToSpeak: "Hold the microphone to dictate, then release to insert what you said.", readingAttachment: "Reading attachment", start: "Start session", startInTerminal: "Start in terminal", @@ -3425,6 +3429,7 @@ export const en: TranslationMap = { title: "Swarm", description: "Let Code Mode orchestrate groups of subagents in parallel.", defaultPhase: "Unphased", + progress: "{complete} of {total}", }, toolSearch: { title: "Tool Search", @@ -5494,6 +5499,8 @@ export const en: TranslationMap = { titleMany: "{count} cloud workspace conflicts", description: "OpenClaw kept your local versions and applied the other cloud changes. Inspect the staged result or take its version for a conflicted path.", + summary: "Local versions kept; inspect or take the cloud version.", + showCommands: "Show commands", morePaths: "+{count} more paths", stagedResult: "Staged cloud result", copyStagedResult: "Copy staged result ref", @@ -5767,6 +5774,8 @@ export const en: TranslationMap = { searchModels: "Search models", noMatchingModels: "No models match your search", sessionOverride: "Session override", + resetToDefault: "Reset to default ({model})", + useDefault: "Use default", defaultWithModel: "Default ({model})", defaultWithLevel: "Default ({level})", fastHelp: "Fast responses finish sooner and can use more of your usage limits.", @@ -6056,6 +6065,7 @@ export const en: TranslationMap = { microphonePermissionBlocked: "Microphone access is blocked. Allow it in browser site settings to list inputs.", holdToRecordSetting: "Hold microphone button to dictate", + holdToDictate: "Hold to dictate", holdToRecordSettingDescription: "Hold the composer microphone button, speak, then release to insert text without sending.", dictationAudioUnsupported: "The Gateway returned an unsupported dictation audio format.", @@ -6066,9 +6076,11 @@ export const en: TranslationMap = { dictationFinalizing: "Finishing dictation…", dictationFinalizationTimedOut: "Dictation stopped before the last partial transcript could be finalized.", - dictationProviderUnavailable: "No transcription provider is configured for dictation.", + dictationProviderUnavailable: + "No transcription provider is configured for dictation. Choose one in Settings to dictate.", dictationRecording: "Recording {elapsed}", dictationReleaseToInsert: "Release to insert dictation", + dictationStop: "Stop dictation", realtimeTalkCancellationRejected: "Realtime output cancellation was not accepted.", realtimeTalkMissingTurnIdentity: "Realtime output is missing its turn identity.", realtimeTalkRequiresMicrophone: "Realtime voice input requires browser microphone access.", diff --git a/ui/src/lib/sessions/session-placement-recovery.ts b/ui/src/lib/sessions/session-placement-recovery.ts index 2c84832bbcf7..b0ee3de87046 100644 --- a/ui/src/lib/sessions/session-placement-recovery.ts +++ b/ui/src/lib/sessions/session-placement-recovery.ts @@ -1,4 +1,7 @@ -import { SessionToolOverridesSchema } from "@openclaw/gateway-protocol"; +import { + SessionPermissionModeSchema, + SessionToolOverridesSchema, +} from "@openclaw/gateway-protocol"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { Value } from "typebox/value"; @@ -54,6 +57,7 @@ const PLACEMENT_CREATE_FIELDS = new Set([ "worktree", "incognito", "visibility", + "permissionMode", "toolOverrides", ...PLACEMENT_CREATE_STRING_FIELDS, ]); @@ -75,6 +79,8 @@ export function parseSessionPlacementCreateParams( record.worktree !== true || (record.incognito !== undefined && record.incognito !== true) || (record.visibility !== undefined && record.visibility !== "draft") || + (record.permissionMode !== undefined && + !Value.Check(SessionPermissionModeSchema, record.permissionMode)) || (record.toolOverrides !== undefined && !Value.Check(SessionToolOverridesSchema, record.toolOverrides)) || (record.projectId !== undefined && record.cwd !== undefined) || diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index c278adcc295a..a29b15c35898 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { t } from "../../i18n/index.ts"; import { @@ -8,6 +9,8 @@ import { renderComposerFixture as renderComposer, resetComposerFixture, } from "./chat-composer.test-support.ts"; +import { renderChatPrimaryActions } from "./components/chat-composer-controls.ts"; +import type { ComposerDictationController } from "./composer-dictation.ts"; afterEach(async () => { await resetComposerFixture(); @@ -32,6 +35,46 @@ function pressComposerEnter( } describe("renderChatComposer controls", () => { + it.each([ + { committed: false, sends: 0 }, + { committed: true, sends: 1 }, + ])( + "sends after dictation only when the current transcript committed: $committed", + async ({ committed, sends }) => { + const container = document.createElement("div"); + const finishActive = vi.fn().mockResolvedValue(committed); + const onSend = vi.fn(); + const dictation = { + active: true, + connecting: false, + finalizing: false, + locksComposer: true, + elapsed: "0:01", + finishActive, + } as unknown as ComposerDictationController; + render( + renderChatPrimaryActions({ + canAbort: false, + canSend: true, + connected: true, + draft: "preexisting draft", + isBusy: false, + steerNowEnabled: false, + sending: false, + dictation, + onSend, + }), + container, + ); + + container.querySelector(".chat-send-btn--dictation-send")?.click(); + await Promise.resolve(); + + expect(finishActive).toHaveBeenCalledOnce(); + expect(onSend).toHaveBeenCalledTimes(sends); + }, + ); + it.each([ { name: "empty idle", diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index ebef6caab002..c13bcb40db4f 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -151,19 +151,19 @@ describe("renderChatComposer controls", () => { }); expect(container.querySelector(".agent-chat__input--offline")).not.toBeNull(); - expect(container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe( + expect(container.querySelector(".agent-chat__composer-status-band")?.textContent?.trim()).toBe( "Offline — 3 queued; messages send when the connection returns.", ); expect(container.querySelector("textarea")?.disabled).toBe(false); expect(button(container, t("chat.runControls.sendMessage")).disabled).toBe(false); const empty = renderComposer({ offline: true, queuedOutboxCount: 0 }); - expect(empty.container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe( - "Offline — messages will be queued and sent when the connection returns.", - ); + expect( + empty.container.querySelector(".agent-chat__composer-status-band")?.textContent?.trim(), + ).toBe("Offline — messages will be queued and sent when the connection returns."); const online = renderComposer({ queuedOutboxCount: 3 }); - expect(online.container.querySelector(".agent-chat__offline-hint")).toBeNull(); + expect(online.container.querySelector(".agent-chat__composer-status-band")).toBeNull(); }); it("replaces the composer with the archived-session notice", () => { @@ -188,7 +188,11 @@ describe("renderChatComposer controls", () => { expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull(); banner?.querySelector("button")?.click(); expect(onAction).toHaveBeenCalledOnce(); - button(container, t("chat.runControls.stopGenerating")).click(); + const stop = container.querySelector( + `[aria-label="${t("chat.runControls.stopGenerating")}"]`, + ); + expect(stop).not.toBeNull(); + stop?.click(); expect(onAbort).toHaveBeenCalledOnce(); }); @@ -211,13 +215,12 @@ describe("renderChatComposer controls", () => { draft: "a draft that hides the placeholder", }); - const textarea = container.querySelector("textarea"); - const reasonRow = container.querySelector(".agent-chat__disabled-reason"); - expect(reasonRow?.textContent).toContain(reason); - expect(container.textContent?.split(reason)).toHaveLength(2); - expect(textarea?.placeholder).toBe(t("chat.composer.placeholder", { name: "OpenClaw" })); - expect(textarea?.disabled).toBe(true); - expect(textarea?.getAttribute("aria-describedby")?.split(" ")).toContain(reasonRow?.id); + // The placeholder carries the reason only for an empty composer; the + // dedicated reason row must keep the explanation visible alongside a draft. + expect(container.querySelector(".agent-chat__composer-status-band")?.textContent).toContain( + reason, + ); + expect(container.querySelector("textarea")?.disabled).toBe(true); }); it("opens the microphone picker, marks the selected input, and persists a selection", async () => { @@ -266,9 +269,11 @@ describe("renderChatComposer controls", () => { expect(items.find((item) => item.value === "studio-mic")?.getAttribute("aria-checked")).toBe( "true", ); - expect(items.find((item) => item.value === "studio-mic")?.querySelector("svg")?.innerHTML).toBe( - iconMarkup(icons.check), - ); + expect( + items + .find((item) => item.value === "studio-mic") + ?.querySelector(".chat-talk-input-picker__check"), + ).not.toBeNull(); items.find((item) => item.value === "headset")?.click(); await dropdown?.updateComplete; @@ -391,7 +396,6 @@ describe("renderChatComposer controls", () => { // type="checkbox" would make wa-dropdown-item paint its own leading check // and toggle it on click, so the row would show two disagreeing marks. expect(items.map((item) => item.getAttribute("type"))).toEqual(["normal", "normal"]); - expect(items.map((item) => item.querySelectorAll("svg").length)).toEqual([1, 0]); expect(items[0]?.querySelector(".chat-talk-input-picker__check")?.getAttribute("slot")).toBe( "details", ); @@ -649,6 +653,7 @@ describe("renderChatComposer status", () => { draw(); let panel = container.querySelector("openclaw-chat-question-panel") as HTMLElement & { updateComplete: Promise; + props: { onCollapsedChange: (collapsed: boolean) => void }; }; await panel.updateComplete; expect(container.querySelector(".agent-chat__input")).toBeNull(); @@ -659,7 +664,7 @@ describe("renderChatComposer status", () => { composerProps.draft = "Host updated this draft while the question was open"; - panel.querySelector(".chat-question-panel__collapse")?.click(); + panel.props.onCollapsedChange(true); draw(); await Promise.resolve(); let textarea = container.querySelector("textarea")!; @@ -667,7 +672,7 @@ describe("renderChatComposer status", () => { expect(document.activeElement).toBe(textarea); panel = container.querySelector("openclaw-chat-question-panel") as typeof panel; - panel.querySelector(".chat-question-panel__collapsed-button")?.click(); + panel.props.onCollapsedChange(false); draw(); await panel.updateComplete; expect(container.querySelector(".agent-chat__input")).toBeNull(); @@ -727,7 +732,7 @@ describe("renderChatComposer status", () => { expect(view.container.querySelector("openclaw-chat-question-panel")).toBeNull(); }); - it("renders only a fresh interrupted run as visible status chrome", () => { + it("keeps terminal status out of the composer chrome", () => { const now = vi.spyOn(Date, "now").mockReturnValue(1_000); let view = renderComposer({ runStatus: { phase: "done", runId: "run-0", sessionKey: "main", occurredAt: 900 }, @@ -738,8 +743,9 @@ describe("renderChatComposer status", () => { runStatus: { phase: "interrupted", runId: "run-1", sessionKey: "main", occurredAt: 900 }, composerControls: html``, }); + expect(view.container.querySelector(".agent-chat__run-status--interrupted")).toBeNull(); expect( - view.container.querySelector(".agent-chat__run-status--interrupted")?.textContent, + view.container.querySelector(".agent-chat__run-status-announcement")?.textContent, ).toContain("Interrupted"); now.mockReturnValue(7_000); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 2da1405c9419..3fae69b2daa5 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -70,18 +70,16 @@ export class ChatPane extends ChatPaneLayoutRender { } void this.ensureTaskSuggestionCloudProfiles(); const selectedSession = selectedChatSessionRow(state); - const selectedSessionId = selectedSession?.sessionId?.trim() || undefined; const mutationAccess = readChatPaneMutationAccess( this.context.gateway.snapshot, state.sessionKey, ); - const projectedObserverDigest = projectSessionObserverDigest( - selectedSession?.key ?? state.sessionKey, - selectedSession?.observerDigest, - ); const observerDigest = pickFreshestObserverDigest( state.observerDigest, - projectedObserverDigest, + projectSessionObserverDigest( + selectedSession?.key ?? state.sessionKey, + selectedSession?.observerDigest, + ), ); const observerRunId = resolveChatPaneObserverRunId({ localRunId: state.chatRunId, @@ -387,12 +385,16 @@ export class ChatPane extends ChatPaneLayoutRender { (!sessionParticipationBlocked || suggestionViewer) && !placementStartupPending, disabledReason: catalogDisabledReason ?? disabledReason, + disabledReasonTone: + sessionParticipationBlocked && !suggestionViewer && !catalogDisabledReason + ? "info" + : "danger", disabledBanner: this.sessionDisabledBanner({ catalogDisabledReason, modelSetupRequired, restartRecoveryTombstoned, selectedSessionArchived, - selectedSessionId, + selectedSessionId: selectedSession?.sessionId?.trim() || undefined, sessionKey: state.sessionKey, unarchiveAccess: mutationAccess.unarchive, }), diff --git a/ui/src/pages/chat/chat-pane-session-creation.ts b/ui/src/pages/chat/chat-pane-session-creation.ts index 1561be702ac3..b2058092a1b5 100644 --- a/ui/src/pages/chat/chat-pane-session-creation.ts +++ b/ui/src/pages/chat/chat-pane-session-creation.ts @@ -48,6 +48,7 @@ export abstract class ChatPaneSessionCreation extends ChatPaneRetainedPresentati return { kind: "composer-replacement" as const, text: t("chat.archivedSessionDisabled"), + icon: "archive" as const, actionLabel: t("common.unarchive"), disabledReason: !params.selectedSessionId ? "Session lifecycle action requires a durable session identity." diff --git a/ui/src/pages/chat/chat-pane-typing.test.ts b/ui/src/pages/chat/chat-pane-typing.test.ts index 68f79c5ac9a3..3f9dbb2d9518 100644 --- a/ui/src/pages/chat/chat-pane-typing.test.ts +++ b/ui/src/pages/chat/chat-pane-typing.test.ts @@ -98,8 +98,7 @@ describe("chat pane typing presence", () => { "Alice is typing…", ); expect(container.querySelectorAll(".agent-chat__typing-bubble > span")).toHaveLength(3); - expect(container.querySelector('[role="status"]')?.textContent).toBe("Alice, Bob are typing…"); - expect(container.querySelector('[role="status"]')?.textContent).not.toContain("Hello"); + expect(container.querySelector(".sr-only")?.textContent).toBe("Alice, Bob are typing…"); }); it("sends only the last 300 draft code points and omits previews when typing stops", () => { diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 79b1c0df0b41..27732ee48f6a 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -1,4 +1,6 @@ // Control UI tests cover chat responsive behavior. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; @@ -549,11 +551,19 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
The companion is already answering a question.
-
- - +
+
+ +
+
` @@ -572,7 +582,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) { : "" }
-
+
${ opts.slashMenu ? `
@@ -597,6 +607,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
` : "" } +
${ opts.composerAttachment ? `
@@ -611,33 +622,25 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) { : "" }
+
-
- ${iconSvg()} - -
-
- -
@@ -678,7 +686,9 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) { } async function syncFixtureComposerPopoverAnchor(page: Page) { - await page.locator(".agent-chat__input").evaluate((node) => { + // The session companion runs the same composer surface, so the pane's own + // composer is named by its shell rather than by the shared surface class. + await page.locator(".agent-chat__composer-shell > .agent-chat__input").evaluate((node) => { const viewport = window.visualViewport; const viewportTop = viewport?.offsetTop ?? 0; const layoutViewportHeight = document.documentElement.clientHeight || window.innerHeight; @@ -1589,6 +1599,42 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { } }); + it("centers a standalone interrupted status on the composer axis", async () => { + const page = await openBrowserPage(1200, 800); + try { + await page.setContent(` +
+
+
+
+ ${iconSvg()}Interrupted +
+
+
+
Composer
+
+ `); + + const [composer, status] = await Promise.all([ + getRect(page, ".agent-chat__composer-shell"), + getRect(page, ".chat-turn-terminal-status--interrupted"), + ]); + expect( + Math.abs(status.left + status.width / 2 - (composer.left + composer.width / 2)), + ).toBeLessThan(1); + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + path: path.join(artifactDir, "interrupted-status-centered.png"), + }); + } + } finally { + await closeBrowserPage(page); + } + }); + it("gives inline MCP Apps the full assistant message column", async () => { const page = await openBrowserPage(1366, 900); try { @@ -2355,7 +2401,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { return { assistantBubble: styleFor(".chat-group.assistant .chat-bubble:first-child"), bubble: styleFor(".chat-group.user .chat-bubble:first-child"), - composer: styleFor(".agent-chat__input"), + composer: styleFor(".agent-chat__composer-shell > .agent-chat__input"), footer: styleFor(".agent-chat__composer-footer"), textarea: styleFor(".agent-chat__composer-combobox > textarea"), }; @@ -2381,23 +2427,37 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { // keeps the text on the tool-row left edge. expect(geometry.assistantBubble?.paddingLeft).toBe(0); expect(geometry.assistantBubble?.paddingRight).toBe(0); - expect(geometry.composer?.borderRadius).toBe(mediumRadius); + // The composer rests one radius step above the bubble: it is the surface + // the thread sits on, not another card in the same stack. + expect(geometry.composer?.borderRadius).toBe(20 * (await readCornerScale(page))); - const usesCompactComposer = width <= 768 || (width <= 932 && height <= 500 && width > height); - const composerInset = usesCompactComposer ? 4 : 8; - const textareaBlockInset = usesCompactComposer ? 10 : composerInset; + // The editor's horizontal inset belongs to its row, not to the control, + // so the text keeps one origin while the surface changes shape. + const textareaBlockInset = width <= 768 || (width <= 932 && height <= 500) ? 10 : 6; expect(geometry.textarea?.paddingTop).toBe(textareaBlockInset); - expect(geometry.textarea?.paddingRight).toBe(composerInset); + expect(geometry.textarea?.paddingRight).toBe(0); expect(geometry.textarea?.paddingBottom).toBe(textareaBlockInset); - expect(geometry.textarea?.paddingLeft).toBe( - usesCompactComposer ? composerInset : composerInset - 4, - ); - expect(geometry.footer?.paddingLeft).toBe(8); - expect(geometry.footer?.paddingRight).toBe(8); - // #105866 splits the block inset evenly around the footer so the - // settings chip centers between the divider and the card edge. - expect(geometry.footer?.paddingTop).toBe(composerInset / 2); - expect(geometry.footer?.paddingBottom).toBe(composerInset / 2); + expect(geometry.textarea?.paddingLeft).toBe(0); + const shortLandscape = width <= 932 && height <= 500; + const footerInset = width <= 768 || shortLandscape ? 4 : 8; + expect(geometry.footer?.paddingLeft).toBe(footerInset); + expect(geometry.footer?.paddingRight).toBe(footerInset); + // Multiline keeps optical breathing room inside the footer on both edges; + // the outer margin only docks the complete row above the surface edge. + expect(geometry.footer?.paddingTop).toBe(width <= 768 ? 4 : shortLandscape ? 2 : 6); + expect(geometry.footer?.paddingBottom).toBe(width <= 768 ? 4 : shortLandscape ? 0 : 6); + + // The resting shape is two stacked regions, not one line that may grow + // into two: a draft that fits on a single line still leaves the surface at + // its multiline floor, with the whole action row below the editor. + const [surface, editor, actionRow] = await Promise.all([ + getRect(page, ".agent-chat__composer-shell > .agent-chat__input"), + getRect(page, ".agent-chat__composer-combobox > textarea"), + getRect(page, ".agent-chat__composer-footer"), + ]); + expect(surface.height).toBeGreaterThanOrEqual(98); + expect(actionRow.top).toBeGreaterThanOrEqual(editor.bottom - 1); + expect(surface.bottom - actionRow.bottom).toBeGreaterThanOrEqual(0); } finally { await closeBrowserPage(page); } @@ -2774,8 +2834,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const page = await openFixture(393, 852); try { const textarea = page.locator(".agent-chat__composer-combobox > textarea"); + // Comfortably past a quarter of the tallest viewport this case runs at, + // so the assertion below proves the cap and the scroll, not the draft. await textarea.fill( - Array.from({ length: 8 }, (_value, index) => `Mobile composer line ${index + 1}`).join( + Array.from({ length: 16 }, (_value, index) => `Mobile composer line ${index + 1}`).join( "\n", ), ); @@ -2807,7 +2869,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { return { attach: rectFor('.agent-chat__input-btn[aria-label="Add attachment"]'), attachIcon: rectFor('.agent-chat__input-btn[aria-label="Add attachment"] svg'), - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), meta: rectFor(".agent-chat__composer-meta"), model: rectFor(".chat-composer-model-control"), context: rectFor(".context-ring"), @@ -2825,6 +2887,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { } : null, thread: rectFor(".chat-thread"), + viewportHeight: window.innerHeight, viewportWidth: window.innerWidth, }; }); @@ -2850,15 +2913,16 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { throw new Error("Expected textarea sizing metrics"); } - const fiveLineHeight = - textareaMetrics.lineHeight * 5 + textareaMetrics.paddingTop + textareaMetrics.paddingBottom; - expect(textareaRect.height).toBeLessThanOrEqual(fiveLineHeight + 1); + // The editor grows against the viewport, not against a line count: past a + // quarter of the screen the surface stops moving and the draft scrolls + // inside it, so a long draft can never push the thread off the page. + expect(textareaRect.height).toBeLessThanOrEqual(layout.viewportHeight * 0.25 + 1); expect(textareaMetrics.scrollHeight).toBeGreaterThan(textareaMetrics.clientHeight); expect(input.y - (thread.y + thread.height)).toBeGreaterThanOrEqual(5.5); expect(shell.x).toBeLessThanOrEqual(8); expect(layout.viewportWidth - (shell.x + shell.width)).toBeLessThanOrEqual(8); expect(attach.x - input.x).toBeLessThanOrEqual(10); - expect(context.x).toBeGreaterThanOrEqual(model.x + model.width - 1); + expect(model.x).toBeGreaterThanOrEqual(context.x + context.width - 1); expect(input.x + input.width - (send.x + send.width)).toBeLessThanOrEqual(8); for (const control of [model, context]) { expect( @@ -2866,8 +2930,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { ).toBeLessThanOrEqual(2); } expect(meta.y).toBeGreaterThanOrEqual(model.y - 1); - expect(attachIcon.width).toBeGreaterThanOrEqual(18); - expect(attachIcon.height).toBeGreaterThanOrEqual(18); + expect(attachIcon.width).toBeGreaterThanOrEqual(16); + expect(attachIcon.height).toBeGreaterThanOrEqual(16); } finally { await closeBrowserPage(page); } @@ -2885,7 +2949,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { async (width, height, composerAttachment) => { const page = await openFixture(width, height, { composerAttachment }); try { - const composer = await getBoundingBox(page, ".agent-chat__input"); + const composer = await getBoundingBox( + page, + ".agent-chat__composer-shell > .agent-chat__input", + ); const menuSelector = ".context-usage__popover"; const triggerSelector = ".context-ring"; await page.locator(triggerSelector).evaluate((node) => { @@ -2933,12 +3000,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { await waitForLayoutSettled(page, ".context-usage__popover, .agent-chat__input"); await syncFixtureComposerPopoverAnchor(page); await waitForLayoutSettled(page, ".context-usage__popover, .agent-chat__input"); - const composer = await getBoundingBox(page, ".agent-chat__input"); + const composer = await getBoundingBox( + page, + ".agent-chat__composer-shell > .agent-chat__input", + ); const menu = await getBoundingBox(page, ".context-usage__popover"); - const anchorEvidence = await page.locator(".agent-chat__input").evaluate((node) => ({ - anchorBottom: getComputedStyle(node).getPropertyValue("--chat-composer-popover-bottom"), - layoutHeight: document.documentElement.clientHeight, - })); + const anchorEvidence = await page + .locator(".agent-chat__composer-shell > .agent-chat__input") + .evaluate((node) => ({ + anchorBottom: getComputedStyle(node).getPropertyValue("--chat-composer-popover-bottom"), + layoutHeight: document.documentElement.clientHeight, + })); expect(menu.y).toBeGreaterThanOrEqual(300); expect( @@ -2968,7 +3040,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }; }; return { - context: rectFor(".context-ring"), controls: rectFor(".agent-chat__composer-controls"), effort: rectFor(".chat-controls__effort-trigger"), footer: rectFor(".agent-chat__composer-footer"), @@ -2976,38 +3047,30 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { model: rectFor(".chat-controls__model-trigger"), modelLabel: rectFor(".chat-controls__model-trigger .chat-controls__inline-select-label"), overrides: rectFor(".agent-chat__session-overrides-pill"), - permission: rectFor(".chat-controls__permission-trigger"), status: rectFor(".agent-chat__composer-run-status"), typing: rectFor(".agent-chat__typing-indicator--outside"), }; }); expect(layout.controls.scrollWidth).toBeLessThanOrEqual(layout.controls.clientWidth + 1); - for (const control of [ - layout.status, - layout.overrides, - layout.model, - layout.effort, - layout.permission, - layout.context, - layout.typing, - ]) { + for (const control of [layout.status, layout.overrides, layout.model, layout.effort]) { expect(control.x).toBeGreaterThanOrEqual(layout.footer.x - 1); expect(control.x + control.width).toBeLessThanOrEqual( layout.footer.x + layout.footer.width + 1, ); } - for (const trigger of [layout.permission, layout.model, layout.effort]) { + expect(layout.typing.x).toBeGreaterThanOrEqual(0); + expect(layout.typing.x + layout.typing.width).toBeLessThanOrEqual(320); + for (const trigger of [layout.model, layout.effort]) { expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); } expect(layout.modelLabel.scrollWidth).toBeLessThanOrEqual(layout.modelLabel.clientWidth + 1); for (const [left, right] of [ - [layout.meta, layout.status], [layout.status, layout.overrides], [layout.overrides, layout.model], [layout.model, layout.effort], - [layout.effort, layout.context], + [layout.effort, layout.meta], ] as const) { expect(rectsOverlap(left, right)).toBe(false); } @@ -3021,7 +3084,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { [320, 568], [393, 852], [568, 320], - [1024, 768], [1366, 900], [1920, 1080], ] as const)( @@ -3063,7 +3125,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { return { chat: rectFor(".card.chat"), shell: rectFor(".agent-chat__composer-shell"), - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), thread: rectFor(".chat-thread"), footer: rectFor(".agent-chat__composer-footer"), textarea: rectFor(".agent-chat__composer-combobox > textarea"), @@ -3079,10 +3141,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { effortLabel: rectFor( ".chat-controls__effort-trigger .chat-controls__inline-select-label", ), - permission: rectFor(".chat-controls__permission-trigger"), - permissionLabel: rectFor( - ".chat-controls__permission-trigger .chat-controls__inline-select-label", - ), context: rectFor(".context-ring"), attach: rectFor('.agent-chat__input-btn[aria-label="Add attachment"]'), send: rectFor(".chat-send-btn"), @@ -3106,11 +3164,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect(controls.modelTriggerPadding).not.toBeNull(); expect(controls.effortTriggerPadding).not.toBeNull(); const effortLabel = expectControlRect(controls.effortLabel, "composer thinking label"); - const permission = expectControlRect(controls.permission, "composer permission trigger"); - const permissionLabel = expectControlRect( - controls.permissionLabel, - "composer permission label", - ); const context = expectControlRect(controls.context, "composer context control"); const attach = expectControlRect(controls.attach, "composer attach control"); const send = expectControlRect(controls.send, "composer send control"); @@ -3122,7 +3175,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { model, modelTrigger, effortTrigger, - permission, context, attach, send, @@ -3138,53 +3190,58 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect(model.y + model.height).toBeLessThanOrEqual(footer.y + footer.height + 1); expect(model.y).toBeGreaterThanOrEqual(textarea.y); expect(context.y).toBeGreaterThanOrEqual(textarea.y); - expect( - Math.abs(attach.y + attach.height / 2 - (send.y + send.height / 2)), - ).toBeLessThanOrEqual(2); - expect(attach.x + attach.width).toBeLessThanOrEqual(textarea.x + 1); - expect(send.x).toBeGreaterThanOrEqual(textarea.x + textarea.width - 1); + // The multiline footer is one row: the leading attachment control and + // trailing action group share its vertical bounds without overlapping. + expect(attach.y).toBeGreaterThanOrEqual(footer.y - 1); + expect(attach.y + attach.height).toBeLessThanOrEqual(footer.y + footer.height + 1); + expect(send.y).toBeGreaterThanOrEqual(footer.y - 1); + expect(send.y + send.height).toBeLessThanOrEqual(footer.y + footer.height + 1); + // Footer controls stay below the editor, pinned to opposite edges of the + // surface, and neither may drift back up into the text. + expect(attach.y).toBeGreaterThanOrEqual(textarea.y + textarea.height - 1); + expect(send.y).toBeGreaterThanOrEqual(textarea.y + textarea.height - 1); + expect(attach.x).toBeLessThan(send.x); expect(send.x + send.width).toBeLessThanOrEqual(input.x + input.width + 1); expect(rectsOverlap(model, send)).toBe(false); - expect(permission.x).toBeLessThan(model.x); - expect(rectsOverlap(permission, model)).toBe(false); - const effortContextGap = context.x - (effortTrigger.x + effortTrigger.width); - expect(effortContextGap).toBeGreaterThanOrEqual(-1); - expect(effortContextGap).toBeLessThanOrEqual(9); + const contextModelGap = model.x - (context.x + context.width); + expect(contextModelGap).toBeGreaterThanOrEqual(-1); const composerFontSize = await page .locator(".agent-chat__composer-combobox > textarea") .evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize)); if (width <= 768) { - expect(controls.modelTriggerPadding).toEqual({ end: 10, start: 10 }); - expect(controls.effortTriggerPadding).toEqual({ end: 10, start: 10 }); + const modelPadding = width <= 480 ? 0 : 4; + expect(controls.modelTriggerPadding).toEqual({ + end: modelPadding, + start: modelPadding, + }); + expect(controls.effortTriggerPadding).toEqual({ end: 4, start: 4 }); expect(composerFontSize).toBe(16); expect(model.width).toBeGreaterThanOrEqual(40); expect(model.width).toBeLessThanOrEqual(footer.width); - for (const trigger of [permission, modelTrigger, effortTrigger]) { + for (const trigger of [modelTrigger, effortTrigger]) { expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); } - for (const label of [modelLabel, effortLabel, permissionLabel]) { + for (const label of [modelLabel, effortLabel]) { expect(label.clientWidth).toBeDefined(); expect(label.scrollWidth).toBeDefined(); expect(label.scrollWidth ?? 0).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1); } expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); - for (const control of [permission, model, context]) { + for (const control of [model, context]) { expect( Math.abs(control.y + control.height / 2 - (model.y + model.height / 2)), ).toBeLessThanOrEqual(2); } - expect(footer.height).toBeLessThanOrEqual(49.1); + expect(footer.height).toBeLessThanOrEqual(53); } else { - expect(controls.modelTriggerPadding).toEqual({ end: 6, start: 8 }); - expect(controls.effortTriggerPadding).toEqual({ end: 6, start: 8 }); - expect(composerFontSize).toBe(14); - for (const label of [permissionLabel, modelLabel, effortLabel]) { - expect(label.scrollWidth).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1); - } - expect(send.width).toBeCloseTo(36, 2); - expect(send.height).toBeCloseTo(36, 2); + // The editor reads at input size, while the controls around it stay + // chrome-sized — that difference is what marks the text as the + // subject of the surface. + expect(composerFontSize).toBe(16); + expect(send.width).toBeCloseTo(32, 2); + expect(send.height).toBeCloseTo(32, 2); } if (width >= 1600) { @@ -3193,7 +3250,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect( Math.abs(shell.x + shell.width / 2 - (chat.x + chat.width / 2)), ).toBeLessThanOrEqual(1); - expect(input.height).toBeLessThanOrEqual(112); + expect(input.height).toBeLessThanOrEqual(119); } if (width > height && height <= 500) { @@ -3216,7 +3273,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const page = await openFixture(width, height, { composerAttachment: true }); try { await expectNoHorizontalOverflow(page); - const input = await getBoundingBox(page, ".agent-chat__input"); + const input = await getBoundingBox( + page, + ".agent-chat__composer-shell > .agent-chat__input", + ); const preview = await getBoundingBox(page, ".chat-attachments-preview"); const attachment = await getBoundingBox(page, ".chat-attachment-thumb"); const previewPaddingTop = await page @@ -3372,7 +3432,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }; }; return { - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), thread: rectFor(".chat-thread"), textarea: rectFor(".agent-chat__composer-combobox > textarea"), }; @@ -3395,7 +3455,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect(textarea.scrollHeight).toBeGreaterThan(textarea.clientHeight); const scrolled = await page.evaluate(() => { - const composer = document.querySelector(".agent-chat__input") as HTMLElement | null; + const composer = document.querySelector( + ".agent-chat__composer-shell > .agent-chat__input", + ) as HTMLElement | null; if (composer) { composer.scrollTop = composer.scrollHeight; } @@ -3409,7 +3471,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }; return { shell: rectFor(".agent-chat__composer-shell"), - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), meta: rectFor(".agent-chat__composer-meta"), model: rectFor(".chat-composer-model-control"), send: rectFor(".chat-send-btn"), @@ -3462,7 +3524,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }; }; return { - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), menu: rectFor(".slash-menu"), textarea: rectFor(".agent-chat__composer-combobox > textarea"), footer: rectFor(".agent-chat__composer-footer"), @@ -3484,7 +3546,9 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect(textarea.y).toBeGreaterThan(menu.y); const scrolled = await page.evaluate(() => { - const composer = document.querySelector(".agent-chat__input") as HTMLElement | null; + const composer = document.querySelector( + ".agent-chat__composer-shell > .agent-chat__input", + ) as HTMLElement | null; if (composer) { composer.scrollTop = composer.scrollHeight; } @@ -3497,7 +3561,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }; return { - input: rectFor(".agent-chat__input"), + input: rectFor(".agent-chat__composer-shell > .agent-chat__input"), footer: rectFor(".agent-chat__composer-footer"), }; }); diff --git a/ui/src/pages/chat/chat-view-notices.ts b/ui/src/pages/chat/chat-view-notices.ts index b1495a03945f..61dca65ab346 100644 --- a/ui/src/pages/chat/chat-view-notices.ts +++ b/ui/src/pages/chat/chat-view-notices.ts @@ -34,21 +34,25 @@ function renderDiskSpaceNotice(diskSpace: SessionPlacementDiskSpace | undefined) const critical = diskSpace.status === "critical"; return html`
-
- + +
${t(critical ? "chat.diskSpace.criticalTitle" : "chat.diskSpace.warningTitle")} + + ${t(critical ? "chat.diskSpace.criticalBody" : "chat.diskSpace.warningBody", { + percent: String(usedPercent), + free: formatBytes(diskSpace.availableBytes), + })} +
-

- ${t(critical ? "chat.diskSpace.criticalBody" : "chat.diskSpace.warningBody", { - percent: String(usedPercent), - free: formatBytes(diskSpace.availableBytes), - })} -

`; } @@ -58,9 +62,16 @@ export function renderChatViewNotices(props: ChatViewNoticesProps) { ${renderDiskSpaceNotice(props.diskSpace)} ${props.error ? html` -