From ac19a857a82b4d4ad51762e8e2ead52f4da062dd Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 4 May 2026 03:29:52 -0500 Subject: [PATCH] fix(control-ui): refine responsive chat controls --- CHANGELOG.md | 2 + docs/web/control-ui.md | 3 + ui/src/styles/chat/grouped.css | 21 + ui/src/styles/chat/layout.css | 110 ++++- ui/src/styles/components.css | 26 ++ ui/src/styles/layout.css | 21 +- ui/src/styles/layout.mobile.css | 163 +++++-- ui/src/ui/app-render.helpers.browser.test.ts | 58 ++- ui/src/ui/app-render.helpers.node.test.ts | 53 ++- ui/src/ui/app-render.helpers.ts | 66 ++- ui/src/ui/app-render.ts | 8 +- ui/src/ui/app-scroll.test.ts | 38 ++ ui/src/ui/app-scroll.ts | 19 + ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 2 + ui/src/ui/chat/build-chat-items.test.ts | 66 +++ ui/src/ui/chat/build-chat-items.ts | 57 ++- .../ui/chat/chat-responsive.browser.test.ts | 423 ++++++++++++++++++ ui/src/ui/chat/grouped-render.test.ts | 44 ++ ui/src/ui/chat/grouped-render.ts | 12 + ui/src/ui/chat/session-controls.ts | 157 ++++++- ui/src/ui/types/chat-types.ts | 4 +- ui/src/ui/views/chat.test.ts | 121 +++++ 23 files changed, 1350 insertions(+), 125 deletions(-) create mode 100644 ui/src/ui/chat/chat-responsive.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 872d271cffa..6207a8ad629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Docs: https://docs.openclaw.ai - Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines. - Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output. - Agents/commands: add `/steer ` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934) +- Control UI/chat: add an agent filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, and hide that row while scrolling down the transcript. Thanks @BunsDev. +- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so no-op heartbeat acknowledgements stay compact without hiding nearby context. - Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc. - TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc. - Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index d3b0c029196..1d79f83648c 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -158,6 +158,9 @@ Imported themes are stored only in the current browser profile. They are not wri - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat). - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). + - The chat session picker is scoped by the selected agent. Switching agents shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet. + - On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls. + - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 6f391bdeda1..439bf143a0a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -242,6 +242,27 @@ img.chat-avatar { padding-right: 70px; } +.chat-duplicate-count { + display: inline-flex; + align-items: center; + align-self: flex-start; + min-height: 22px; + margin-top: 8px; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--panel) 72%, transparent); + color: var(--muted); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.chat-group.user .chat-duplicate-count { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: color-mix(in srgb, var(--foreground) 80%, var(--primary) 20%); +} + .chat-bubble-actions { position: absolute; top: 6px; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 79813602a16..ea8dba10e20 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -55,7 +55,7 @@ /* Grow, shrink, and use 0 base for proper scrolling */ overflow-y: auto; overflow-x: hidden; - padding: 0 6px 6px; + padding: 0 clamp(6px, 1vw, 12px) 6px; margin: 0 0 0 0; min-height: 0; /* Allow shrinking for flex scroll behavior */ @@ -602,6 +602,7 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 6px 10px; border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); } @@ -1037,20 +1038,50 @@ } .chat-controls__session { - min-width: 140px; - max-width: 300px; + min-width: 0; + max-width: none; +} + +.chat-controls__agent { + min-width: 0; + max-width: none; } .chat-controls__session-row { - display: flex; + display: grid; + grid-template-columns: + minmax(132px, 7fr) minmax(116px, 5fr) minmax(132px, 5fr) + minmax(128px, 4fr); + grid-template-areas: "session agent model thinking"; align-items: center; - gap: 12px; - flex-wrap: wrap; + gap: 8px; + width: 100%; + min-width: 0; +} + +.chat-controls__session-row--single-agent { + grid-template-columns: minmax(132px, 7fr) minmax(132px, 5fr) minmax(128px, 4fr); + grid-template-areas: "session model thinking"; +} + +.chat-controls__session-picker { + grid-area: session; +} + +.chat-controls__agent { + grid-area: agent; } .chat-controls__model { - min-width: 170px; - max-width: 320px; + grid-area: model; + min-width: 0; + max-width: none; +} + +.chat-controls__thinking-select { + grid-area: thinking; + min-width: 0; + max-width: none; } .chat-controls__thinking { @@ -1075,13 +1106,25 @@ .chat-controls__session select { padding: 6px 10px; font-size: 13px; - max-width: 300px; + width: 100%; + max-width: none; overflow: hidden; text-overflow: ellipsis; } +.chat-controls__agent select { + width: 100%; + max-width: none; +} + .chat-controls__model select { - max-width: 320px; + width: 100%; + max-width: none; +} + +.chat-controls__thinking-select select { + width: 100%; + max-width: none; } .chat-controls__thinking { @@ -1105,12 +1148,24 @@ border-color: rgba(16, 24, 40, 0.15); } +@media (max-width: 1535px) { + .chat-controls__thinking-select { + min-width: 0; + max-width: none; + } +} + @media (max-width: 768px) { .chat-controls__session { min-width: 120px; max-width: none; } + .chat-controls__agent { + min-width: 120px; + max-width: none; + } + .chat-controls__model { min-width: 140px; max-width: none; @@ -1123,6 +1178,22 @@ .chat-compose__field textarea { min-height: 64px; } + + .agent-chat__input-btn, + .agent-chat__toolbar .btn--ghost, + .chat-send-btn { + width: 44px; + min-width: 44px; + height: 44px; + } + + .agent-chat__suggestions { + grid-template-columns: minmax(0, 1fr); + } + + .agent-chat__suggestion { + min-height: 44px; + } } @media (max-width: 640px) { @@ -1161,6 +1232,11 @@ .chat-controls__model { min-width: 150px; } + + .chat-bubble.has-copy { + padding-top: 34px; + padding-right: 12px; + } } /* Chat loading skeleton */ @@ -1177,7 +1253,9 @@ justify-content: center; text-align: center; gap: 12px; - padding: 48px 24px; + width: min(100%, 560px); + margin: auto; + padding: clamp(28px, 6vh, 56px) 24px; flex: 1; min-height: 0; } @@ -1266,18 +1344,18 @@ } .agent-chat__suggestions { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; - justify-content: center; - max-width: 480px; + width: min(100%, 480px); margin-top: 8px; } .agent-chat__suggestion { + min-height: 40px; font-size: 13px; padding: 8px 16px; - border-radius: var(--radius-full); + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--panel); color: var(--foreground); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 3a0233a499a..6a1f1fd82f6 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1748,6 +1748,32 @@ margin-bottom: 0; } +@media (max-width: 768px) { + .chat-side-result { + position: fixed; + left: max(8px, var(--safe-area-left)); + right: max(8px, var(--safe-area-right)); + bottom: calc(108px + var(--safe-area-bottom)); + z-index: 35; + width: auto; + max-height: min(42vh, 320px); + margin: 0; + overflow: auto; + background: color-mix(in srgb, var(--card) 96%, var(--info) 4%); + box-shadow: var(--shadow-lg); + } + + .chat-side-result--error { + background: color-mix(in srgb, var(--card) 96%, var(--danger) 4%); + } + + .chat-side-result__dismiss { + width: 44px; + min-width: 44px; + height: 44px; + } +} + /* =========================================== Code Blocks =========================================== */ diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index af70a5bd88d..de7ada7cb3d 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -980,6 +980,16 @@ pointer-events: none; } +.content--chat .content-header.content-header--chat-hidden { + opacity: 0; + transform: translateY(-10px); + max-height: 0px; + padding-top: 0; + padding-bottom: 0; + overflow: hidden; + pointer-events: none; +} + .page-title { font-size: 22px; font-weight: 650; @@ -1094,25 +1104,30 @@ /* Chat view header adjustments */ .content--chat .content-header { - flex-direction: row; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: space-between; - gap: 16px; + gap: 12px; padding-bottom: 0; overflow: visible; + max-height: 56px; } .content--chat .content-header > div:first-child { text-align: left; + min-width: 0; } .content--chat .page-meta { - justify-content: flex-start; + justify-content: flex-end; + min-width: max-content; overflow: visible; } .content--chat .chat-controls { flex-shrink: 0; + flex-wrap: nowrap; } /* =========================================== diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index fcba380d672..4223c8a2f13 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -5,44 +5,62 @@ @media (max-width: 1320px) { .content--chat .content-header { align-items: stretch; - gap: 12px; - row-gap: 10px; - max-height: 180px; + gap: 8px; + row-gap: 0; + max-height: 56px; overflow: visible; } .content--chat .content-header > div:first-child { - flex: 1 1 100%; min-width: 0; } .content--chat .page-meta { - width: 100%; min-width: 0; - justify-content: space-between; - flex-wrap: wrap; - row-gap: 8px; + justify-content: flex-end; + flex-wrap: nowrap; + row-gap: 0; } .content--chat .chat-controls { - margin-left: auto; + margin-left: 0; justify-content: flex-end; - row-gap: 8px; + row-gap: 0; } .chat-controls__session-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: start; - gap: 10px 12px; + grid-template-columns: + minmax(112px, 7fr) minmax(96px, 5fr) minmax(116px, 5fr) + minmax(112px, 4fr); + grid-template-areas: "session agent model thinking"; + align-items: center; + gap: 8px; width: 100%; } + .chat-controls__session-row--single-agent { + grid-template-columns: minmax(112px, 7fr) minmax(116px, 5fr) minmax(112px, 4fr); + grid-template-areas: "session model thinking"; + } + + .chat-controls__agent { + grid-area: agent; + } + + .chat-controls__session-picker { + grid-area: session; + } + + .chat-controls__model { + grid-area: model; + } + .chat-controls__thinking-select { - grid-column: 1 / -1; + grid-area: thinking; } .chat-controls__session, + .chat-controls__agent, .chat-controls__model, .chat-controls__thinking-select { min-width: 0; @@ -50,6 +68,7 @@ } .chat-controls__session select, + .chat-controls__agent select, .chat-controls__model select, .chat-controls__thinking-select select { width: 100%; @@ -381,23 +400,31 @@ .chat-mobile-controls-wrapper .chat-controls-mobile-toggle { display: flex; + width: 44px; + min-width: 44px; + height: 44px; } - /* The dropdown panel — anchored below the gear in topbar */ + /* The dropdown panel is viewport-clamped so narrow phones never crop controls. */ .chat-mobile-controls-wrapper .chat-controls-dropdown { display: none; - position: absolute; - top: 100%; - right: 0; + position: fixed; + top: calc(var(--shell-topbar-height) + 8px); + right: max(8px, var(--safe-area-right)); + left: auto; z-index: 100; + width: min(420px, calc(100vw - var(--safe-area-left) - var(--safe-area-right) - 16px)); background: var(--card, #161b22); border: 1px solid var(--border, #30363d); border-radius: var(--radius-md); - padding: 8px; + padding: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); flex-direction: column; - gap: 4px; - min-width: 220px; + gap: 8px; + min-width: 0; + max-height: min(72vh, 520px); + overflow: auto; + overscroll-behavior: contain; } .chat-mobile-controls-wrapper .chat-controls-dropdown.open { @@ -407,34 +434,91 @@ .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls { display: flex; flex-direction: column; - gap: 4px; + align-items: stretch; + gap: 8px; width: 100%; } + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row { + display: grid; + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); + grid-template-areas: + "session agent" + "model thinking"; + gap: 8px; + width: 100%; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row--single-agent { + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); + grid-template-areas: + "session thinking" + "model model"; + } + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session { min-width: unset; max-width: unset; width: 100%; } + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__agent { + grid-area: agent; + min-width: unset; + max-width: unset; + width: 100%; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-picker { + grid-area: session; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__model { + grid-area: model; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select { + grid-area: thinking; + min-width: 0; + max-width: none; + width: 100%; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select-full { + display: block; + width: 100%; + max-width: none; + } + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session select { width: 100%; font-size: 14px; + min-height: 44px; + padding: 10px 12px; + } + + .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__agent select { + width: 100%; + font-size: 14px; + min-height: 44px; padding: 10px 12px; } .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking { display: flex; - flex-direction: row; + flex-wrap: wrap; gap: 6px; - padding: 4px 0; - justify-content: center; + justify-content: flex-end; + width: 100%; + padding: 0; } .chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon { + width: 44px; min-width: 44px; height: 44px; } + .content { padding: 4px 4px 16px; gap: 12px; @@ -548,23 +632,34 @@ } .agent-chat__input { - margin: 0 8px 10px; + margin: 0 8px calc(10px + var(--safe-area-bottom)); } .agent-chat__toolbar { - padding: 4px 8px; + align-items: stretch; + gap: 8px; + padding: 6px 8px; + } + + .agent-chat__toolbar-left, + .agent-chat__toolbar-right { + flex-wrap: wrap; + gap: 6px; } .agent-chat__input-btn, - .agent-chat__toolbar .btn--ghost { - width: 28px; - height: 28px; + .agent-chat__toolbar .btn--ghost, + .chat-send-btn { + width: 44px; + min-width: 44px; + height: 44px; } .agent-chat__input-btn svg, - .agent-chat__toolbar .btn--ghost svg { - width: 14px; - height: 14px; + .agent-chat__toolbar .btn--ghost svg, + .chat-send-btn svg { + width: 16px; + height: 16px; } /* Log stream */ diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index fdb83310b53..fb0c1b981ba 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -48,6 +48,10 @@ function createState(overrides: Partial = {}) { applySettings: () => undefined, chatMobileControlsOpen: false, setChatMobileControlsOpen: () => undefined, + chatModelCatalog: [], + chatModelOverrides: {}, + chatModelsLoading: false, + client: { request: vi.fn() }, ...overrides, } as unknown as AppViewState; } @@ -105,12 +109,26 @@ describe("chat header controls (browser)", () => { it("renders the cron session filter in the mobile dropdown controls", async () => { const state = createState({ + sessionKey: "agent:alpha:main", + agentsList: { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Alpha" }, + { id: "beta", name: "Beta" }, + ], + }, sessionsResult: { ts: 0, path: "", - count: 2, + count: 3, defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [row({ key: "main" }), row({ key: "agent:main:cron:daily-briefing" })], + sessions: [ + row({ key: "agent:alpha:main" }), + row({ key: "agent:alpha:cron:daily-briefing" }), + row({ key: "agent:beta:cron:nightly-check" }), + ], }, }); const container = document.createElement("div"); @@ -134,6 +152,42 @@ describe("chat header controls (browser)", () => { expect(state.sessionsHideCron).toBe(false); }); + it("uses the shared chat session controls in the mobile dropdown", async () => { + const state = createState({ + sessionKey: "agent:alpha:main", + chatMobileControlsOpen: true, + agentsList: { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Alpha" }, + { id: "beta", name: "Beta" }, + ], + }, + sessionsResult: { + ts: 0, + path: "", + count: 2, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [ + row({ key: "agent:alpha:main" }), + row({ key: "agent:beta:dashboard:recent", label: "Beta recent" }), + ], + }, + }); + const container = document.createElement("div"); + render(renderChatMobileToggle(state), container); + await Promise.resolve(); + + const sessionRows = container.querySelectorAll(".chat-controls__session-row"); + expect(sessionRows).toHaveLength(1); + expect(container.querySelector('select[data-chat-agent-filter="true"]')).not.toBeNull(); + expect(container.querySelector('select[data-chat-session-select="true"]')).not.toBeNull(); + expect(container.querySelector('select[data-chat-model-select="true"]')).not.toBeNull(); + expect(container.querySelector('select[data-chat-thinking-select="true"]')).not.toBeNull(); + }); + it("renders the mobile dropdown from state instead of mutating DOM classes", async () => { const setChatMobileControlsOpen = vi.fn(); const state = createState({ diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index f0682191ef4..2992be4a0b3 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -571,7 +571,7 @@ describe("resolveSessionOptionGroups", () => { expect(labels).not.toContain("Subagent: cron-config-check"); }); - it("uses agent group labels to keep duplicate main sessions unique", () => { + it("filters the chat session options to the active agent", () => { const labels = labelsForSessionOptions({ sessionKey: "agent:alpha:main", agentsList: { @@ -593,10 +593,53 @@ describe("resolveSessionOptionGroups", () => { ], }); - expect(labels.filter((label) => label === "Deep Chat (alpha) / main")).toHaveLength(1); - expect(labels).toContain("Deep Chat (alpha) / main · named-main"); - expect(labels).toContain("Coding (beta) / main"); - expect(labels).not.toContain("main"); + expect(labels).toContain("main"); + expect(labels).toContain("Deep Chat (alpha) / main"); + expect(labels).not.toContain("Coding (beta) / main"); + }); + + it("shows sessions for the selected agent after switching agent scope", () => { + const labels = labelsForSessionOptions({ + sessionKey: "agent:beta:main", + agentsList: { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Deep Chat" }, + { id: "beta", name: "Coding" }, + ], + }, + sessions: [ + row({ key: "agent:alpha:main" }), + row({ key: "agent:beta:main" }), + row({ key: "agent:beta:dashboard:recent", label: "Bug triage" }), + ], + }); + + expect(labels).toEqual(["main", "Bug triage"]); + }); + + it("keeps bare legacy sessions scoped to the default agent only", () => { + const labels = labelsForSessionOptions({ + sessionKey: "agent:beta:main", + agentsList: { + defaultId: "alpha", + mainKey: "agent:alpha:main", + scope: "all", + agents: [ + { id: "alpha", name: "Deep Chat" }, + { id: "beta", name: "Coding" }, + ], + }, + sessions: [ + row({ key: "main", label: "Legacy main" }), + row({ key: "agent:alpha:main", label: "Alpha main" }), + row({ key: "agent:beta:main", label: "Beta main" }), + ], + }); + + expect(labels).toEqual(["Beta main"]); }); }); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index ca377befc81..468611f0efb 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -7,7 +7,6 @@ import { isCronSessionKey, parseSessionKey, renderChatSessionSelect as renderChatSessionSelectBase, - renderChatThinkingSelect, resolveSessionDisplayName, resolveSessionOptionGroups, } from "./chat/session-controls.ts"; @@ -17,7 +16,11 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { createSessionAndRefresh, loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; -import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "./session-key.ts"; +import { + normalizeAgentId, + parseAgentSessionKey, + resolveAgentIdFromSessionKey, +} from "./session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; import type { ThemeMode } from "./theme.ts"; import type { SessionsListResult } from "./types.ts"; @@ -233,9 +236,7 @@ export function renderChatSessionSelect(state: AppViewState) { export function renderChatControls(state: AppViewState) { const hideCron = state.sessionsHideCron ?? true; - const hiddenCronCount = hideCron - ? countHiddenCronSessions(state.sessionKey, state.sessionsResult) - : 0; + const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0; const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -418,7 +419,6 @@ export function renderChatControls(state: AppViewState) { * Hidden on desktop via CSS. */ export function renderChatMobileToggle(state: AppViewState) { - const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); const controlsDropdownId = "chat-mobile-controls-dropdown"; const mobileControlsOpen = state.chatMobileControlsOpen; const disableThinkingToggle = state.onboarding; @@ -427,9 +427,7 @@ export function renderChatMobileToggle(state: AppViewState) { const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; const focusActive = state.onboarding ? true : state.settings.chatFocusMode; const hideCron = state.sessionsHideCron ?? true; - const hiddenCronCount = hideCron - ? countHiddenCronSessions(state.sessionKey, state.sessionsResult) - : 0; + const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0; const toolCallsIcon = html`
- - ${renderChatThinkingSelect(state)} + ${renderChatSessionSelectBase(state, switchChatSession)}
+
+
+
+ ${ + showAgent + ? `` + : "" + } + + + +
+
+ + + + +
+
+
+
+ `; +} + +function chatHeaderControlsHtml(hidden = false) { + return ` +
+ +
+
+ `; +} + +function chatHtml(opts: { sideResult?: boolean; singleAgent?: boolean } = {}) { + return ` +
+
+
+
+ +
${chatControlsHtml({ agent: !opts.singleAgent })}
+
+
+
+
+
+
+
+
+
+
+
V
+
+
Please keep every control visible at the smallest viewport.
+
+
+
+
A
+
+
+
+

