From 1ab9852320b6980afd8ad63af005fd92e558c652 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Wed, 26 Aug 2026 00:07:50 -0700 Subject: [PATCH] improve(ui): collapse mobile composer controls (#129853) * improve(ui): collapse mobile model controls * refactor(ui): align mobile composer controls * test(ui): capture mobile composer states * fix(ui): preserve mobile settings semantics * fix(ui): restore mobile picker focus Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> * test(ui): await sidebar narration preload --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- ui/src/components/icons.ts | 6 + ui/src/e2e/chat-composer-redesign.e2e.test.ts | 144 +++++++++++++----- .../e2e/new-session-page.places.e2e.test.ts | 32 ++++ ...ession-page.prompt-attachments.e2e.test.ts | 2 + .../chat/chat-responsive.browser.test.ts | 84 +++++----- ui/src/pages/chat/chat-view.test.ts | 41 +++++ .../chat/components/chat-effort-picker.ts | 17 ++- .../chat/components/chat-model-controls.ts | 11 ++ .../chat/components/chat-model-picker.ts | 42 ++++- .../components/chat-picker-overlay.test.ts | 33 ++++ .../chat/components/chat-picker-overlay.ts | 29 +++- ui/src/styles/chat/layout.css | 39 +++++ ui/src/styles/new-session.css | 15 +- ui/src/test-helpers/app-sidebar.ts | 1 + 14 files changed, 396 insertions(+), 100 deletions(-) diff --git a/ui/src/components/icons.ts b/ui/src/components/icons.ts index b058ca97df4a..93f7b2227def 100644 --- a/ui/src/components/icons.ts +++ b/ui/src/components/icons.ts @@ -136,6 +136,12 @@ export const icons = { d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /> `), + slidersHorizontal: strokeIcon(svg` + + + + + `), bug: strokeIcon(svg` diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index 244016a89674..ef1949a23dd6 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -36,7 +36,7 @@ suite.define(() => { }); it("keeps mobile picker panels above an attachment-expanded composer", async () => { - await suite.withPage({ viewport: { width: 667, height: 375 } }, async ({ page }) => { + await suite.withPage({ viewport: { width: 393, height: 852 } }, async ({ page }) => { const gateway = await installMockGateway(page); await page.goto(`${suite.server.baseUrl}chat`); await gateway.waitForRequest("chat.startup"); @@ -49,6 +49,7 @@ suite.define(() => { buffer: Buffer.from("mobile composer attachment"), }); await composer.locator(".chat-attachments-preview").waitFor({ state: "visible" }); + const mobileModelSettings = composer.locator('[data-chat-model-settings="true"]'); for (const picker of [ { @@ -60,13 +61,22 @@ suite.define(() => { trigger: '[data-chat-thinking-select="true"]', }, ]) { - await composer.locator(picker.trigger).click(); + if (picker.menu === ".chat-controls__effort-menu") { + await mobileModelSettings.click(); + await composer.locator(".chat-controls__mobile-effort-option").click(); + } else { + await composer.locator(picker.trigger).click(); + } await page.waitForTimeout(100); + const visibleTrigger = + picker.menu === ".chat-controls__effort-menu" + ? mobileModelSettings + : composer.locator(picker.trigger); const [composerBox, footerBox, menuBox, triggerBox] = await Promise.all([ composer.boundingBox(), composer.locator(".agent-chat__composer-footer").boundingBox(), page.locator(picker.menu).boundingBox(), - composer.locator(picker.trigger).boundingBox(), + visibleTrigger.boundingBox(), ]); expect(composerBox).not.toBeNull(); expect(footerBox).not.toBeNull(); @@ -76,19 +86,42 @@ suite.define(() => { throw new Error(`expected mobile layout boxes for ${picker.menu}`); } expect(menuBox.x).toBeGreaterThanOrEqual(12); - expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(655); - expect(menuBox.width).toBeGreaterThanOrEqual(642); + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(381); + expect(menuBox.width).toBeGreaterThanOrEqual(368); expect(menuBox.y).toBeGreaterThanOrEqual(0); expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(composerBox.y + 1); - expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual(376); - expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(376); - await composer.locator(picker.trigger).click(); + expect(triggerBox.y + triggerBox.height).toBeLessThanOrEqual(853); + expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(853); + await page.keyboard.press("Escape"); } + await mobileModelSettings.press("Enter"); + const mobileEffortOption = composer.locator(".chat-controls__mobile-effort-option"); + await expect.poll(() => mobileEffortOption.isVisible()).toBe(true); + await mobileEffortOption.press("Enter"); + const focusedEffortControl = composer.locator( + "[data-chat-thinking-slider]:not([disabled]), [data-chat-speed-toggle]:not([disabled])", + ); + await expect + .poll(() => + focusedEffortControl.first().evaluate((node) => node === document.activeElement), + ) + .toBe(true); + await page.keyboard.press("Escape"); + await expect + .poll(() => mobileModelSettings.evaluate((node) => node === document.activeElement)) + .toBe(true); }); }); 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 artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const pageOptions = { + viewport: { width: 1920, height: 1080 }, + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { width: 393, height: 852 } } } + : {}), + }; + await suite.withPage(pageOptions, async ({ page }) => { const gateway = await installMockGateway(page, { assistantName: "Rosita", deferredMethods: ["chat.send"], @@ -190,6 +223,16 @@ suite.define(() => { const voice = page.getByRole("button", { name: "Start voice input" }); const microphonePicker = page.getByRole("button", { name: "Microphone input" }); const microphonePickerShell = page.locator(".chat-talk-input-picker"); + const captureMobileState = async (fileName: string) => { + if (!artifactDir) { + return; + } + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: `${artifactDir}/${fileName}`, + }); + }; await expect.poll(() => model.isVisible()).toBe(true); expect(await gateway.getRequests("chat.metadata")).toHaveLength(0); @@ -527,6 +570,8 @@ suite.define(() => { }); await expect.poll(() => followUp.isVisible()).toBe(true); await expect.poll(() => page.locator(".chat-send-btn--stop").count()).toBe(0); + await page.setViewportSize({ width: 393, height: 852 }); + await captureMobileState("mobile-composer-active-follow-up.png"); await textarea.fill(""); const stop = page.getByRole("button", { name: "Stop generating" }); @@ -536,6 +581,17 @@ suite.define(() => { await expect .poll(() => stop.evaluate((node) => getComputedStyle(node).backgroundColor)) .not.toBe(brandFill); + const mobileModelSettings = composer.locator('[data-chat-model-settings="true"]'); + await expect.poll(() => mobileModelSettings.isVisible()).toBe(true); + const [activeMobileSettingsBox, activeMobileStopBox] = await Promise.all([ + mobileModelSettings.boundingBox(), + stop.boundingBox(), + ]); + expect(activeMobileSettingsBox?.width).toBeGreaterThanOrEqual(44); + expect(activeMobileSettingsBox?.height).toBeGreaterThanOrEqual(44); + expect(activeMobileStopBox?.width).toBeGreaterThanOrEqual(44); + expect(activeMobileStopBox?.height).toBeGreaterThanOrEqual(44); + await captureMobileState("mobile-composer-active-stop.png"); await textarea.press("Escape"); const abortRequest = await gateway.waitForRequest("chat.abort"); expect(abortRequest.params).toMatchObject({ @@ -550,6 +606,7 @@ suite.define(() => { .toBe(true); await expect.poll(() => emptySend.isVisible()).toBe(true); await expect.poll(() => emptySend.isDisabled()).toBe(true); + await captureMobileState("mobile-composer-idle.png"); // 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. @@ -563,7 +620,6 @@ suite.define(() => { }) .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 @@ -577,47 +633,47 @@ suite.define(() => { return settled ? settled.x + settled.width : Number.POSITIVE_INFINITY; }) .toBeLessThanOrEqual(393); - const [mobileAttachBox, mobileModelBox, mobileSettingsBox, mobileContextBox, mobileVoiceBox] = - await Promise.all([ - attach.boundingBox(), - model.boundingBox(), - settings.boundingBox(), - contextUsage.boundingBox(), - voice.boundingBox(), - ]); + await expect.poll(() => mobileModelSettings.isVisible()).toBe(true); + await expect.poll(() => effort.isVisible()).toBe(false); + const [ + mobileAttachBox, + mobileModelSettingsBox, + mobileSettingsBox, + mobileContextBox, + mobileVoiceBox, + ] = await Promise.all([ + attach.boundingBox(), + mobileModelSettings.boundingBox(), + settings.boundingBox(), + contextUsage.boundingBox(), + voice.boundingBox(), + ]); expect(mobileAttachBox).not.toBeNull(); - expect(mobileModelBox).not.toBeNull(); + expect(mobileModelSettingsBox).not.toBeNull(); expect(mobileSettingsBox).not.toBeNull(); expect(mobileContextBox).not.toBeNull(); expect(mobileVoiceBox).not.toBeNull(); if ( !mobileAttachBox || - !mobileModelBox || + !mobileModelSettingsBox || !mobileSettingsBox || !mobileContextBox || !mobileVoiceBox ) { throw new Error("expected mobile composer controls to have layout boxes"); } - await expect - .poll(() => - model.evaluate((node) => { - const style = getComputedStyle(node); - return [style.paddingInlineStart, style.paddingInlineEnd]; - }), - ) - .toEqual(["0px", "0px"]); - await expect - .poll(() => - effort.evaluate((node) => { - const style = getComputedStyle(node); - return [style.paddingInlineStart, style.paddingInlineEnd]; - }), - ) - .toEqual(["4px", "4px"]); - for (const control of [mobileModelBox, mobileContextBox]) { + expect(mobileModelSettingsBox.width).toBeGreaterThanOrEqual(44); + expect(mobileModelSettingsBox.height).toBeGreaterThanOrEqual(44); + expect(mobileModelSettingsBox.x).toBeGreaterThanOrEqual( + mobileContextBox.x + mobileContextBox.width - 1, + ); + for (const control of [mobileModelSettingsBox, mobileContextBox]) { expect( - Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)), + Math.abs( + control.y + + control.height / 2 - + (mobileModelSettingsBox.y + mobileModelSettingsBox.height / 2), + ), ).toBeLessThanOrEqual(2); } expect(mobileSettingsBox.x).toBeGreaterThanOrEqual(0); @@ -653,12 +709,14 @@ suite.define(() => { await expect .poll(() => page.getByRole("button", { name: "Send message" }).isVisible()) .toBe(true); + await captureMobileState("mobile-composer-send-ready.png"); await textarea.fill(""); await expect.poll(() => camera.count()).toBe(0); - await model.click(); + await mobileModelSettings.click(); await expect .poll(() => composer.locator(".chat-controls__model-menu").isVisible()) .toBe(true); + await captureMobileState("mobile-composer-model-open.png"); const mobilePickerBox = await composer.locator(".chat-controls__model-menu").boundingBox(); expect(mobilePickerBox).not.toBeNull(); if (!mobilePickerBox) { @@ -666,7 +724,12 @@ suite.define(() => { } expect(mobilePickerBox.x).toBeGreaterThanOrEqual(0); expect(mobilePickerBox.x + mobilePickerBox.width).toBeLessThanOrEqual(393); - await model.click(); + await composer.locator(".chat-controls__mobile-effort-option").click(); + await expect + .poll(() => composer.locator(".chat-controls__effort-menu").isVisible()) + .toBe(true); + await captureMobileState("mobile-composer-effort-open.png"); + await page.keyboard.press("Escape"); await settings.click(); await expect.poll(() => viewMenu.isVisible()).toBe(true); await settings.click(); @@ -684,7 +747,6 @@ suite.define(() => { 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", 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 a8710c9197ca..70cdbc7a0666 100644 --- a/ui/src/e2e/new-session-page.places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -278,6 +278,38 @@ suite.define(() => { expect(await page.locator(".new-session-page__message").getAttribute("rows")).toBe("1"); await captureProjectUiProof(page, "new-session-control-layout.png"); + await page.setViewportSize({ width: 393, height: 852 }); + const mobileModelSettings = page.locator( + '.new-session-page__composer [data-chat-model-settings="true"]', + ); + await expect.poll(() => mobileModelSettings.isVisible()).toBe(true); + const [mobileFooterBox, mobileModelSettingsBox] = await Promise.all([ + page.locator(".new-session-page__composer .agent-chat__composer-footer").boundingBox(), + mobileModelSettings.boundingBox(), + ]); + expect(mobileFooterBox).not.toBeNull(); + expect(mobileModelSettingsBox).not.toBeNull(); + if (!mobileFooterBox || !mobileModelSettingsBox) { + throw new Error("expected mobile new-session composer controls"); + } + expect(mobileModelSettingsBox.width).toBeGreaterThanOrEqual(44); + expect(mobileModelSettingsBox.height).toBeGreaterThanOrEqual(44); + expect(mobileModelSettingsBox.x).toBeGreaterThan( + mobileFooterBox.x + mobileFooterBox.width / 2, + ); + expect(mobileModelSettingsBox.x + mobileModelSettingsBox.width).toBeLessThanOrEqual( + mobileFooterBox.x + mobileFooterBox.width, + ); + await captureProjectUiProof(page, "mobile-new-session-idle.png"); + await mobileModelSettings.click(); + await expect.poll(() => page.locator(".chat-controls__model-menu").isVisible()).toBe(true); + await captureProjectUiProof(page, "mobile-new-session-model-open.png"); + await page.locator(".chat-controls__mobile-effort-option").click(); + await expect.poll(() => page.locator(".chat-controls__effort-menu").isVisible()).toBe(true); + await captureProjectUiProof(page, "mobile-new-session-effort-open.png"); + await page.keyboard.press("Escape"); + await page.setViewportSize({ width: 1280, height: 900 }); + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); await agentPicker.locator(".agent-select__trigger").click(); await pollLocatorText(agentPicker.locator(".agent-select__menu-title")).toBe("Agents"); 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 1f05f291f55a..fa88e5fff105 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 @@ -226,6 +226,7 @@ suite.define(() => { it("pastes an image into the draft and forwards it with the initial turn", async () => { await withNewSessionPage(async (page) => { + await page.setViewportSize({ width: 393, height: 852 }); const gateway = await installMockGateway(page, { methodResponses: { "sessions.create": { key: "agent:main:image-draft", runStarted: true }, @@ -237,6 +238,7 @@ suite.define(() => { await pastePng(message); await page.getByRole("img", { name: "pixel.png" }).waitFor(); + await captureUiProof(page, "mobile-composer-new-session-attachment.png"); await page.getByRole("button", { name: "Start session" }).click(); const create = await gateway.waitForRequest("sessions.create"); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 62700dba80ae..a8944b1cd375 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -428,10 +428,12 @@ function composerControlsHtml(crowded = false) {
- + + ${iconSvg()} GPT-5.6 Luna
+
@@ -3056,7 +3058,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { } }); - it("keeps transient footer controls from crushing the mobile model pickers", async () => { + it("keeps transient footer controls from crushing mobile model settings", async () => { const page = await openFixture(320, 568, { crowdedComposerFooter: true }); try { await expectNoHorizontalOverflow(page); @@ -3075,19 +3077,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { }; return { controls: rectFor(".agent-chat__composer-controls"), - effort: rectFor(".chat-controls__effort-trigger"), footer: rectFor(".agent-chat__composer-footer"), meta: rectFor(".agent-chat__composer-meta"), - model: rectFor(".chat-controls__model-trigger"), - modelLabel: rectFor(".chat-controls__model-trigger .chat-controls__inline-select-label"), overrides: rectFor(".agent-chat__session-overrides-pill"), + settings: rectFor(".chat-controls__model-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]) { + for (const control of [layout.status, layout.overrides, layout.settings]) { expect(control.x).toBeGreaterThanOrEqual(layout.footer.x - 1); expect(control.x + control.width).toBeLessThanOrEqual( layout.footer.x + layout.footer.width + 1, @@ -3095,16 +3095,12 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { } 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); + expect(layout.settings.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + expect(layout.settings.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); for (const [left, right] of [ [layout.status, layout.overrides], - [layout.overrides, layout.model], - [layout.model, layout.effort], - [layout.effort, layout.meta], + [layout.overrides, layout.settings], + [layout.settings, layout.meta], ] as const) { expect(rectsOverlap(left, right)).toBe(false); } @@ -3165,6 +3161,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { textarea: rectFor(".agent-chat__composer-combobox > textarea"), meta: rectFor(".agent-chat__composer-meta"), model: rectFor(".chat-composer-model-control"), + modelSettings: rectFor(".chat-controls__model-trigger"), modelTrigger: rectFor(".chat-controls__model-trigger"), modelTriggerPadding: paddingFor(".chat-controls__model-trigger"), modelLabel: rectFor( @@ -3189,30 +3186,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const textarea = expectControlRect(controls.textarea, "composer textarea"); const meta = expectControlRect(controls.meta, "composer metadata"); const model = expectControlRect(controls.model, "composer model control"); - const modelTrigger = expectControlRect(controls.modelTrigger, "composer model trigger"); - const modelLabel = expectControlRect(controls.modelLabel, "composer model label"); - const effortTrigger = expectControlRect( - controls.effortTrigger, - "composer thinking trigger", - ); - expect(controls.modelTriggerPadding).not.toBeNull(); - expect(controls.effortTriggerPadding).not.toBeNull(); - const effortLabel = expectControlRect(controls.effortLabel, "composer thinking 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"); - for (const control of [ - footer, - textarea, - meta, - model, - modelTrigger, - effortTrigger, - context, - attach, - send, - ]) { + for (const control of [footer, textarea, meta, model, context, attach, send]) { expect(control.x).toBeGreaterThanOrEqual(input.x - 1); expect(control.x + control.width).toBeLessThanOrEqual(input.x + input.width + 1); } @@ -3242,25 +3220,34 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const composerFontSize = await page .locator(".agent-chat__composer-combobox > textarea") .evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize)); - if (width <= 768) { - 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(composerFontSize).toBe(16); + if (width <= 480) { + const modelSettings = expectControlRect( + controls.modelSettings, + "composer model settings", + ); expect(model.width).toBeGreaterThanOrEqual(40); expect(model.width).toBeLessThanOrEqual(footer.width); - for (const trigger of [modelTrigger, effortTrigger]) { - expect(trigger.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); - expect(trigger.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); - } + expect(modelSettings.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + expect(modelSettings.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + expect(modelSettings.x).toBeGreaterThanOrEqual(context.x + context.width - 1); + } else { + const modelTrigger = expectControlRect(controls.modelTrigger, "composer model trigger"); + const modelLabel = expectControlRect(controls.modelLabel, "composer model label"); + const effortTrigger = expectControlRect( + controls.effortTrigger, + "composer thinking trigger", + ); + const effortLabel = expectControlRect(controls.effortLabel, "composer thinking label"); + expect(controls.modelTriggerPadding).not.toBeNull(); + expect(controls.effortTriggerPadding).not.toBeNull(); for (const label of [modelLabel, effortLabel]) { - expect(label.clientWidth).toBeDefined(); - expect(label.scrollWidth).toBeDefined(); expect(label.scrollWidth ?? 0).toBeLessThanOrEqual((label.clientWidth ?? 0) + 1); } + expect(modelTrigger.x).toBeGreaterThanOrEqual(model.x - 1); + expect(effortTrigger.x).toBeGreaterThanOrEqual(modelTrigger.x + modelTrigger.width - 1); + } + if (width <= 768) { expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); for (const control of [model, context]) { @@ -3273,7 +3260,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { // 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); } diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index f83eef1ef794..a6ed1fec0188 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -6887,6 +6887,47 @@ describe("chat model controls", () => { ).not.toBeNull(); }); + it("labels the mobile secondary setting as Fast mode when reasoning is unavailable", () => { + const { state } = createChatHeaderState({ + model: "gpt-5.5", + modelProvider: "openai", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + reasoning: false, + }, + ], + }); + const sessionsResult = expectDefined(state.sessionsResult, "fast-only session"); + const session = expectDefined(sessionsResult.sessions[0], "fast-only session row"); + state.sessionsResult = { + ...sessionsResult, + defaults: { + ...sessionsResult.defaults, + thinkingLevels: [], + }, + sessions: [ + { + ...session, + thinkingLevels: [], + }, + ], + }; + + const container = renderModelControls(state); + const mobileSecondary = container.querySelector(".chat-controls__mobile-effort-option"); + const modelTrigger = container.querySelector('[data-chat-model-select="true"]'); + + expect(mobileSecondary?.textContent).toContain("Fast mode"); + expect(mobileSecondary?.textContent).toContain("Standard"); + expect(modelTrigger?.getAttribute("aria-label")).toContain("Fast mode: Standard"); + expect(modelTrigger?.getAttribute("aria-label")).not.toContain("Thinking level"); + expect(getThinkingSlider(container)).toBeNull(); + expect(container.querySelector("[data-chat-speed-toggle]")).not.toBeNull(); + }); + it("applies model, reasoning, and speed for the session that opened the picker", async () => { const { state } = createReasoningHeaderState({ models: createOpenAiModelCatalog(), diff --git a/ui/src/pages/chat/components/chat-effort-picker.ts b/ui/src/pages/chat/components/chat-effort-picker.ts index 4626c44abc78..e6e5c22a057d 100644 --- a/ui/src/pages/chat/components/chat-effort-picker.ts +++ b/ui/src/pages/chat/components/chat-effort-picker.ts @@ -122,8 +122,23 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
{ + const details = event.currentTarget as HTMLDetailsElement; handleChatComposerDetailsToggle(event); - syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement); + syncChatPickerOverlay(details); + if (!details.open) { + return; + } + if (!details.hasAttribute("data-chat-focus-panel")) { + return; + } + details.removeAttribute("data-chat-focus-panel"); + queueMicrotask(() => { + details + .querySelector( + "[data-chat-thinking-slider]:not(:disabled), [data-chat-thinking-option]:not(:disabled), [data-chat-speed-toggle]:not(:disabled)", + ) + ?.focus({ preventScroll: true }); + }); }} > 0; + const mobileSecondary = + showReasoning || (showFastMode && fastMode.supported) + ? { + disabled: effortDisabled, + label: showReasoning ? t("chat.modelControls.effort") : t("chat.modelControls.fastMode"), + value: showReasoning ? effortLabel : fastMode.label, + } + : undefined; return html`
${renderChatModelPicker({ @@ -402,6 +412,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) { : undefined, disabled: modelDisabled, disabledReason: props.modelMutationDisabledReason, + mobileSecondary, modelCatalogState: managedCatalog, modelSelectionLocked: props.modelSelectionLocked === true, modelOptions, diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index 5a977d376431..e29bbfff5565 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -30,6 +30,7 @@ type ChatModelPickerParams = { contextWindow?: ChatContextWindowControlParams; disabled: boolean; disabledReason?: string; + mobileSecondary?: { disabled: boolean; label: string; value: string }; modelCatalogState?: ChatModelCatalogState; modelSelectionLocked: boolean; modelOptions: ChatModelPickerOption[]; @@ -411,6 +412,28 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { highlightModelRow(menu, row); } }; + const openEffortPicker = (event: MouseEvent) => { + event.stopPropagation(); + if (params.mobileSecondary?.disabled !== false) { + return; + } + // SAFETY: Lit binds this handler directly to the effort button rendered below. + const modelPicker = (event.currentTarget as HTMLElement).closest( + ".chat-controls__model-picker", + ); + const effortPicker = modelPicker?.parentElement?.querySelector( + ".chat-controls__effort-picker", + ); + if (!modelPicker || !effortPicker) { + return; + } + effortPicker.setAttribute("data-chat-focus-panel", ""); + modelPicker.open = false; + effortPicker.open = true; + }; + const settingsLabel = params.mobileSecondary + ? `${t("chat.selectors.model")}: ${triggerTitle}; ${params.mobileSecondary.label}: ${params.mobileSecondary.value}` + : `${t("chat.selectors.model")}: ${triggerTitle}`; return html`
{ @@ -451,6 +475,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { (event.currentTarget as HTMLElement).focus({ preventScroll: true }); }} > + ${modelToolsUnavailable ? html` @@ -484,6 +511,19 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { class="chat-controls__inline-select-menu chat-controls__model-menu" aria-label=${t("chat.selectors.model")} > + ${params.mobileSecondary + ? html` + + ` + : nothing} ${params.modelSelectionLocked ? html`
{ expect(trigger.hasAttribute("data-chat-pointer-restored-focus")).toBe(false); }); + + it("returns Escape focus to the visible mobile settings trigger", async () => { + const composer = document.createElement("div"); + composer.className = "agent-chat__input"; + const settings = document.createElement("div"); + settings.className = "chat-controls__model-settings"; + const modelPicker = document.createElement("details"); + modelPicker.className = "chat-controls__model-picker"; + const modelTrigger = document.createElement("summary"); + modelPicker.append(modelTrigger); + const effortPicker = document.createElement("details"); + effortPicker.className = "chat-controls__effort-picker"; + const hiddenEffortTrigger = document.createElement("summary"); + hiddenEffortTrigger.style.display = "none"; + const effortControl = document.createElement("input"); + effortPicker.append(hiddenEffortTrigger, effortControl); + settings.append(modelPicker, effortPicker); + composer.append(settings); + document.body.append(composer); + + effortPicker.open = true; + effortPicker.addEventListener("toggle", handleChatComposerDetailsToggle); + effortPicker.dispatchEvent(new Event("toggle")); + effortControl.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + expect(effortPicker.open).toBe(false); + expect(document.activeElement).toBe(modelTrigger); + }); }); diff --git a/ui/src/pages/chat/components/chat-picker-overlay.ts b/ui/src/pages/chat/components/chat-picker-overlay.ts index b464676108ac..e862ee7269f8 100644 --- a/ui/src/pages/chat/components/chat-picker-overlay.ts +++ b/ui/src/pages/chat/components/chat-picker-overlay.ts @@ -44,6 +44,25 @@ function pickerTrigger(picker: HTMLElement): HTMLElement | null { : picker.querySelector("[slot=trigger]"); } +function restoreEscapeFocus(picker: HTMLElement): void { + const trigger = pickerTrigger(picker); + if (!trigger || getComputedStyle(trigger).display !== "none") { + trigger?.focus({ preventScroll: true }); + return; + } + const focusScope = picker.closest("openclaw-chat-pane") ?? picker.closest("openclaw-app"); + // Closing can replace the composer subtree; resolve the visible trigger only + // after the retained pane has rendered the replacement controls. + requestAnimationFrame(() => + requestAnimationFrame(() => { + const target = (focusScope ?? document).querySelector( + ".chat-controls__model-settings .chat-controls__model-picker > summary", + ); + target?.focus({ preventScroll: true }); + }), + ); +} + function dismissChatComposerPickersOutside(event: PointerEvent): void { const path = event.composedPath(); for (const picker of openChatComposerPickers()) { @@ -75,14 +94,14 @@ function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void { } event.preventDefault(); event.stopPropagation(); - const trigger = pickers.at(-1); + const lastPicker = pickers.at(-1); pickers.forEach(closeComposerPicker); invocationComposer?.dispatchEvent(new CustomEvent(CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT)); invocationComposer ?.querySelector(".agent-chat__composer-combobox > textarea") ?.focus({ preventScroll: true }); - if (trigger) { - pickerTrigger(trigger)?.focus({ preventScroll: true }); + if (lastPicker) { + restoreEscapeFocus(lastPicker); } } @@ -92,7 +111,9 @@ export function ensureChatComposerPickerDismissal(): void { } composerPickerDismissalInstalled = true; document.addEventListener("pointerdown", dismissChatComposerPickersOutside, true); - document.addEventListener("keydown", dismissChatComposerPickersOnEscape, true); + // Window capture observes the open picker before component Escape handlers + // mutate details.open and erase the return-focus owner. + window.addEventListener("keydown", dismissChatComposerPickersOnEscape, true); document.addEventListener( "keydown", (event) => { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 55da82867777..1b6f993508b9 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -5970,6 +5970,11 @@ button.chat-pr__diff { gap: 2px; } +.chat-controls__model-settings-icon, +.chat-controls__mobile-effort-option { + display: none; +} + .chat-controls__model-settings .chat-controls__inline-select-trigger { width: auto; } @@ -7192,6 +7197,7 @@ button.chat-pr__diff { .agent-chat__input .chat-controls__model-trigger { gap: 3px; + justify-content: center; padding-inline: 0; } @@ -7298,6 +7304,39 @@ button.chat-pr__diff { width: fit-content; max-width: 100%; } + + .agent-chat__input .chat-composer-model-control, + .agent-chat__input .chat-controls__model-settings, + .agent-chat__input .chat-controls__model-picker, + .agent-chat__input .chat-controls__model-trigger { + flex: 0 0 var(--chat-composer-control-size); + width: var(--chat-composer-control-size); + min-width: var(--chat-composer-control-size); + max-width: var(--chat-composer-control-size); + } + + .agent-chat__input .chat-controls__model-trigger > :not(.chat-controls__model-settings-icon) { + display: none; + } + + .agent-chat__input .chat-controls__model-settings-icon, + .agent-chat__input .chat-controls__mobile-effort-option { + display: inline-flex; + } + + .agent-chat__input .chat-controls__model-settings-icon svg { + width: var(--chat-composer-chip-icon); + height: var(--chat-composer-chip-icon); + } + + .agent-chat__input .chat-controls__effort-picker { + position: absolute; + width: 0; + } + + .agent-chat__input .chat-controls__effort-picker > .chat-controls__effort-trigger { + display: none; + } } @media (max-width: 640px) { diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index fecfe22291ec..1354f11a2d77 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -1100,10 +1100,8 @@ wa-dropdown.new-session-page__start-menu { gap: 4px 2px; } - /* A new session has visibility chips instead of the chat settings cluster. - Keep those chips intrinsic and let the model picker use the remaining width; - spacing stays on the shared family token so the row keeps one rhythm as it - wraps. */ + /* Visibility chips stay intrinsic and let the model control use the + remaining width until the shared narrow-composer treatment takes over. */ .new-session-page__composer .agent-chat__composer-controls { display: flex; flex-wrap: wrap; @@ -1121,3 +1119,12 @@ wa-dropdown.new-session-page__start-menu { position: static; } } + +@container chat-composer (max-width: 480px) { + .new-session-page__composer .agent-chat__input .chat-composer-model-control { + flex: 0 0 var(--chat-composer-control-size); + width: var(--chat-composer-control-size); + min-width: var(--chat-composer-control-size); + max-width: var(--chat-composer-control-size); + } +} diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index fc0042e406e6..982ce954f1aa 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -570,6 +570,7 @@ export async function mountSidebar( sidebarMenus: { preloadMenuRenderer: () => Promise }; }; await Promise.all([ + import("../components/app-sidebar-session-narration.ts"), sidebarWithPreloads.preloadCatalogRenderer(), sidebarWithPreloads.sidebarMenus.preloadMenuRenderer(), ]);