mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(control-ui): refine mobile chat layout
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user