The chat shell should stay compact and readable.

+
const importantLongIdentifier = "control-ui-chat-responsive-regression-fixture-keeps-code-scrollable"; console.log(importantLongIdentifier);
+
+
+
+
+
+
+
+
+ ${ + opts.sideResult + ? `
+
+
BTWNot saved to chat history
+ +
+
What should I check next?
+

Inspect the responsive controls and keep the transcript usable.

+
` + : "" + } +
+
+ +
+
+
+ + + 8 +
+
+ + + +
+
+
+
+
+
+ `; +} + +async function openFixture( + width: number, + height: number, + opts: { sideResult?: boolean; singleAgent?: boolean } = {}, +) { + const page = await browser.newPage({ viewport: { width, height } }); + await page.setContent( + `${chatHtml(opts)}`, + ); + return page; +} + +async function openHeaderFixture(width: number, height: number, opts: { hidden?: boolean } = {}) { + const page = await browser.newPage({ viewport: { width, height } }); + await page.setContent( + `${chatHeaderControlsHtml(Boolean(opts.hidden))}`, + ); + return page; +} + +async function expectNoHorizontalOverflow(page: Page) { + const metrics = await page.evaluate(() => ({ + body: document.body.scrollWidth, + html: document.documentElement.scrollWidth, + viewport: window.innerWidth, + })); + expect(metrics.html).toBeLessThanOrEqual(metrics.viewport + 1); + expect(metrics.body).toBeLessThanOrEqual(metrics.viewport + 1); +} + +beforeAll(async () => { + browser = await chromium.launch({ headless: true }); +}); + +afterAll(async () => { + await browser.close(); +}); + +describe("chat responsive browser layout", () => { + it.each([ + [1120, 740], + [1366, 900], + [1440, 900], + ] as const)("keeps desktop chat controls in one row at %sx%s", async (width, height) => { + const page = await openHeaderFixture(width, height); + try { + await expectNoHorizontalOverflow(page); + const controls = await page.evaluate(() => { + const rectFor = (selector: string) => { + const node = document.querySelector(selector); + const rect = node?.getBoundingClientRect(); + return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null; + }; + return { + session: rectFor('[data-chat-session-select="true"]'), + agent: rectFor('[data-chat-agent-filter="true"]'), + model: rectFor('[data-chat-model-select="true"]'), + thinking: rectFor('[data-chat-thinking-select="true"]'), + action: rectFor(".page-meta .btn--icon"), + }; + }); + const rowY = [ + controls.session?.y, + controls.agent?.y, + controls.model?.y, + controls.thinking?.y, + controls.action?.y, + ].filter((value): value is number => typeof value === "number"); + expect(rowY.length).toBe(5); + expect(Math.max(...rowY) - Math.min(...rowY)).toBeLessThanOrEqual(4); + expect(controls.session!.x).toBeLessThan(controls.agent!.x); + expect(controls.session!.width / controls.agent!.width).toBeGreaterThan(1.25); + expect(controls.session!.width / controls.agent!.width).toBeLessThan(1.55); + } finally { + await page.close(); + } + }); + + it("collapses the desktop chat controls row when scroll state hides it", async () => { + const page = await openHeaderFixture(1366, 900, { hidden: true }); + try { + const hiddenState = await page.evaluate(() => { + const header = document.querySelector(".content-header") as HTMLElement | null; + const rect = header?.getBoundingClientRect(); + const style = header ? getComputedStyle(header) : null; + return { + height: rect?.height ?? -1, + opacity: style?.opacity ?? "", + pointerEvents: style?.pointerEvents ?? "", + }; + }); + expect(hiddenState.height).toBeLessThanOrEqual(1); + expect(hiddenState.opacity).toBe("0"); + expect(hiddenState.pointerEvents).toBe("none"); + } finally { + await page.close(); + } + }); + + it.each(VIEWPORTS)("keeps the chat shell inside the viewport at %sx%s", async (width, height) => { + const page = await openFixture(width, height); + try { + await expectNoHorizontalOverflow(page); + const code = await page.locator(".chat-text pre").boundingBox(); + expect(code).not.toBeNull(); + expect(code!.x + code!.width).toBeLessThanOrEqual(width + 1); + } finally { + await page.close(); + } + }); + + it.each(["dark", "light"] as const)( + "keeps mobile controls inside the viewport with touch targets in %s mode", + async (themeMode) => { + const page = await openFixture(320, 568); + try { + await page.evaluate( + (mode) => document.documentElement.setAttribute("data-theme-mode", mode), + themeMode, + ); + const dropdown = await page.locator(".chat-controls-dropdown.open").boundingBox(); + expect(dropdown).not.toBeNull(); + expect(dropdown!.x).toBeGreaterThanOrEqual(8); + expect(dropdown!.x + dropdown!.width).toBeLessThanOrEqual(312); + await expectNoHorizontalOverflow(page); + const mobileControls = await page.evaluate(() => { + const rectFor = (selector: string) => { + const node = document.querySelector(selector) as HTMLSelectElement | null; + if (!node) { + return null; + } + const rect = node.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + text: node.options[node.selectedIndex]?.textContent?.trim() ?? "", + display: getComputedStyle(node).display, + }; + }; + return { + agent: rectFor('[data-chat-agent-filter="true"]'), + session: rectFor('[data-chat-session-select="true"]'), + thinkingFull: rectFor('[data-chat-thinking-select="true"]'), + compactCount: document.querySelectorAll('[data-chat-thinking-select-compact="true"]') + .length, + }; + }); + expect(mobileControls.agent).not.toBeNull(); + expect(mobileControls.session).not.toBeNull(); + expect(mobileControls.session!.y).toBe(mobileControls.agent!.y); + expect(mobileControls.session!.x).toBeLessThan(mobileControls.agent!.x); + expect(mobileControls.session!.width / mobileControls.agent!.width).toBeGreaterThan(1.25); + expect(mobileControls.session!.width / mobileControls.agent!.width).toBeLessThan(1.55); + expect(mobileControls.thinkingFull?.display).not.toBe("none"); + expect(mobileControls.thinkingFull?.text).toBe("Default (high)"); + expect(mobileControls.compactCount).toBe(0); + + const sizes = await page + .locator(".chat-controls-mobile-toggle, .chat-controls-dropdown .btn--icon") + .evaluateAll((nodes) => + nodes.map((node) => { + const rect = (node as HTMLElement).getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(size.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + expect(size.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + } + } finally { + await page.close(); + } + }, + ); + + it("keeps composer actions touch-sized on phones", async () => { + const page = await openFixture(320, 568); + try { + const sizes = await page + .locator(".agent-chat__input-btn, .agent-chat__toolbar .btn--ghost, .chat-send-btn") + .evaluateAll((nodes) => + nodes.map((node) => { + const rect = (node as HTMLElement).getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(size.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + expect(size.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX); + } + } 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 { + await expectNoHorizontalOverflow(page); + expect(await page.locator('[data-chat-agent-filter="true"]').count()).toBe(0); + const session = await page.locator('[data-chat-session-select="true"]').boundingBox(); + const model = await page.locator('[data-chat-model-select="true"]').boundingBox(); + const thinking = await page.locator('[data-chat-thinking-select="true"]').boundingBox(); + expect(session).not.toBeNull(); + expect(model).not.toBeNull(); + expect(thinking).not.toBeNull(); + expect(thinking!.x).toBeGreaterThan(session!.x); + expect(model!.y).toBeGreaterThan(session!.y); + expect(model!.width).toBeGreaterThan(session!.width); + } finally { + await page.close(); + } + }); + + it("renders BTW side results as a mobile overlay without horizontal overflow", async () => { + const page = await openFixture(320, 568, { sideResult: true }); + try { + await expectNoHorizontalOverflow(page); + const position = await page + .locator(".chat-side-result") + .evaluate((node) => getComputedStyle(node).position); + expect(position).toBe("fixed"); + } finally { + await page.close(); + } + }); +}); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 0a607c9aabf..25c91472612 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -133,6 +133,31 @@ function renderAssistantMessages( ); } +function renderAssistantMessageEntries( + container: HTMLElement, + entries: MessageGroup["messages"], + opts: Partial = {}, +) { + const group: MessageGroup = { + kind: "group", + key: "assistant-group", + role: "assistant", + messages: entries, + timestamp: Date.now(), + isStreaming: false, + }; + render( + renderMessageGroup(group, { + showReasoning: true, + showToolCalls: true, + assistantName: "OpenClaw", + assistantAvatar: null, + ...opts, + }), + container, + ); +} + function renderGroupedMessage( container: HTMLElement, message: unknown, @@ -366,6 +391,25 @@ afterEach(() => { }); describe("grouped chat rendering", () => { + it("renders a compact count for collapsed duplicate messages", () => { + const container = document.createElement("div"); + renderAssistantMessageEntries(container, [ + { + key: "assistant-heartbeat", + message: { + role: "assistant", + content: [{ type: "text", text: "HEARTBEAT_OK" }], + timestamp: 1, + }, + duplicateCount: 4, + }, + ]); + + const badge = container.querySelector(".chat-duplicate-count"); + expect(badge?.textContent?.trim()).toBe("×4"); + expect(badge?.getAttribute("aria-label")).toBe("4 consecutive identical messages collapsed"); + }); + it("does not render the stale assistant read-aloud footer action", () => { const container = document.createElement("div"); renderAssistantMessage(container, { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7d565841f6d..581e66fd05e 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -447,6 +447,7 @@ export function renderMessageGroup( item.key, { isStreaming: group.isStreaming && index === group.messages.length - 1, + duplicateCount: item.duplicateCount ?? 1, showReasoning: opts.showReasoning, showToolCalls: opts.showToolCalls ?? true, autoExpandToolCalls: opts.autoExpandToolCalls ?? false, @@ -1371,6 +1372,7 @@ function renderGroupedMessage( messageKey: string, opts: { isStreaming: boolean; + duplicateCount?: number; showReasoning: boolean; showToolCalls?: boolean; autoExpandToolCalls?: boolean; @@ -1478,6 +1480,7 @@ function renderGroupedMessage( : "Tool output"; const hasActions = canCopyMarkdown || canExpand; + const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1)); return html`
@@ -1617,6 +1620,15 @@ function renderGroupedMessage( }) : nothing} `} + ${duplicateCount > 1 + ? html`
+ ×${duplicateCount} +
` + : nothing}
`; } diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index fa098a19aaa..119d922b5f0 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -9,7 +9,11 @@ import { import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts"; import { loadSessions } from "../controllers/sessions.ts"; import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; -import { parseAgentSessionKey } from "../session-key.ts"; +import { + buildAgentMainSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; import { listThinkingLevelLabels, @@ -25,15 +29,25 @@ export function renderChatSessionSelect( onSwitchSession: ChatSessionSwitchHandler = () => undefined, ) { const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); + const agentOptions = resolveChatAgentFilterOptions(state); + const hasAgentSelect = agentOptions.length > 1; + const agentSelect = renderChatAgentSelect(state, onSwitchSession, agentOptions); const modelSelect = renderChatModelSelect(state); const thinkingSelect = renderChatThinkingSelect(state); const selectedSessionLabel = sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey) ?.label ?? state.sessionKey; return html` -
-