fix(ui): landing cleanup (#475) (thanks @rahthakor)

This commit is contained in:
Peter Steinberger
2026-01-09 19:53:32 +01:00
parent 9624d70187
commit 067c20608c
10 changed files with 87 additions and 66 deletions

View File

@@ -68,6 +68,7 @@
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c - Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat. - Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow). - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { import {
bashTool, bashTool,
@@ -50,6 +50,16 @@ beforeEach(() => {
}); });
describe("bash tool backgrounding", () => { describe("bash tool backgrounding", () => {
const originalShell = process.env.SHELL;
beforeEach(() => {
if (!isWin) process.env.SHELL = "/bin/bash";
});
afterEach(() => {
if (!isWin) process.env.SHELL = originalShell;
});
it( it(
"backgrounds after yield and can be polled", "backgrounds after yield and can be polled",
async () => { async () => {
@@ -171,9 +181,7 @@ describe("bash tool backgrounding", () => {
expect(text).toContain("hi"); expect(text).toContain("hi");
}); });
// Skip: Fails when user's shell config (.zshenv) sources files that don't exist in test env, it("logs line-based slices and defaults to last lines", async () => {
// adding extra lines to stdout and breaking line count assertions.
it.skip("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", { const result = await bashTool.execute("call1", {
command: echoLines(["one", "two", "three"]), command: echoLines(["one", "two", "three"]),
background: true, background: true,
@@ -193,9 +201,7 @@ describe("bash tool backgrounding", () => {
expect(status).toBe("completed"); expect(status).toBe("completed");
}); });
// Skip: Fails when user's shell config (.zshenv) sources files that don't exist in test env, it("supports line offsets for log slices", async () => {
// adding extra lines to stdout and breaking offset assertions.
it.skip("supports line offsets for log slices", async () => {
const result = await bashTool.execute("call1", { const result = await bashTool.execute("call1", {
command: echoLines(["alpha", "beta", "gamma"]), command: echoLines(["alpha", "beta", "gamma"]),
background: true, background: true,

View File

@@ -6,13 +6,9 @@
<title>Clawdbot Control</title> <title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<!-- Flaticon Uicons - Free icon font -->
<link rel="stylesheet" href="https://cdn-uicons.flaticon.com/2.6.0/uicons-regular-rounded/css/uicons-regular-rounded.css" />
<link rel="stylesheet" href="https://cdn-uicons.flaticon.com/2.6.0/uicons-solid-rounded/css/uicons-solid-rounded.css" />
</head> </head>
<body> <body>
<clawdbot-app></clawdbot-app> <clawdbot-app></clawdbot-app>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,6 @@
--shell-pad: 16px; --shell-pad: 16px;
--shell-gap: 16px; --shell-gap: 16px;
--shell-nav-width: 220px; --shell-nav-width: 220px;
--shell-nav-collapsed-width: 56px;
--shell-topbar-height: 56px; --shell-topbar-height: 56px;
--shell-focus-duration: 220ms; --shell-focus-duration: 220ms;
--shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1); --shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1);
@@ -19,7 +18,7 @@
} }
.shell--nav-collapsed { .shell--nav-collapsed {
grid-template-columns: var(--shell-nav-collapsed-width) minmax(0, 1fr); grid-template-columns: 0px minmax(0, 1fr);
} }
.shell--chat-focus { .shell--chat-focus {
@@ -298,13 +297,12 @@
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
min-height: 0; min-height: 0;
height: calc(100vh - var(--shell-pad) * 2 - var(--topbar-height, 80px) - var(--shell-gap));
overflow-y: auto; /* Enable vertical scrolling for pages with long content */ overflow-y: auto; /* Enable vertical scrolling for pages with long content */
overflow-x: hidden; overflow-x: hidden;
} }
.shell--chat .content { .shell--chat .content {
height: calc(100vh - var(--shell-pad) * 2 - var(--topbar-height, 80px) - var(--shell-gap)); /* No-op: keep chat layout consistent with other tabs */
} }
.docs-link { .docs-link {

View File

@@ -3,7 +3,7 @@ import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { import {
TAB_GROUPS, TAB_GROUPS,
iconClassForTab, iconForTab,
pathForTab, pathForTab,
subtitleForTab, subtitleForTab,
titleForTab, titleForTab,
@@ -608,7 +608,7 @@ function renderTab(state: AppViewState, tab: Tab) {
}} }}
title=${titleForTab(tab)} title=${titleForTab(tab)}
> >
<i class="nav-item__icon ${iconClassForTab(tab)}"></i> <span class="nav-item__icon" aria-hidden="true">${iconForTab(tab)}</span>
<span class="nav-item__text">${titleForTab(tab)}</span> <span class="nav-item__text">${titleForTab(tab)}</span>
</a> </a>
`; `;
@@ -620,8 +620,9 @@ function renderChatControls(state: AppViewState) {
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`; const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
// Icon for grouped view // Icon for grouped view
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`; const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
// Refresh icon (Flaticon style) // Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`; const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
return html` return html`
<div class="chat-controls"> <div class="chat-controls">
<label class="field chat-controls__session"> <label class="field chat-controls__session">
@@ -637,7 +638,11 @@ function renderChatControls(state: AppViewState) {
state.chatRunId = null; state.chatRunId = null;
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state); void loadChatHistory(state);
}} }}
> >
@@ -661,6 +666,18 @@ function renderChatControls(state: AppViewState) {
${refreshIcon} ${refreshIcon}
</button> </button>
<span class="chat-controls__separator">|</span> <span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
})}
aria-pressed=${state.settings.chatFocusMode}
title="Toggle focus mode (hide sidebar + page header)"
>
${focusIcon}
</button>
<button <button
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}" class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() => @click=${() =>

View File

@@ -205,6 +205,7 @@ export class ClawdbotApp extends LitElement {
@state() eventLog: EventLogEntry[] = []; @state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = []; private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null; private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null;
@state() sessionKey = this.settings.sessionKey; @state() sessionKey = this.settings.sessionKey;
@state() chatLoading = false; @state() chatLoading = false;
@@ -1156,6 +1157,10 @@ export class ClawdbotApp extends LitElement {
// Sidebar handlers for tool output viewing // Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) { handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {
window.clearTimeout(this.sidebarCloseTimer);
this.sidebarCloseTimer = null;
}
this.sidebarContent = content; this.sidebarContent = content;
this.sidebarError = null; this.sidebarError = null;
this.sidebarOpen = true; this.sidebarOpen = true;
@@ -1164,9 +1169,14 @@ export class ClawdbotApp extends LitElement {
handleCloseSidebar() { handleCloseSidebar() {
this.sidebarOpen = false; this.sidebarOpen = false;
// Clear content after transition // Clear content after transition
setTimeout(() => { if (this.sidebarCloseTimer != null) {
window.clearTimeout(this.sidebarCloseTimer);
}
this.sidebarCloseTimer = window.setTimeout(() => {
if (this.sidebarOpen) return;
this.sidebarContent = null; this.sidebarContent = null;
this.sidebarError = null; this.sidebarError = null;
this.sidebarCloseTimer = null;
}, 200); }, 200);
} }

View File

@@ -16,21 +16,24 @@ 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 = "";
}); });
describe("chat markdown rendering", () => { describe("chat markdown rendering", () => {
// Skip: Tool card rendering was refactored to use sidebar-based output display. it("renders markdown inside tool result cards", async () => {
// The .chat-tool-card__output class is only in the legacy renderer and requires localStorage.setItem(
// the <details> element to be expanded. New layout uses renderToolCard() which "clawdbot.control.settings.v1",
// shows preview/inline text without the __output wrapper. JSON.stringify({ useNewChatLayout: false }),
it.skip("renders markdown inside tool result cards", async () => { );
const app = mountApp("/chat"); const app = mountApp("/chat");
await app.updateComplete; await app.updateComplete;

View File

@@ -28,10 +28,7 @@ afterEach(() => {
}); });
describe("chat focus mode", () => { describe("chat focus mode", () => {
// Skip: Focus mode toggle button was moved to settings panel, no longer in chat view. it("collapses header + sidebar on chat tab only", async () => {
// The shell--chat-focus class still works when settings.chatFocusMode is true,
// but there's no in-chat toggle button to test.
it.skip("collapses header + sidebar on chat tab only", async () => {
const app = mountApp("/chat"); const app = mountApp("/chat");
await app.updateComplete; await app.updateComplete;
@@ -68,4 +65,3 @@ describe("chat focus mode", () => {
expect(shell?.classList.contains("shell--chat-focus")).toBe(true); expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
}); });
}); });

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
TAB_GROUPS, TAB_GROUPS,
iconClassForTab, iconForTab,
inferBasePathFromPathname, inferBasePathFromPathname,
normalizeBasePath, normalizeBasePath,
normalizePath, normalizePath,
@@ -16,33 +16,34 @@ import {
/** All valid tab identifiers derived from TAB_GROUPS */ /** All valid tab identifiers derived from TAB_GROUPS */
const ALL_TABS: Tab[] = TAB_GROUPS.flatMap((group) => group.tabs) as Tab[]; const ALL_TABS: Tab[] = TAB_GROUPS.flatMap((group) => group.tabs) as Tab[];
describe("iconClassForTab", () => { describe("iconForTab", () => {
it("returns a non-empty string for every tab", () => { it("returns a non-empty string for every tab", () => {
for (const tab of ALL_TABS) { for (const tab of ALL_TABS) {
const icon = iconClassForTab(tab); const icon = iconForTab(tab);
expect(icon).toBeTruthy(); expect(icon).toBeTruthy();
expect(typeof icon).toBe("string"); expect(typeof icon).toBe("string");
expect(icon.length).toBeGreaterThan(0); expect(icon.length).toBeGreaterThan(0);
} }
}); });
it("returns expected icon classes for each tab", () => { it("returns stable icons for known tabs", () => {
expect(iconClassForTab("chat")).toBe("fi fi-rr-comment"); expect(iconForTab("chat")).toBe("💬");
expect(iconClassForTab("overview")).toBe("fi fi-rr-chart-histogram"); expect(iconForTab("overview")).toBe("📊");
expect(iconClassForTab("connections")).toBe("fi fi-rr-link"); expect(iconForTab("connections")).toBe("🔗");
expect(iconClassForTab("instances")).toBe("fi fi-rr-radar"); expect(iconForTab("instances")).toBe("📡");
expect(iconClassForTab("sessions")).toBe("fi fi-rr-document"); expect(iconForTab("sessions")).toBe("📄");
expect(iconClassForTab("cron")).toBe("fi fi-rr-clock"); expect(iconForTab("cron")).toBe("");
expect(iconClassForTab("skills")).toBe("fi fi-rr-bolt"); expect(iconForTab("skills")).toBe("⚡️");
expect(iconClassForTab("nodes")).toBe("fi fi-rr-computer"); expect(iconForTab("nodes")).toBe("🖥️");
expect(iconClassForTab("config")).toBe("fi fi-rr-settings"); expect(iconForTab("config")).toBe("⚙️");
expect(iconClassForTab("debug")).toBe("fi fi-rr-bug"); expect(iconForTab("debug")).toBe("🐞");
expect(iconForTab("logs")).toBe("🧾");
}); });
it("returns fallback icon class for unknown tab", () => { it("returns a fallback icon for unknown tab", () => {
// TypeScript won't allow this normally, but runtime could receive unexpected values // TypeScript won't allow this normally, but runtime could receive unexpected values
const unknownTab = "unknown" as Tab; const unknownTab = "unknown" as Tab;
expect(iconClassForTab(unknownTab)).toBe("fi fi-rr-file"); expect(iconForTab(unknownTab)).toBe("📁");
}); });
}); });

View File

@@ -98,42 +98,35 @@ export function inferBasePathFromPathname(pathname: string): string {
return `/${segments.join("/")}`; return `/${segments.join("/")}`;
} }
/** Returns the Flaticon uicons class for a tab icon */ export function iconForTab(tab: Tab): string {
export function iconClassForTab(tab: Tab): string {
switch (tab) { switch (tab) {
case "chat": case "chat":
return "fi fi-rr-comment"; // chat bubble return "💬";
case "overview": case "overview":
return "fi fi-rr-chart-histogram"; // bar chart return "📊";
case "connections": case "connections":
return "fi fi-rr-link"; // link return "🔗";
case "instances": case "instances":
return "fi fi-rr-radar"; // radar/satellite return "📡";
case "sessions": case "sessions":
return "fi fi-rr-document"; // document return "📄";
case "cron": case "cron":
return "fi fi-rr-clock"; // clock return "⏰";
case "skills": case "skills":
return "fi fi-rr-bolt"; // lightning bolt return "⚡️";
case "nodes": case "nodes":
return "fi fi-rr-computer"; // computer return "🖥️";
case "config": case "config":
return "fi fi-rr-settings"; // gear return "⚙️";
case "debug": case "debug":
return "fi fi-rr-bug"; // bug icon return "🐞";
case "logs": case "logs":
return "fi fi-rr-file-code"; // file with code return "🧾";
default: default:
return "fi fi-rr-file"; // generic file return "📁";
} }
} }
/** @deprecated Use iconClassForTab for better icon styling */
export function iconForTab(tab: Tab): string {
// Keep backward compatibility - return empty string, icons now use CSS classes
return "";
}
export function titleForTab(tab: Tab) { export function titleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":