chore: update deps and add control ui routing tests

This commit is contained in:
Peter Steinberger
2025-12-30 14:30:46 +01:00
parent 3d6cc435ef
commit 3aefe375c1
9 changed files with 812 additions and 158 deletions

View File

@@ -1,7 +1,13 @@
import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { TAB_GROUPS, subtitleForTab, titleForTab, type Tab } from "./navigation";
import {
TAB_GROUPS,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import type {
ConfigSnapshot,
CronJob,
@@ -51,6 +57,7 @@ export type AppViewState = {
settings: { gatewayUrl: string; token: string; sessionKey: string };
password: string;
tab: Tab;
basePath: string;
connected: boolean;
hello: GatewayHelloOk | null;
lastError: string | null;
@@ -352,12 +359,27 @@ export function renderApp(state: AppViewState) {
}
function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
return html`
<button
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${() => state.setTab(tab)}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab(tab);
}}
>
<span>${titleForTab(tab)}</span>
</button>
</a>
`;
}

View File

@@ -4,7 +4,7 @@ import { customElement, state } from "lit/decorators.js";
import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
import { loadSettings, saveSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import type { Tab } from "./navigation";
import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import type {
ConfigSnapshot,
CronJob,
@@ -157,6 +157,8 @@ export class ClawdisApp extends LitElement {
client: GatewayBrowserClient | null = null;
private chatScrollFrame: number | null = null;
basePath = "";
private popStateHandler = () => this.onPopState();
createRenderRoot() {
return this;
@@ -164,9 +166,17 @@ export class ClawdisApp extends LitElement {
connectedCallback() {
super.connectedCallback();
this.basePath = this.inferBasePath();
this.syncTabWithLocation(true);
window.addEventListener("popstate", this.popStateHandler);
this.connect();
}
disconnectedCallback() {
window.removeEventListener("popstate", this.popStateHandler);
super.disconnectedCallback();
}
protected updated(changed: Map<PropertyKey, unknown>) {
if (
this.tab === "chat" &&
@@ -264,8 +274,9 @@ export class ClawdisApp extends LitElement {
}
setTab(next: Tab) {
this.tab = next;
if (this.tab !== next) this.tab = next;
void this.refreshActiveTab();
this.syncUrlWithTab(next, false);
}
private async refreshActiveTab() {
@@ -276,11 +287,54 @@ export class ClawdisApp extends LitElement {
if (this.tab === "cron") await this.loadCron();
if (this.tab === "skills") await loadSkills(this);
if (this.tab === "nodes") await loadNodes(this);
if (this.tab === "chat") await loadChatHistory(this);
if (this.tab === "chat") {
await loadChatHistory(this);
this.scheduleChatScroll();
}
if (this.tab === "config") await loadConfig(this);
if (this.tab === "debug") await loadDebug(this);
}
private inferBasePath() {
if (typeof window === "undefined") return "";
const path = window.location.pathname;
if (path === "/ui" || path.startsWith("/ui/")) return "/ui";
return "";
}
private syncTabWithLocation(replace: boolean) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat";
this.setTabFromRoute(resolved);
this.syncUrlWithTab(resolved, replace);
}
private onPopState() {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, this.basePath);
if (!resolved) return;
this.setTabFromRoute(resolved);
}
private setTabFromRoute(next: Tab) {
if (this.tab !== next) this.tab = next;
if (this.connected) void this.refreshActiveTab();
}
private syncUrlWithTab(tab: Tab, replace: boolean) {
if (typeof window === "undefined") return;
const targetPath = normalizePath(pathForTab(tab, this.basePath));
const currentPath = normalizePath(window.location.pathname);
if (currentPath === targetPath) return;
const url = new URL(window.location.href);
url.pathname = targetPath;
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
async loadOverview() {
await Promise.all([
loadProviders(this, false),

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ClawdisApp } from "./app";
const originalConnect = ClawdisApp.prototype.connect;
function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
const app = document.createElement("clawdis-app") as ClawdisApp;
document.body.append(app);
return app;
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
beforeEach(() => {
ClawdisApp.prototype.connect = () => {
// no-op: avoid real gateway WS connections in browser tests
};
document.body.innerHTML = "";
});
afterEach(() => {
ClawdisApp.prototype.connect = originalConnect;
document.body.innerHTML = "";
});
describe("control UI routing", () => {
it("hydrates the tab from the location", async () => {
const app = mountApp("/sessions");
await app.updateComplete;
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/sessions");
});
it("respects /ui base paths", async () => {
const app = mountApp("/ui/cron");
await app.updateComplete;
expect(app.basePath).toBe("/ui");
expect(app.tab).toBe("cron");
expect(window.location.pathname).toBe("/ui/cron");
});
it("updates the URL when clicking nav items", async () => {
const app = mountApp("/chat");
await app.updateComplete;
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(window.location.pathname).toBe("/connections");
});
it("auto-scrolls chat history to the latest message", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const initialContainer = app.querySelector(".messages") as HTMLElement | null;
expect(initialContainer).not.toBeNull();
if (!initialContainer) return;
initialContainer.style.maxHeight = "180px";
initialContainer.style.overflow = "auto";
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
role: "assistant",
content: `Line ${index} - ${"x".repeat(200)}`,
timestamp: Date.now() + index,
}));
await app.updateComplete;
await nextFrame();
const container = app.querySelector(".messages") as HTMLElement | null;
expect(container).not.toBeNull();
if (!container) return;
const maxScroll = container.scrollHeight - container.clientHeight;
expect(maxScroll).toBeGreaterThan(0);
expect(container.scrollTop).toBe(maxScroll);
});
});

View File

@@ -20,6 +20,64 @@ export type Tab =
| "config"
| "debug";
const TAB_PATHS: Record<Tab, string> = {
overview: "/overview",
connections: "/connections",
instances: "/instances",
sessions: "/sessions",
cron: "/cron",
skills: "/skills",
nodes: "/nodes",
chat: "/chat",
config: "/config",
debug: "/debug",
};
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
);
function normalizeBasePath(basePath: string): string {
if (!basePath) return "";
let base = basePath.trim();
if (!base.startsWith("/")) base = `/${base}`;
if (base === "/") return "";
if (base.endsWith("/")) base = base.slice(0, -1);
return base;
}
export function normalizePath(path: string): string {
if (!path) return "/";
let normalized = path.trim();
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
export function pathForTab(tab: Tab, basePath = ""): string {
const base = normalizeBasePath(basePath);
const path = TAB_PATHS[tab];
return base ? `${base}${path}` : path;
}
export function tabFromPath(pathname: string, basePath = ""): Tab | null {
const base = normalizeBasePath(basePath);
let path = pathname || "/";
if (base) {
if (path === base) {
path = "/";
} else if (path.startsWith(`${base}/`)) {
path = path.slice(base.length);
}
}
let normalized = normalizePath(path).toLowerCase();
if (normalized.endsWith("/index.html")) normalized = "/";
if (normalized === "/") return "chat";
return PATH_TO_TAB.get(normalized) ?? null;
}
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":