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` -