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

@@ -25,6 +25,7 @@
- Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).
- Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.
- Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue).
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
- iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.

View File

@@ -74,7 +74,7 @@
"@mariozechner/pi-agent-core": "^0.30.2",
"@mariozechner/pi-ai": "^0.30.2",
"@mariozechner/pi-coding-agent": "^0.30.2",
"@sinclair/typebox": "^0.34.41",
"@sinclair/typebox": "^0.34.45",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.17.1",
"body-parser": "^2.2.1",
@@ -87,7 +87,7 @@
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.1.1",
"file-type": "^21.2.0",
"grammy": "^1.38.4",
"json5": "^2.2.3",
"long": "5.3.2",
@@ -101,7 +101,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@lit-labs/signals": "^0.1.3",
"@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6",
"@mariozechner/mini-lit": "0.2.1",
"@types/body-parser": "^1.19.6",
@@ -113,14 +113,14 @@
"@vitest/coverage-v8": "^4.0.16",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lit": "^3.3.2",
"lucide": "^0.562.0",
"markdown-it": "^14.1.0",
"ollama": "^0.6.3",
"oxlint": "^1.35.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.55",
"rolldown": "1.0.0-beta.57",
"signal-utils": "^0.21.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",

689
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,17 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"lit": "^3.3.1"
"lit": "^3.3.2"
},
"devDependencies": {
"@vitest/browser-playwright": "4.0.16",
"playwright": "^1.57.0",
"typescript": "^5.9.3",
"vite": "8.0.0-beta.3"
"vite": "8.0.0-beta.3",
"vitest": "4.0.16"
}
}
}

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":

15
ui/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium", name: "chromium" }],
headless: true,
ui: false,
},
},
});