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).
|
- 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: 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).
|
- 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).
|
- 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: 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.
|
- 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-agent-core": "^0.30.2",
|
||||||
"@mariozechner/pi-ai": "^0.30.2",
|
"@mariozechner/pi-ai": "^0.30.2",
|
||||||
"@mariozechner/pi-coding-agent": "^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",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.1.1",
|
"file-type": "^21.2.0",
|
||||||
"grammy": "^1.38.4",
|
"grammy": "^1.38.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"long": "5.3.2",
|
"long": "5.3.2",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@lit-labs/signals": "^0.1.3",
|
"@lit-labs/signals": "^0.2.0",
|
||||||
"@lit/context": "^1.1.6",
|
"@lit/context": "^1.1.6",
|
||||||
"@mariozechner/mini-lit": "0.2.1",
|
"@mariozechner/mini-lit": "0.2.1",
|
||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
@@ -113,14 +113,14 @@
|
|||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.1",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.562.0",
|
"lucide": "^0.562.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"oxlint": "^1.35.0",
|
"oxlint": "^1.36.0",
|
||||||
"oxlint-tsgolint": "^0.10.0",
|
"oxlint-tsgolint": "^0.10.0",
|
||||||
"quicktype-core": "^23.2.6",
|
"quicktype-core": "^23.2.6",
|
||||||
"rolldown": "1.0.0-beta.55",
|
"rolldown": "1.0.0-beta.57",
|
||||||
"signal-utils": "^0.21.1",
|
"signal-utils": "^0.21.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run --config vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.3.1"
|
"lit": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/browser-playwright": "4.0.16",
|
||||||
|
"playwright": "^1.57.0",
|
||||||
"typescript": "^5.9.3",
|
"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 { html, nothing } from "lit";
|
||||||
|
|
||||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
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 {
|
import type {
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -51,6 +57,7 @@ export type AppViewState = {
|
|||||||
settings: { gatewayUrl: string; token: string; sessionKey: string };
|
settings: { gatewayUrl: string; token: string; sessionKey: string };
|
||||||
password: string;
|
password: string;
|
||||||
tab: Tab;
|
tab: Tab;
|
||||||
|
basePath: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
hello: GatewayHelloOk | null;
|
hello: GatewayHelloOk | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
@@ -352,12 +359,27 @@ export function renderApp(state: AppViewState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTab(state: AppViewState, tab: Tab) {
|
function renderTab(state: AppViewState, tab: Tab) {
|
||||||
|
const href = pathForTab(tab, state.basePath);
|
||||||
return html`
|
return html`
|
||||||
<button
|
<a
|
||||||
|
href=${href}
|
||||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
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>
|
<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 { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
|
||||||
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
||||||
import { renderApp } from "./app-render";
|
import { renderApp } from "./app-render";
|
||||||
import type { Tab } from "./navigation";
|
import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
||||||
import type {
|
import type {
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -157,6 +157,8 @@ export class ClawdisApp extends LitElement {
|
|||||||
|
|
||||||
client: GatewayBrowserClient | null = null;
|
client: GatewayBrowserClient | null = null;
|
||||||
private chatScrollFrame: number | null = null;
|
private chatScrollFrame: number | null = null;
|
||||||
|
basePath = "";
|
||||||
|
private popStateHandler = () => this.onPopState();
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
@@ -164,9 +166,17 @@ export class ClawdisApp extends LitElement {
|
|||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.basePath = this.inferBasePath();
|
||||||
|
this.syncTabWithLocation(true);
|
||||||
|
window.addEventListener("popstate", this.popStateHandler);
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
window.removeEventListener("popstate", this.popStateHandler);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
protected updated(changed: Map<PropertyKey, unknown>) {
|
protected updated(changed: Map<PropertyKey, unknown>) {
|
||||||
if (
|
if (
|
||||||
this.tab === "chat" &&
|
this.tab === "chat" &&
|
||||||
@@ -264,8 +274,9 @@ export class ClawdisApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTab(next: Tab) {
|
setTab(next: Tab) {
|
||||||
this.tab = next;
|
if (this.tab !== next) this.tab = next;
|
||||||
void this.refreshActiveTab();
|
void this.refreshActiveTab();
|
||||||
|
this.syncUrlWithTab(next, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshActiveTab() {
|
private async refreshActiveTab() {
|
||||||
@@ -276,11 +287,54 @@ export class ClawdisApp extends LitElement {
|
|||||||
if (this.tab === "cron") await this.loadCron();
|
if (this.tab === "cron") await this.loadCron();
|
||||||
if (this.tab === "skills") await loadSkills(this);
|
if (this.tab === "skills") await loadSkills(this);
|
||||||
if (this.tab === "nodes") await loadNodes(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 === "config") await loadConfig(this);
|
||||||
if (this.tab === "debug") await loadDebug(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() {
|
async loadOverview() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadProviders(this, false),
|
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"
|
| "config"
|
||||||
| "debug";
|
| "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) {
|
export function titleForTab(tab: Tab) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "overview":
|
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