From a25444c5fa06aa3492200ead5b08ccbfc2c92c4a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 15 May 2026 03:38:07 -0500 Subject: [PATCH] fix(ui): align chat header controls --- CHANGELOG.md | 1 + ui/src/styles/chat/layout.css | 73 ++++++++++---------- ui/src/styles/chat/layout.test.ts | 11 +++ ui/src/styles/layout.css | 31 ++++++++- ui/src/styles/layout.mobile.css | 32 ++++----- ui/src/styles/layout.mobile.test.ts | 17 +++++ ui/src/ui/app-render.helpers.browser.test.ts | 36 ++++++---- ui/src/ui/app-render.helpers.ts | 71 ++++++++++--------- 8 files changed, 163 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ca0d74976d..5d2afbee7250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007. +- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev. - Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu. - Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors. - Discord: report unresolved configured bot-token SecretRefs during startup instead of treating the account as unconfigured. (#82009) Thanks @giodl73-repo. diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 26962697a19c..cb28584ff91a 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1158,8 +1158,9 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 12px; + gap: 8px; flex-wrap: wrap; + min-height: 36px; } .chat-controls__session { @@ -1179,8 +1180,9 @@ minmax(128px, 4fr); grid-template-areas: "agent session model thinking"; align-items: center; - gap: 8px; + gap: 6px; width: 100%; + min-height: 36px; min-width: 0; } @@ -1251,53 +1253,48 @@ /* Controls separator */ .chat-controls__separator { - color: rgba(255, 255, 255, 0.4); - font-size: 18px; - margin: 0 8px; + align-self: center; + flex: 0 0 1px; + width: 1px; + height: 22px; + margin: 0 3px; + overflow: hidden; + background: color-mix(in srgb, var(--border-strong) 72%, transparent); + color: transparent; + font-size: 0; font-weight: 300; } -:root[data-theme-mode="light"] .chat-controls__separator { - color: rgba(16, 24, 40, 0.3); +.chat-controls .btn--icon { + width: 36px; + min-width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-lg); } -.chat-controls__session select { - padding: 6px 10px; - font-size: 13px; +:root[data-theme-mode="light"] .chat-controls__separator { + background: rgba(16, 24, 40, 0.18); +} + +.chat-controls__session select, +.chat-controls__agent select, +.chat-controls__model select, +.chat-controls__thinking-select select { + box-sizing: border-box; + height: 36px; + min-height: 36px; + padding: 0 34px 0 12px; width: 100%; max-width: none; + border-color: color-mix(in srgb, var(--input) 88%, transparent); + border-radius: var(--radius-lg); + background-color: color-mix(in srgb, var(--card) 92%, var(--bg-elevated) 8%); + font-size: 13px; overflow: hidden; text-overflow: ellipsis; } -.chat-controls__agent select { - width: 100%; - max-width: none; -} - -.chat-controls__model select { - width: 100%; - max-width: none; -} - -.chat-controls__thinking-select select { - width: 100%; - max-width: none; -} - -.chat-controls__autoscroll { - min-width: 112px; - max-width: 118px; -} - -.chat-controls__autoscroll-select { - width: 100%; - height: 32px; - min-width: 0; - padding: 4px 26px 4px 8px; - font-size: 12px; -} - .chat-controls__thinking { display: flex; align-items: center; diff --git a/ui/src/styles/chat/layout.test.ts b/ui/src/styles/chat/layout.test.ts index 47e8c186882b..3c1d3775b079 100644 --- a/ui/src/styles/chat/layout.test.ts +++ b/ui/src/styles/chat/layout.test.ts @@ -46,4 +46,15 @@ describe("chat layout styles", () => { expect(css).toContain("@media (display-mode: standalone) and (max-width: 768px)"); expect(css).toContain("margin-bottom: calc(14px + max(var(--safe-area-bottom), 34px));"); }); + + it("keeps desktop chat header controls on a compact aligned rhythm", () => { + const css = readLayoutCss(); + + expect(css).toContain("min-height: 36px;"); + expect(css).toContain("height: 36px;"); + expect(css).toContain(".chat-controls .btn--icon {"); + expect(css).toContain("width: 36px;"); + expect(css).toContain(".chat-controls__separator {"); + expect(css).toContain("height: 22px;"); + }); }); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index de7ada7cb3d3..df509731af92 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -983,6 +983,7 @@ .content--chat .content-header.content-header--chat-hidden { opacity: 0; transform: translateY(-10px); + min-height: 0; max-height: 0px; padding-top: 0; padding-bottom: 0; @@ -1108,19 +1109,30 @@ grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: space-between; - gap: 12px; - padding-bottom: 0; + gap: 10px; + min-height: 44px; + padding: 4px 8px; overflow: visible; - max-height: 56px; + max-height: 44px; } .content--chat .content-header > div:first-child { + display: grid; + align-items: center; + position: relative; text-align: left; min-width: 0; } +.shell--chat-focus .content--chat .content-header { + min-height: 0; +} + .content--chat .page-meta { + align-self: center; justify-content: flex-end; + height: 36px; + align-items: center; min-width: max-content; overflow: visible; } @@ -1128,6 +1140,19 @@ .content--chat .chat-controls { flex-shrink: 0; flex-wrap: nowrap; + gap: 6px; +} + +.content--chat .content-header .chat-controls__session-notice { + position: absolute; + top: calc(100% + 2px); + left: 0; + width: min(100%, 680px); + min-height: 0; + overflow: hidden; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; } /* =========================================== diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index b6d1a30d7e3c..0e2384a36201 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -4,10 +4,11 @@ @media (max-width: 1320px) { .content--chat .content-header { - align-items: stretch; + align-items: center; gap: 8px; row-gap: 0; - max-height: 56px; + min-height: 44px; + max-height: 44px; overflow: visible; } @@ -16,6 +17,9 @@ } .content--chat .page-meta { + align-self: center; + height: 36px; + align-items: center; min-width: 0; justify-content: flex-end; flex-wrap: nowrap; @@ -505,28 +509,18 @@ } .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking { - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: flex-end; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: center; + justify-content: stretch; + gap: 8px; width: 100%; padding: 0; } - .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__autoscroll { - flex: 1 1 100%; - max-width: none; - min-width: 0; - } - - .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__autoscroll-select { - width: 100%; - height: 40px; - } - .chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon { - width: 44px; - min-width: 44px; + width: 100%; + min-width: 0; height: 44px; } diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts index b4c36c9f3aa5..2151e995937d 100644 --- a/ui/src/styles/layout.mobile.test.ts +++ b/ui/src/styles/layout.mobile.test.ts @@ -16,12 +16,29 @@ function readGroupedChatCss(): string { describe("chat header responsive mobile styles", () => { it("keeps the chat header and session controls from clipping on narrow widths", () => { const css = readMobileCss(); + const layoutCss = readLayoutCss(); expect(css).toContain("@media (max-width: 1320px)"); expect(css).toContain(".content--chat .content-header"); + expect(css).toContain("max-height: 44px;"); + expect(layoutCss).toContain(".content--chat .content-header .chat-controls__session-notice"); + expect(layoutCss).toContain("position: absolute;"); expect(css).toContain(".chat-controls__session-row"); expect(css).toContain(".chat-controls__thinking-select"); }); + + it("lays out mobile chat header action icons as an even full-width grid", () => { + const css = readMobileCss(); + + expect(css).toContain( + ".chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking", + ); + expect(css).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));"); + expect(css).toContain( + ".chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {\n width: 100%;", + ); + expect(css).toContain("height: 44px;"); + }); }); describe("sidebar menu trigger styles", () => { diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index aaca2c294dab..99726d85e428 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -100,11 +100,12 @@ describe("chat header controls (browser)", () => { container.querySelectorAll(".chat-controls .btn--icon[data-tooltip]"), ); - expect(buttons).toHaveLength(5); + expect(buttons).toHaveLength(6); const labels = buttons.map((button) => button.getAttribute("data-tooltip")); expect(labels).toEqual([ t("chat.refreshTitle"), + `${t("chat.autoScrollMode")}: ${t("chat.autoScrollNearBottom")}`, t("chat.thinkingToggle"), t("chat.toolCallsToggle"), t("chat.focusToggle"), @@ -162,7 +163,9 @@ describe("chat header controls (browser)", () => { container.querySelectorAll(".chat-controls__thinking .btn--icon"), ); - expect(buttons).toHaveLength(4); + expect(buttons).toHaveLength(5); + const autoScrollButton = requireButton(buttons.at(0), "auto-scroll mode"); + expect(autoScrollButton.dataset.chatAutoScrollMode).toBe("near-bottom"); const cronButton = requireButton(buttons.at(-1), "cron sessions"); expect([...cronButton.classList]).toEqual(["btn", "btn--sm", "btn--icon", "active"]); expect(cronButton.getAttribute("aria-pressed")).toBe("true"); @@ -173,26 +176,29 @@ describe("chat header controls (browser)", () => { expect(state.sessionsHideCron).toBe(false); }); - it("renders and applies the chat auto-scroll mode selector", async () => { + it("renders and applies the chat auto-scroll mode toggle", async () => { const applySettings = vi.fn(); const state = createState({ applySettings }); const container = document.createElement("div"); render(renderChatControls(state), container); await Promise.resolve(); - const select = requireElement( - container.querySelector('[data-chat-auto-scroll-select="true"]'), - "auto-scroll select", + const toggle = requireButton( + container.querySelector('[data-chat-auto-scroll-toggle="true"]'), + "auto-scroll toggle", ); - expect(select.getAttribute("aria-label")).toBe(t("chat.autoScrollMode")); - expect(select.value).toBe("near-bottom"); + expect(toggle.getAttribute("aria-label")).toBe( + `${t("chat.autoScrollMode")}: ${t("chat.autoScrollNearBottom")}`, + ); + expect(toggle.getAttribute("data-tooltip")).toBe(toggle.getAttribute("aria-label")); + expect(toggle.dataset.chatAutoScrollMode).toBe("near-bottom"); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); - select.value = "off"; - select.dispatchEvent(new Event("change")); + toggle.click(); expect(applySettings).toHaveBeenCalledWith({ ...state.settings, - chatAutoScroll: "off", + chatAutoScroll: "always", }); }); @@ -229,12 +235,16 @@ describe("chat header controls (browser)", () => { const selectDatasets = Array.from(container.querySelectorAll("select")).map( (select) => select.dataset, ); - expect(selectDatasets).toHaveLength(5); + expect(selectDatasets).toHaveLength(4); expect(selectDatasets[0]?.chatAgentFilter).toBe("true"); expect(selectDatasets[1]?.chatSessionSelect).toBe("true"); expect(selectDatasets[2]?.chatModelSelect).toBe("true"); expect(selectDatasets[3]?.chatThinkingSelect).toBe("true"); - expect(selectDatasets[4]?.chatAutoScrollSelect).toBe("true"); + const autoScrollToggle = requireButton( + container.querySelector('[data-chat-auto-scroll-toggle="true"]'), + "auto-scroll toggle", + ); + expect(autoScrollToggle.dataset.chatAutoScrollMode).toBe("near-bottom"); }); it("renders the mobile dropdown from state instead of mutating DOM classes", async () => { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index be55ed345e0e..831f7b721dbc 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -25,11 +25,7 @@ import { parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; -import { - CHAT_AUTO_SCROLL_MODES, - normalizeChatAutoScrollMode, - type ChatAutoScrollMode, -} from "./storage.ts"; +import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; import type { ThemeMode } from "./theme.ts"; import type { SessionsListResult } from "./types.ts"; @@ -276,37 +272,40 @@ function chatAutoScrollLabel(mode: ChatAutoScrollMode) { return t("chat.autoScrollNearBottom"); } -function renderChatAutoScrollSelect(state: AppViewState) { +function nextChatAutoScrollMode(mode: ChatAutoScrollMode): ChatAutoScrollMode { + switch (mode) { + case "near-bottom": + return "always"; + case "always": + return "off"; + case "off": + return "near-bottom"; + } + return "near-bottom"; +} + +function renderChatAutoScrollToggle(state: AppViewState) { const mode = normalizeChatAutoScrollMode(state.settings.chatAutoScroll); - const label = t("chat.autoScrollMode"); + const label = `${t("chat.autoScrollMode")}: ${chatAutoScrollLabel(mode)}`; + const active = mode !== "off"; return html` - + `; } @@ -399,7 +398,7 @@ export function renderChatControls(state: AppViewState) { ${refreshIcon} | - ${renderChatAutoScrollSelect(state)} + ${renderChatAutoScrollToggle(state)}