feat: configurable control ui base path

This commit is contained in:
Peter Steinberger
2026-01-03 17:54:52 +01:00
parent 822def84d2
commit 1d6de24ab3
18 changed files with 310 additions and 857 deletions

View File

@@ -4,7 +4,14 @@ 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 { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import {
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
pathForTab,
tabFromPath,
type Tab,
} from "./navigation";
import {
resolveTheme,
type ResolvedTheme,
@@ -74,6 +81,12 @@ type EventLogEntry = {
payload?: unknown;
};
declare global {
interface Window {
__CLAWDIS_CONTROL_UI_BASE_PATH__?: string;
}
}
const DEFAULT_CRON_FORM: CronFormState = {
name: "",
description: "",
@@ -468,9 +481,11 @@ export class ClawdisApp extends LitElement {
private inferBasePath() {
if (typeof window === "undefined") return "";
const path = window.location.pathname;
if (path === "/ui" || path.startsWith("/ui/")) return "/ui";
return "";
const configured = window.__CLAWDIS_CONTROL_UI_BASE_PATH__;
if (typeof configured === "string" && configured.trim()) {
return normalizeBasePath(configured);
}
return inferBasePathFromPathname(window.location.pathname);
}
private syncThemeWithSettings() {

View File

@@ -21,11 +21,13 @@ beforeEach(() => {
ClawdisApp.prototype.connect = () => {
// no-op: avoid real gateway WS connections in browser tests
};
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined;
document.body.innerHTML = "";
});
afterEach(() => {
ClawdisApp.prototype.connect = originalConnect;
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined;
document.body.innerHTML = "";
});
@@ -47,6 +49,25 @@ describe("control UI routing", () => {
expect(window.location.pathname).toBe("/ui/cron");
});
it("infers nested base paths", async () => {
const app = mountApp("/apps/clawdis/cron");
await app.updateComplete;
expect(app.basePath).toBe("/apps/clawdis");
expect(app.tab).toBe("cron");
expect(window.location.pathname).toBe("/apps/clawdis/cron");
});
it("honors explicit base path overrides", async () => {
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = "/clawdis";
const app = mountApp("/clawdis/sessions");
await app.updateComplete;
expect(app.basePath).toBe("/clawdis");
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/clawdis/sessions");
});
it("updates the URL when clicking nav items", async () => {
const app = mountApp("/chat");
await app.updateComplete;

View File

@@ -37,7 +37,7 @@ const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
);
function normalizeBasePath(basePath: string): string {
export function normalizeBasePath(basePath: string): string {
if (!basePath) return "";
let base = basePath.trim();
if (!base.startsWith("/")) base = `/${base}`;
@@ -78,6 +78,24 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null {
return PATH_TO_TAB.get(normalized) ?? null;
}
export function inferBasePathFromPathname(pathname: string): string {
let normalized = normalizePath(pathname);
if (normalized.endsWith("/index.html")) {
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
}
if (normalized === "/") return "";
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0) return "";
for (let i = 0; i < segments.length; i++) {
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
if (PATH_TO_TAB.has(candidate)) {
const prefix = segments.slice(0, i);
return prefix.length ? `/${prefix.join("/")}` : "";
}
}
return `/${segments.join("/")}`;
}
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":