fix(control-ui): refine mobile chat layout

This commit is contained in:
Colin
2026-06-10 20:03:13 +01:00
committed by Shakker
parent 797aab5ab7
commit 2129d5b3ab
8 changed files with 234 additions and 8 deletions
+25 -2
View File
@@ -242,6 +242,10 @@ img.chat-avatar {
padding-right: 70px;
}
.chat-bubble--has-actions {
padding-right: 70px;
}
.chat-duplicate-count {
display: inline-flex;
align-items: center;
@@ -279,11 +283,30 @@ img.chat-avatar {
pointer-events: auto;
}
@media (hover: none) {
@media (hover: none), (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
.chat-bubble-actions {
opacity: 1;
pointer-events: auto;
}
.chat-bubble--has-actions {
display: flex;
flex-direction: column;
padding-right: 14px;
}
.chat-bubble--has-actions .chat-bubble-actions {
position: static;
order: 2;
justify-content: flex-end;
margin-top: 8px;
}
.chat-bubble--has-actions .chat-copy-btn,
.chat-bubble--has-actions .chat-expand-btn {
min-width: 44px;
min-height: 44px;
}
}
.chat-copy-btn,
@@ -622,7 +645,7 @@ details.msg-meta:not([open]) .msg-meta__details {
background: #dc2626;
}
@media (max-width: 768px) {
@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
.chat-group {
margin-left: 0;
margin-right: 0;
+57
View File
@@ -1376,6 +1376,59 @@
background: color-mix(in srgb, var(--danger) 85%, #fff);
}
@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
.agent-chat__toolbar {
flex-wrap: wrap;
align-items: center;
}
.agent-chat__toolbar-left {
order: 1;
flex: 1 1 auto;
}
.agent-chat__toolbar-right {
order: 2;
flex: 0 0 auto;
margin-left: auto;
}
.agent-chat__composer-controls {
order: 3;
display: grid;
flex: 1 0 100%;
grid-template-columns: minmax(0, 1fr) 44px;
gap: 8px;
width: 100%;
}
.agent-chat__composer-controls .chat-controls__model,
.chat-composer-model-control {
min-width: 0;
}
.chat-settings-popover-wrapper {
width: 44px;
margin-left: 0;
justify-self: end;
}
.chat-settings-chip {
width: 44px;
min-width: 44px;
max-width: 44px;
height: 44px;
min-height: 44px;
justify-content: center;
padding: 0;
}
.chat-settings-chip__text,
.chat-settings-chip__chevron {
display: none;
}
}
.chat-queue__item--steered {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
}
@@ -2489,6 +2542,10 @@
padding-top: 34px;
padding-right: 12px;
}
.chat-bubble.has-copy.chat-bubble--has-actions {
padding-top: 8px;
}
}
/* Chat loading skeleton */
+12 -2
View File
@@ -186,6 +186,13 @@
display: none;
}
.shell--nav-drawer-open .sidebar-shell__header .nav-collapse-toggle {
display: flex;
width: 44px;
min-width: 44px;
height: 44px;
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-shell__header {
justify-content: center;
align-items: center;
@@ -295,7 +302,7 @@
}
/* Mobile-specific styles */
@media (max-width: 768px) {
@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
.shell {
--shell-pad: 8px;
--shell-gap: 8px;
@@ -374,7 +381,10 @@
}
.shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav {
width: 78px;
width: min(92vw, 320px);
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.sidebar-shell,
+7 -2
View File
@@ -2314,11 +2314,16 @@ export function renderApp(state: AppViewState) {
<button
type="button"
class="nav-collapse-toggle"
@click=${() =>
@click=${() => {
if (navDrawerOpen) {
state.navDrawerOpen = false;
return;
}
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
});
}}
title="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
+101 -2
View File
@@ -116,6 +116,35 @@ function chatControlsHtml(opts: { agent?: boolean } = {}) {
`;
}
function composerControlsHtml() {
return `
<div class="agent-chat__composer-controls">
<div class="chat-composer-model-control">
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
<summary class="chat-controls__inline-select-trigger" data-chat-composer-model="true" aria-label="Chat model">
<span class="chat-controls__inline-select-label">Default model · Off</span>
<span class="chat-controls__inline-select-icon">${iconSvg()}</span>
</summary>
<div class="chat-controls__inline-select-menu chat-controls__inline-select-menu--combined">
<div class="chat-controls__combined-model-list">
<button class="chat-controls__inline-select-option chat-controls__combined-model-option chat-controls__inline-select-option--selected">Default model</button>
<button class="chat-controls__inline-select-option chat-controls__combined-model-option">gpt-5.5</button>
<button class="chat-controls__inline-select-option chat-controls__combined-model-option">claude-sonnet-4-6</button>
</div>
</div>
</details>
</div>
<div class="chat-settings-popover-wrapper">
<button class="chat-settings-chip" type="button" aria-label="Chat settings">
<span class="chat-settings-chip__icon">${iconSvg()}</span>
<span class="chat-settings-chip__text">Chat settings</span>
<span class="chat-settings-chip__chevron">${iconSvg()}</span>
</button>
</div>
</div>
`;
}
function chatHeaderControlsHtml(hidden = false) {
return `
<main class="content content--chat" data-chat-header-responsive-fixture>
@@ -205,13 +234,13 @@ function chatHtml(opts: { sideResult?: boolean; singleAgent?: boolean } = {}) {
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button class="agent-chat__input-btn">${iconSvg()}</button>
<button class="agent-chat__input-btn">${iconSvg()}</button>
<button class="agent-chat__input-btn">${iconSvg()}</button>
<span class="agent-chat__token-count">8</span>
</div>
${composerControlsHtml()}
<div class="agent-chat__toolbar-right">
<button class="btn btn--ghost">${iconSvg()}</button>
<button class="btn btn--ghost">${iconSvg()}</button>
<button class="chat-send-btn">${iconSvg()}</button>
</div>
</div>
@@ -269,6 +298,18 @@ async function getTextContentRect(page: Page, selector: string) {
return rect;
}
function rectsOverlap(
first: Pick<ControlRect, "x" | "y" | "width" | "height">,
second: Pick<ControlRect, "x" | "y" | "width" | "height">,
) {
return (
first.x < second.x + second.width &&
first.x + first.width > second.x &&
first.y < second.y + second.height &&
first.y + first.height > second.y
);
}
async function openHeaderFixture(width: number, height: number, opts: { hidden?: boolean } = {}) {
const page = await browser.newPage({ viewport: { width, height } });
await page.setContent(
@@ -525,6 +566,64 @@ describeBrowserLayout("chat responsive browser layout", () => {
}
});
it.each([
[320, 568],
[393, 852],
[568, 320],
] as const)(
"keeps current composer model, settings, and send controls from overlapping at %sx%s",
async (width, height) => {
const page = await openFixture(width, height);
try {
await expectNoHorizontalOverflow(page);
const controls = await page.evaluate(() => {
const rectFor = (selector: string) => {
const node = document.querySelector(selector) as HTMLElement | null;
if (!node) {
return null;
}
const rect = node.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
display: getComputedStyle(node).display,
};
};
return {
input: rectFor(".agent-chat__input"),
left: rectFor(".agent-chat__toolbar-left"),
model: rectFor(".chat-composer-model-control"),
settings: rectFor(".chat-settings-chip"),
settingsLabel: rectFor(".chat-settings-chip__text"),
send: rectFor(".chat-send-btn"),
};
});
const input = expectControlRect(controls.input, "composer");
const left = expectControlRect(controls.left, "composer left controls");
const model = expectControlRect(controls.model, "composer model control");
const settings = expectControlRect(controls.settings, "composer settings control");
const send = expectControlRect(controls.send, "composer send control");
const settingsLabel = expectControlRect(controls.settingsLabel, "settings label");
for (const control of [left, model, settings, send]) {
expect(control.x).toBeGreaterThanOrEqual(input.x - 1);
expect(control.x + control.width).toBeLessThanOrEqual(input.x + input.width + 1);
}
expect(rectsOverlap(model, settings)).toBe(false);
expect(rectsOverlap(model, send)).toBe(false);
expect(rectsOverlap(settings, send)).toBe(false);
expect(settings.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
expect(settings.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
expect(settingsLabel.display).toBe("none");
} finally {
await page.close();
}
},
);
it("uses the compact mobile grid when the agent filter is not rendered", async () => {
const page = await openFixture(320, 568, { singleAgent: true });
try {
+14
View File
@@ -2224,6 +2224,20 @@ describe("grouped chat rendering", () => {
);
});
it("reserves layout space for assistant message actions", () => {
const container = document.createElement("div");
renderAssistantMessage(container, {
id: "assistant-action-space",
role: "assistant",
content: "Copyable assistant text.",
timestamp: Date.now(),
});
const bubble = container.querySelector(".chat-group.assistant .chat-bubble");
expect(bubble?.classList.contains("chat-bubble--has-actions")).toBe(true);
expect(bubble?.querySelector(".chat-bubble-actions")).not.toBeNull();
});
it("renders hidden assistant_message canvas results with the configured sandbox", () => {
const container = document.createElement("div");
const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) =>
+2
View File
@@ -1696,10 +1696,12 @@ function renderGroupedMessage(
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
const isToolMessage = normalizedRole === "tool" || isToolResult;
const reserveActionSpace = hasActions && !isToolMessage;
const bubbleClasses = [
"chat-bubble",
isToolMessage ? "chat-bubble--tool-shell" : "",
hasActions ? "has-copy" : "",
reserveActionSpace ? "chat-bubble--has-actions" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
+16
View File
@@ -421,6 +421,22 @@ describe("control UI routing", () => {
expect([...nav.classList]).toEqual(["shell-nav"]);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
const drawerClose = expectElement(
app,
".sidebar-shell__header .nav-collapse-toggle",
HTMLButtonElement,
);
expect(drawerClose.getAttribute("aria-label")).toBe("Collapse sidebar");
drawerClose.click();
await app.updateComplete;
expect([...shell.classList]).toEqual(["shell", "shell--chat"]);
expect(toggle.getAttribute("aria-expanded")).toBe("false");
toggle.click();
await app.updateComplete;
expect([...shell.classList]).toEqual(["shell", "shell--chat", "shell--nav-drawer-open"]);
const link = expectElement(app, 'a.nav-item[href="/config"]', HTMLAnchorElement);
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));