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
- 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: 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).
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- 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 {
bashTool,
@@ -50,6 +50,16 @@ beforeEach(() => {
});
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(
"backgrounds after yield and can be polled",
async () => {
@@ -171,9 +181,7 @@ describe("bash tool backgrounding", () => {
expect(text).toContain("hi");
});
// Skip: Fails when user's shell config (.zshenv) sources files that don't exist in test env,
// adding extra lines to stdout and breaking line count assertions.
it.skip("logs line-based slices and defaults to last lines", async () => {
it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", {
command: echoLines(["one", "two", "three"]),
background: true,
@@ -193,9 +201,7 @@ describe("bash tool backgrounding", () => {
expect(status).toBe("completed");
});
// Skip: Fails when user's shell config (.zshenv) sources files that don't exist in test env,
// adding extra lines to stdout and breaking offset assertions.
it.skip("supports line offsets for log slices", async () => {
it("supports line offsets for log slices", async () => {
const result = await bashTool.execute("call1", {
command: echoLines(["alpha", "beta", "gamma"]),
background: true,

View File

@@ -6,13 +6,9 @@
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<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>
<body>
<clawdbot-app></clawdbot-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -2,7 +2,6 @@
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 220px;
--shell-nav-collapsed-width: 56px;
--shell-topbar-height: 56px;
--shell-focus-duration: 220ms;
--shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1);
@@ -19,7 +18,7 @@
}
.shell--nav-collapsed {
grid-template-columns: var(--shell-nav-collapsed-width) minmax(0, 1fr);
grid-template-columns: 0px minmax(0, 1fr);
}
.shell--chat-focus {
@@ -298,13 +297,12 @@
flex-direction: column;
gap: 20px;
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-x: hidden;
}
.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 {

View File

@@ -3,7 +3,7 @@ import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import {
TAB_GROUPS,
iconClassForTab,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
@@ -608,7 +608,7 @@ function renderTab(state: AppViewState, tab: 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>
</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>`;
// 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>`;
// 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 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`
<div class="chat-controls">
<label class="field chat-controls__session">
@@ -637,7 +638,11 @@ function renderChatControls(state: AppViewState) {
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({ ...state.settings, sessionKey: next });
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void loadChatHistory(state);
}}
>
@@ -661,6 +666,18 @@ function renderChatControls(state: AppViewState) {
${refreshIcon}
</button>
<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
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() =>

View File

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

View File

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

View File

@@ -28,10 +28,7 @@ afterEach(() => {
});
describe("chat focus mode", () => {
// Skip: Focus mode toggle button was moved to settings panel, no longer in chat view.
// 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 () => {
it("collapses header + sidebar on chat tab only", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -68,4 +65,3 @@ describe("chat focus mode", () => {
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
});
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
TAB_GROUPS,
iconClassForTab,
iconForTab,
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
@@ -16,33 +16,34 @@ import {
/** All valid tab identifiers derived from TAB_GROUPS */
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", () => {
for (const tab of ALL_TABS) {
const icon = iconClassForTab(tab);
const icon = iconForTab(tab);
expect(icon).toBeTruthy();
expect(typeof icon).toBe("string");
expect(icon.length).toBeGreaterThan(0);
}
});
it("returns expected icon classes for each tab", () => {
expect(iconClassForTab("chat")).toBe("fi fi-rr-comment");
expect(iconClassForTab("overview")).toBe("fi fi-rr-chart-histogram");
expect(iconClassForTab("connections")).toBe("fi fi-rr-link");
expect(iconClassForTab("instances")).toBe("fi fi-rr-radar");
expect(iconClassForTab("sessions")).toBe("fi fi-rr-document");
expect(iconClassForTab("cron")).toBe("fi fi-rr-clock");
expect(iconClassForTab("skills")).toBe("fi fi-rr-bolt");
expect(iconClassForTab("nodes")).toBe("fi fi-rr-computer");
expect(iconClassForTab("config")).toBe("fi fi-rr-settings");
expect(iconClassForTab("debug")).toBe("fi fi-rr-bug");
it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬");
expect(iconForTab("overview")).toBe("📊");
expect(iconForTab("connections")).toBe("🔗");
expect(iconForTab("instances")).toBe("📡");
expect(iconForTab("sessions")).toBe("📄");
expect(iconForTab("cron")).toBe("");
expect(iconForTab("skills")).toBe("⚡️");
expect(iconForTab("nodes")).toBe("🖥️");
expect(iconForTab("config")).toBe("⚙️");
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
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("/")}`;
}
/** Returns the Flaticon uicons class for a tab icon */
export function iconClassForTab(tab: Tab): string {
export function iconForTab(tab: Tab): string {
switch (tab) {
case "chat":
return "fi fi-rr-comment"; // chat bubble
return "💬";
case "overview":
return "fi fi-rr-chart-histogram"; // bar chart
return "📊";
case "connections":
return "fi fi-rr-link"; // link
return "🔗";
case "instances":
return "fi fi-rr-radar"; // radar/satellite
return "📡";
case "sessions":
return "fi fi-rr-document"; // document
return "📄";
case "cron":
return "fi fi-rr-clock"; // clock
return "⏰";
case "skills":
return "fi fi-rr-bolt"; // lightning bolt
return "⚡️";
case "nodes":
return "fi fi-rr-computer"; // computer
return "🖥️";
case "config":
return "fi fi-rr-settings"; // gear
return "⚙️";
case "debug":
return "fi fi-rr-bug"; // bug icon
return "🐞";
case "logs":
return "fi fi-rr-file-code"; // file with code
return "🧾";
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) {
switch (tab) {
case "overview":