feat(control-ui): add chat focus mode

This commit is contained in:
Peter Steinberger
2026-01-06 08:16:09 +01:00
parent 173e9f103e
commit 882048d90b
10 changed files with 197 additions and 15 deletions

View File

@@ -55,6 +55,7 @@
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - 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: 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: 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 runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth). - Status: show model auth source (api-key/oauth).
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.

View File

@@ -200,6 +200,11 @@
background: rgba(245, 159, 74, 0.2); 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 { .btn.danger {
border-color: rgba(255, 107, 107, 0.45); border-color: rgba(255, 107, 107, 0.45);
background: rgba(255, 107, 107, 0.18); background: rgba(255, 107, 107, 0.18);

View File

@@ -1,16 +1,28 @@
.shell { .shell {
--shell-pad: 18px; --shell-pad: 18px;
--shell-gap: 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; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-columns: var(--shell-nav-col) minmax(0, 1fr);
grid-template-rows: auto 1fr; grid-template-rows: var(--shell-topbar-row) 1fr;
grid-template-areas: grid-template-areas:
"topbar topbar" "topbar topbar"
"nav content"; "nav content";
gap: var(--shell-gap); gap: var(--shell-gap);
padding: var(--shell-pad); padding: var(--shell-pad);
animation: dashboard-enter 0.6s ease-out; 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 { .topbar {
@@ -27,6 +39,23 @@
background: linear-gradient(135deg, var(--chrome), rgba(255, 255, 255, 0.02)); background: linear-gradient(135deg, var(--chrome), rgba(255, 255, 255, 0.02));
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); 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 { .brand {
@@ -72,6 +101,23 @@
background: var(--panel); background: var(--panel);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px); 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 { .nav-group {
@@ -163,6 +209,21 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 0 6px; 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 { .page-title {
@@ -229,6 +290,7 @@
.shell { .shell {
--shell-pad: 12px; --shell-pad: 12px;
--shell-gap: 12px; --shell-gap: 12px;
--shell-nav-col: 1fr;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
grid-template-areas: grid-template-areas:

View File

@@ -185,9 +185,10 @@ export function renderApp(state: AppViewState) {
const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
const isChat = state.tab === "chat"; const isChat = state.tab === "chat";
const chatFocus = isChat && state.settings.chatFocusMode;
return html` return html`
<div class="shell ${isChat ? "shell--chat" : ""}"> <div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""}">
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<div class="brand-title">Clawdbot Control</div> <div class="brand-title">Clawdbot Control</div>
@@ -398,10 +399,16 @@ export function renderApp(state: AppViewState) {
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
error: state.lastError, error: state.lastError,
sessions: state.sessionsResult, sessions: state.sessionsResult,
focusMode: state.settings.chatFocusMode,
onRefresh: () => { onRefresh: () => {
state.resetToolStream(); state.resetToolStream();
return loadChatHistory(state); return loadChatHistory(state);
}, },
onToggleFocusMode: () =>
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
}),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
}) })

View File

@@ -70,10 +70,19 @@ describe("config form renderer", () => {
); );
const select = container.querySelector("select") as HTMLSelectElement | null; const select = container.querySelector("select") as HTMLSelectElement | null;
expect(select).not.toBeNull(); const selects = Array.from(container.querySelectorAll("select"));
if (!select) return; const modeSelect = selects.find((el) =>
select.value = "1"; Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"),
select.dispatchEvent(new Event("change", { bubbles: true })); ) 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"); expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector( const checkbox = container.querySelector(
@@ -133,11 +142,16 @@ describe("config form renderer", () => {
const selects = Array.from(container.querySelectorAll("select")); const selects = Array.from(container.querySelectorAll("select"));
const bindSelect = selects.find((el) => 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; ) as HTMLSelectElement | undefined;
expect(bindSelect).not.toBeUndefined(); expect(bindSelect).not.toBeUndefined();
if (!bindSelect) return; 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 })); bindSelect.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
}); });
@@ -181,7 +195,7 @@ describe("config form renderer", () => {
type: "object", type: "object",
properties: { properties: {
mixed: { mixed: {
anyOf: [{ type: "string" }, { type: "number" }], anyOf: [{ type: "string" }, { type: "object", properties: {} }],
}, },
}, },
}; };

View File

@@ -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<HTMLButtonElement>(
'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<HTMLAnchorElement>('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<HTMLAnchorElement>('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);
});
});

View File

