chore: update deps and add control ui routing tests
This commit is contained in:
@@ -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.
|
||||
|
||||
12
package.json
12
package.json
@@ -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
689
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
15
ui/vitest.config.ts
Normal file
15
ui/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user