chore: update deps and add control ui routing tests
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
93
ui/src/ui/navigation.browser.test.ts
Normal file
93
ui/src/ui/navigation.browser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user