@@ -22,12 +22,14 @@ beforeEach(() => {
// no-op: avoid real gateway WS connections in browser tests // no-op: avoid real gateway WS connections in browser tests
}; };
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = ""; document.body.innerHTML = "";
}); });
afterEach(() => { afterEach(() => {
ClawdbotApp.prototype.connect = originalConnect; ClawdbotApp.prototype.connect = originalConnect;
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined; window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = ""; document.body.innerHTML = "";
}); });
@@ -102,13 +104,19 @@ describe("control UI routing", () => {
})); }));
await app.updateComplete; await app.updateComplete;
await nextFrame(); for (let i = 0; i < 6; i++) {
await nextFrame();
}
const container = app.querySelector(".chat-thread") as HTMLElement | null; const container = app.querySelector(".chat-thread") as HTMLElement | null;
expect(container).not.toBeNull(); expect(container).not.toBeNull();
if (!container) return; if (!container) return;
const maxScroll = container.scrollHeight - container.clientHeight; const maxScroll = container.scrollHeight - container.clientHeight;
expect(maxScroll).toBeGreaterThan(0); expect(maxScroll).toBeGreaterThan(0);
for (let i = 0; i < 10; i++) {
if (container.scrollTop === maxScroll) break;
await nextFrame();
}
expect(container.scrollTop).toBe(maxScroll); expect(container.scrollTop).toBe(maxScroll);
}); });

View File

@@ -7,6 +7,7 @@ export type UiSettings = {
token: string; token: string;
sessionKey: string; sessionKey: string;
theme: ThemeMode; theme: ThemeMode;
chatFocusMode: boolean;
}; };
export function loadSettings(): UiSettings { export function loadSettings(): UiSettings {
@@ -20,6 +21,7 @@ export function loadSettings(): UiSettings {
token: "", token: "",
sessionKey: "main", sessionKey: "main",
theme: "system", theme: "system",
chatFocusMode: false,
}; };
try { try {
@@ -42,6 +44,10 @@ export function loadSettings(): UiSettings {
parsed.theme === "system" parsed.theme === "system"
? parsed.theme ? parsed.theme
: defaults.theme, : defaults.theme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
: defaults.chatFocusMode,
}; };
} catch { } catch {
return defaults; return defaults;

View File

@@ -22,13 +22,14 @@ export type ChatProps = {
disabledReason: string | null; disabledReason: string | null;
error: string | null; error: string | null;
sessions: SessionsListResult | null; sessions: SessionsListResult | null;
focusMode: boolean;
onRefresh: () => void; onRefresh: () => void;
onToggleFocusMode: () => void;
onDraftChange: (next: string) => void; onDraftChange: (next: string) => void;
onSend: () => void; onSend: () => void;
}; };
export function renderChat(props: ChatProps) { export function renderChat(props: ChatProps) {
const canInteract = props.connected;
const canCompose = props.connected && !props.sending; const canCompose = props.connected && !props.sending;
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
const composePlaceholder = props.connected const composePlaceholder = props.connected
@@ -43,7 +44,7 @@ export function renderChat(props: ChatProps) {
<span>Session Key</span> <span>Session Key</span>
<select <select
.value=${props.sessionKey} .value=${props.sessionKey}
?disabled=${!canInteract} ?disabled=${!props.connected}
@change=${(e: Event) => @change=${(e: Event) =>
props.onSessionKeyChange((e.target as HTMLSelectElement).value)} props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
> >
@@ -57,7 +58,7 @@ export function renderChat(props: ChatProps) {
</label> </label>
<button <button
class="btn" class="btn"
?disabled=${props.loading || !canInteract} ?disabled=${props.loading || !props.connected}
@click=${props.onRefresh} @click=${props.onRefresh}
> >
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
@@ -65,6 +66,14 @@ export function renderChat(props: ChatProps) {
</div> </div>
<div class="chat-header__right"> <div class="chat-header__right">
<div class="muted">Thinking: ${props.thinkingLevel ?? "inherit"}</div> <div class="muted">Thinking: ${props.thinkingLevel ?? "inherit"}</div>
<button
class="btn ${props.focusMode ? "active" : ""}"
@click=${props.onToggleFocusMode}
aria-pressed=${props.focusMode}
title="Toggle focus mode (hide header + sidebar)"
>
Focus
</button>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,9 @@ describe("config view", () => {
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
mixed: { anyOf: [{ type: "string" }, { type: "number" }] }, mixed: {
anyOf: [{ type: "string" }, { type: "object", properties: {} }],
},
}, },
}, },
schemaLoading: false, schemaLoading: false,