From 882048d90b94b6d192bee6a4cecacdf58ac69275 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 08:16:09 +0100 Subject: [PATCH] feat(control-ui): add chat focus mode --- CHANGELOG.md | 1 + ui/src/styles/components.css | 5 ++ ui/src/styles/layout.css | 66 ++++++++++++++++++++++++- ui/src/ui/app-render.ts | 9 +++- ui/src/ui/config-form.browser.test.ts | 28 ++++++++--- ui/src/ui/focus-mode.browser.test.ts | 68 ++++++++++++++++++++++++++ ui/src/ui/navigation.browser.test.ts | 10 +++- ui/src/ui/storage.ts | 6 +++ ui/src/ui/views/chat.ts | 15 ++++-- ui/src/ui/views/config.browser.test.ts | 4 +- 10 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 ui/src/ui/focus-mode.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eaec5981c..d16843996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. +- Control UI: add Chat focus mode toggle to collapse header + sidebar. - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 12dbdbec9..f47b34450 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -200,6 +200,11 @@ background: rgba(245, 159, 74, 0.2); } +.btn.active { + border-color: rgba(245, 159, 74, 0.55); + background: rgba(245, 159, 74, 0.16); +} + .btn.danger { border-color: rgba(255, 107, 107, 0.45); background: rgba(255, 107, 107, 0.18); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b311c1447..15da2ae4a 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1,16 +1,28 @@ .shell { --shell-pad: 18px; --shell-gap: 18px; + --shell-nav-col: minmax(220px, 280px); + --shell-topbar-row: auto; + --shell-focus-duration: 220ms; + --shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1); min-height: 100vh; display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - grid-template-rows: auto 1fr; + grid-template-columns: var(--shell-nav-col) minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-row) 1fr; grid-template-areas: "topbar topbar" "nav content"; gap: var(--shell-gap); padding: var(--shell-pad); animation: dashboard-enter 0.6s ease-out; + transition: padding var(--shell-focus-duration) var(--shell-focus-ease); +} + +.shell--chat-focus { + --shell-pad: 10px; + --shell-gap: 12px; + --shell-nav-col: 0px; + --shell-topbar-row: 0px; } .topbar { @@ -27,6 +39,23 @@ background: linear-gradient(135deg, var(--chrome), rgba(255, 255, 255, 0.02)); backdrop-filter: blur(18px); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + overflow: hidden; + transform-origin: top center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-height var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease), + border-width var(--shell-focus-duration) var(--shell-focus-ease); + max-height: max(0px, var(--topbar-height, 92px)); +} + +.shell--chat-focus .topbar { + opacity: 0; + transform: translateY(-10px); + max-height: 0px; + padding: 0; + border-width: 0; + pointer-events: none; } .brand { @@ -72,6 +101,23 @@ background: var(--panel); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); + transform-origin: left center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-width var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease), + border-width var(--shell-focus-duration) var(--shell-focus-ease); + max-width: 320px; +} + +.shell--chat-focus .nav { + opacity: 0; + transform: translateX(-12px); + max-width: 0px; + padding: 0; + border-width: 0; + overflow: hidden; + pointer-events: none; } .nav-group { @@ -163,6 +209,21 @@ justify-content: space-between; gap: 12px; padding: 0 6px; + overflow: hidden; + transform-origin: top center; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-height var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease); + max-height: 90px; +} + +.shell--chat-focus .content-header { + opacity: 0; + transform: translateY(-10px); + max-height: 0px; + padding: 0; + pointer-events: none; } .page-title { @@ -229,6 +290,7 @@ .shell { --shell-pad: 12px; --shell-gap: 12px; + --shell-nav-col: 1fr; grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; grid-template-areas: diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index de9654498..eb2d48cc8 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -185,9 +185,10 @@ export function renderApp(state: AppViewState) { const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const isChat = state.tab === "chat"; + const chatFocus = isChat && state.settings.chatFocusMode; return html` -
+
Clawdbot Control
@@ -398,10 +399,16 @@ export function renderApp(state: AppViewState) { disabledReason: chatDisabledReason, error: state.lastError, sessions: state.sessionsResult, + focusMode: state.settings.chatFocusMode, onRefresh: () => { state.resetToolStream(); return loadChatHistory(state); }, + onToggleFocusMode: () => + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }), onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), }) diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 2236a21b7..8d012e488 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -70,10 +70,19 @@ describe("config form renderer", () => { ); const select = container.querySelector("select") as HTMLSelectElement | null; - expect(select).not.toBeNull(); - if (!select) return; - select.value = "1"; - select.dispatchEvent(new Event("change", { bubbles: true })); + const selects = Array.from(container.querySelectorAll("select")); + const modeSelect = selects.find((el) => + Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"), + ) as HTMLSelectElement | undefined; + expect(modeSelect).not.toBeUndefined(); + if (!modeSelect) return; + const tokenOption = Array.from(modeSelect.options).find( + (opt) => opt.textContent?.trim() === "token", + ); + expect(tokenOption).not.toBeUndefined(); + if (!tokenOption) return; + modeSelect.value = tokenOption.value; + modeSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); const checkbox = container.querySelector( @@ -133,11 +142,16 @@ describe("config form renderer", () => { const selects = Array.from(container.querySelectorAll("select")); const bindSelect = selects.find((el) => - Array.from(el.options).some((opt) => opt.value === "tailnet"), + Array.from(el.options).some((opt) => opt.textContent?.trim() === "tailnet"), ) as HTMLSelectElement | undefined; expect(bindSelect).not.toBeUndefined(); if (!bindSelect) return; - bindSelect.value = "tailnet"; + const tailnetOption = Array.from(bindSelect.options).find( + (opt) => opt.textContent?.trim() === "tailnet", + ); + expect(tailnetOption).not.toBeUndefined(); + if (!tailnetOption) return; + bindSelect.value = tailnetOption.value; bindSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); @@ -181,7 +195,7 @@ describe("config form renderer", () => { type: "object", properties: { mixed: { - anyOf: [{ type: "string" }, { type: "number" }], + anyOf: [{ type: "string" }, { type: "object", properties: {} }], }, }, }; diff --git a/ui/src/ui/focus-mode.browser.test.ts b/ui/src/ui/focus-mode.browser.test.ts new file mode 100644 index 000000000..334dde30e --- /dev/null +++ b/ui/src/ui/focus-mode.browser.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ClawdbotApp } from "./app"; + +const originalConnect = ClawdbotApp.prototype.connect; + +function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("clawdbot-app") as ClawdbotApp; + document.body.append(app); + return app; +} + +beforeEach(() => { + ClawdbotApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +afterEach(() => { + ClawdbotApp.prototype.connect = originalConnect; + window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +describe("chat focus mode", () => { + it("collapses header + sidebar on chat tab only", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const shell = app.querySelector(".shell"); + expect(shell).not.toBeNull(); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const toggle = app.querySelector( + 'button[title^="Toggle focus mode"]', + ); + expect(toggle).not.toBeNull(); + toggle?.click(); + + await app.updateComplete; + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + + const link = app.querySelector('a.nav-item[href="/connections"]'); + expect(link).not.toBeNull(); + link?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); + + await app.updateComplete; + expect(app.tab).toBe("connections"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const chatLink = app.querySelector('a.nav-item[href="/chat"]'); + chatLink?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); + + await app.updateComplete; + expect(app.tab).toBe("chat"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + }); +}); + diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index f7b522b4c..6c3b68b0c 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -22,12 +22,14 @@ beforeEach(() => { // no-op: avoid real gateway WS connections in browser tests }; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); document.body.innerHTML = ""; }); afterEach(() => { ClawdbotApp.prototype.connect = originalConnect; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); document.body.innerHTML = ""; }); @@ -102,13 +104,19 @@ describe("control UI routing", () => { })); await app.updateComplete; - await nextFrame(); + for (let i = 0; i < 6; i++) { + await nextFrame(); + } const container = app.querySelector(".chat-thread") as HTMLElement | null; expect(container).not.toBeNull(); if (!container) return; const maxScroll = container.scrollHeight - container.clientHeight; expect(maxScroll).toBeGreaterThan(0); + for (let i = 0; i < 10; i++) { + if (container.scrollTop === maxScroll) break; + await nextFrame(); + } expect(container.scrollTop).toBe(maxScroll); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 2cb066ace..b77dd96d4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -7,6 +7,7 @@ export type UiSettings = { token: string; sessionKey: string; theme: ThemeMode; + chatFocusMode: boolean; }; export function loadSettings(): UiSettings { @@ -20,6 +21,7 @@ export function loadSettings(): UiSettings { token: "", sessionKey: "main", theme: "system", + chatFocusMode: false, }; try { @@ -42,6 +44,10 @@ export function loadSettings(): UiSettings { parsed.theme === "system" ? parsed.theme : defaults.theme, + chatFocusMode: + typeof parsed.chatFocusMode === "boolean" + ? parsed.chatFocusMode + : defaults.chatFocusMode, }; } catch { return defaults; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd20de44b..59d9fc1aa 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -22,13 +22,14 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; + focusMode: boolean; onRefresh: () => void; + onToggleFocusMode: () => void; onDraftChange: (next: string) => void; onSend: () => void; }; export function renderChat(props: ChatProps) { - const canInteract = props.connected; const canCompose = props.connected && !props.sending; const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const composePlaceholder = props.connected @@ -43,7 +44,7 @@ export function renderChat(props: ChatProps) { Session Key