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