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>
This commit is contained in:
RoboClaw
2026-08-26 00:07:50 -07:00
committed by GitHub
parent b1cc806f1b
commit 1ab9852320
14 changed files with 396 additions and 100 deletions
+6
View File
@@ -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"
/>
<circle cx="12" cy="12" r="3" />`),
slidersHorizontal: strokeIcon(svg` <line x1="4" x2="14" y1="7" y2="7" />
<line x1="18" x2="20" y1="7" y2="7" />
<line x1="4" x2="6" y1="17" y2="17" />
<line x1="10" x2="20" y1="17" y2="17" />
<line x1="14" x2="14" y1="4" y2="10" />
<line x1="8" x2="8" y1="14" y2="20" />`),
bug: strokeIcon(svg` <path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
+103 -41
View File
@@ -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",
@@ -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");
@@ -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");
@@ -428,10 +428,12 @@ function composerControlsHtml(crowded = false) {
<div class="chat-composer-model-control">
<div class="chat-controls__session chat-controls__model chat-controls__model-settings">
<details class="chat-controls__inline-select chat-controls__model-picker">
<summary class="chat-controls__inline-select-trigger chat-controls__model-trigger" data-chat-composer-model="true" aria-label="Chat model">
<summary class="chat-controls__inline-select-trigger chat-controls__model-trigger" data-chat-composer-model="true" data-chat-model-settings="true" aria-label="Chat model: GPT-5.6 Luna; Chat thinking level: Medium">
<span class="chat-controls__model-settings-icon">${iconSvg()}</span>
<span class="chat-controls__inline-select-label">GPT-5.6 Luna</span>
</summary>
<div class="chat-controls__inline-select-menu chat-controls__model-menu">
<button class="chat-controls__inline-select-option chat-controls__mobile-effort-option">Effort <span>Medium</span></button>
<div class="chat-controls__model-search-wrap"><input class="chat-controls__model-search" placeholder="Search models" /></div>
<div class="chat-controls__model-options">
<button class="chat-controls__inline-select-option chat-controls__model-option chat-controls__inline-select-option--selected">Default model</button>
@@ -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);
}
+41
View File
@@ -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(),
@@ -122,8 +122,23 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
<details
class="chat-controls__inline-select chat-controls__effort-picker"
@toggle=${(event: Event) => {
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<HTMLElement>(
"[data-chat-thinking-slider]:not(:disabled), [data-chat-thinking-option]:not(:disabled), [data-chat-speed-toggle]:not(:disabled)",
)
?.focus({ preventScroll: true });
});
}}
>
<summary
@@ -385,6 +385,16 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
commonDisabled ||
effortMutationDisabled ||
(thinking.options.length === 0 && (!showFastMode || fastMode.disabled));
const effortLabel = thinking.selection.displayLabel.replace(/^Inherited:\s*/u, "");
const showReasoning = thinking.options.length > 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`
<div class="chat-controls__session chat-controls__model chat-controls__model-settings">
${renderChatModelPicker({
@@ -402,6 +412,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
: undefined,
disabled: modelDisabled,
disabledReason: props.modelMutationDisabledReason,
mobileSecondary,
modelCatalogState: managedCatalog,
modelSelectionLocked: props.modelSelectionLocked === true,
modelOptions,
@@ -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<HTMLDetailsElement>(
".chat-controls__model-picker",
);
const effortPicker = modelPicker?.parentElement?.querySelector<HTMLDetailsElement>(
".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`
<details
class="chat-controls__inline-select chat-controls__model-picker"
@@ -437,10 +460,11 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
? "chat-controls__inline-select-trigger--disabled"
: ""}"
data-chat-model-select="true"
data-chat-model-settings="true"
data-chat-model-locked=${params.modelSelectionLocked ? "true" : "false"}
data-chat-select-value=${params.selectedModelValue}
data-chat-model-tools=${modelToolsUnavailable ? "unavailable" : "available"}
aria-label=${`${t("chat.selectors.model")}: ${triggerTitle}`}
aria-label=${settingsLabel}
aria-disabled=${params.disabled ? "true" : "false"}
title=${params.disabledReason ?? triggerTitle}
@click=${(event: MouseEvent) => {
@@ -451,6 +475,9 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
(event.currentTarget as HTMLElement).focus({ preventScroll: true });
}}
>
<span class="chat-controls__model-settings-icon" aria-hidden="true"
>${icons.slidersHorizontal}</span
>
${modelToolsUnavailable
? html`
<openclaw-tooltip .content=${t("chat.modelControls.chatOnlyHelp")}>
@@ -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`
<button
class="chat-controls__inline-select-option chat-controls__mobile-effort-option"
type="button"
?disabled=${params.mobileSecondary.disabled}
@click=${openEffortPicker}
>
<span>${params.mobileSecondary.label}</span>
<span>${params.mobileSecondary.value}</span>
</button>
`
: nothing}
${params.modelSelectionLocked
? html`
<div
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it } from "vitest";
import {
handleChatComposerDetailsToggle,
handleChatComposerDropdownShow,
markPointerOpenedChatComposerDropdown,
restorePointerOpenedChatComposerTrigger,
@@ -33,4 +34,36 @@ describe("chat picker overlay", () => {
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<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
expect(effortPicker.open).toBe(false);
expect(document.activeElement).toBe(modelTrigger);
});
});
@@ -44,6 +44,25 @@ function pickerTrigger(picker: HTMLElement): HTMLElement | null {
: picker.querySelector<HTMLElement>("[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<HTMLElement>(
".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<HTMLTextAreaElement>(".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) => {
+39
View File
@@ -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) {
+11 -4
View File
@@ -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);
}
}
+1
View File
@@ -570,6 +570,7 @@ export async function mountSidebar(
sidebarMenus: { preloadMenuRenderer: () => Promise<unknown> };
};
await Promise.all([
import("../components/app-sidebar-session-narration.ts"),
sidebarWithPreloads.preloadCatalogRenderer(),
sidebarWithPreloads.sidebarMenus.preloadMenuRenderer(),
]);