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
diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts
index c975c4631..78a8b41cc 100644
--- a/ui/src/ui/views/config.browser.test.ts
+++ b/ui/src/ui/views/config.browser.test.ts
@@ -17,7 +17,9 @@ describe("config view", () => {
schema: {
type: "object",
properties: {
- mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
+ mixed: {
+ anyOf: [{ type: "string" }, { type: "object", properties: {} }],
+ },
},
},
schemaLoading: false,