UI: keep unfinished settings routes hidden

This commit is contained in:
Vincent Koc 2026-03-11 00:47:39 -04:00
parent 1e142a3a2c
commit fece56306f
3 changed files with 169 additions and 24 deletions

View File

@ -1,19 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applySettings,
attachThemeListener,
setTabFromRoute,
syncThemeWithSettings,
} from "./app-settings.ts";
import type { Tab } from "./navigation.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
type Tab =
| "agents"
| "overview"
| "channels"
| "instances"
| "sessions"
| "usage"
| "cron"
| "skills"
| "nodes"
| "chat"
| "config"
| "communications"
| "appearance"
| "automation"
| "infrastructure"
| "aiAgents"
| "debug"
| "logs";
type AppSettingsModule = typeof import("./app-settings.ts");
type SettingsHost = {
settings: {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number;
navCollapsed: boolean;
navWidth: number;
navGroupsCollapsed: Record<string, boolean>;
};
theme: ThemeName & ThemeMode;
themeMode: ThemeMode;
themeResolved: import("./theme.ts").ResolvedTheme;
applySessionKey: string;
sessionKey: string;
tab: Tab;
connected: boolean;
chatHasAutoScrolled: boolean;
logsAtBottom: boolean;
eventLog: unknown[];
eventLogBuffer: unknown[];
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
};
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
@ -48,37 +114,50 @@ const createHost = (tab: Tab): SettingsHost => ({
});
describe("setTabFromRoute", () => {
let appSettings: AppSettingsModule;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
vi.stubGlobal("window", {
setInterval,
clearInterval,
} as Window & typeof globalThis);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts and stops log polling based on the tab", () => {
it("starts and stops log polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "logs");
appSettings.setTabFromRoute(host, "logs");
expect(host.logsPollInterval).not.toBeNull();
expect(host.debugPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.logsPollInterval).toBeNull();
});
it("starts and stops debug polling based on the tab", () => {
it("starts and stops debug polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "debug");
appSettings.setTabFromRoute(host, "debug");
expect(host.debugPollInterval).not.toBeNull();
expect(host.logsPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.debugPollInterval).toBeNull();
});
it("re-resolves the active palette when only themeMode changes", () => {
it("re-resolves the active palette when only themeMode changes", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
host.settings.theme = "knot";
host.settings.themeMode = "dark";
@ -86,7 +165,7 @@ describe("setTabFromRoute", () => {
host.themeMode = "dark";
host.themeResolved = "openknot";
applySettings(host, {
appSettings.applySettings(host, {
...host.settings,
themeMode: "light",
});
@ -96,19 +175,21 @@ describe("setTabFromRoute", () => {
expect(host.themeResolved).toBe("openknot-light");
});
it("syncs both theme family and mode from persisted settings", () => {
it("syncs both theme family and mode from persisted settings", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
host.settings.theme = "dash";
host.settings.themeMode = "light";
syncThemeWithSettings(host);
appSettings.syncThemeWithSettings(host);
expect(host.theme).toBe("dash");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("dash-light");
});
it("applies named system themes on OS preference changes", () => {
it("applies named system themes on OS preference changes", async () => {
appSettings ??= await import("./app-settings.ts");
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
const matchMedia = vi.fn().mockReturnValue({
matches: false,
@ -118,12 +199,17 @@ describe("setTabFromRoute", () => {
removeEventListener: vi.fn(),
});
vi.stubGlobal("matchMedia", matchMedia);
vi.stubGlobal("window", {
setInterval,
clearInterval,
matchMedia,
} as Window & typeof globalThis);
const host = createHost("chat");
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "system";
attachThemeListener(host);
appSettings.attachThemeListener(host);
listeners[0]?.({ matches: true } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot");

View File

@ -1,9 +1,56 @@
import { describe, expect, it } from "vitest";
import { TAB_GROUPS } from "./navigation.ts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type NavigationModule = typeof import("./navigation.ts");
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
describe("TAB_GROUPS", () => {
let navigation: NavigationModule;
beforeEach(async () => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
navigation = await import("./navigation.ts");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not expose unfinished settings slices in the sidebar", () => {
const settings = TAB_GROUPS.find((group) => group.label === "settings");
const settings = navigation.TAB_GROUPS.find((group) => group.label === "settings");
expect(settings?.tabs).toEqual(["config", "debug", "logs"]);
});
it("does not route directly into unfinished settings slices", () => {
expect(navigation.tabFromPath("/communications")).toBeNull();
expect(navigation.tabFromPath("/appearance")).toBeNull();
expect(navigation.tabFromPath("/automation")).toBeNull();
expect(navigation.tabFromPath("/infrastructure")).toBeNull();
expect(navigation.tabFromPath("/ai-agents")).toBeNull();
expect(navigation.tabFromPath("/config")).toBe("config");
});
});

View File

@ -55,7 +55,19 @@ const TAB_PATHS: Record<Tab, string> = {
logs: "/logs",
};
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
const HIDDEN_SETTINGS_TABS = new Set<Tab>([
"communications",
"appearance",
"automation",
"infrastructure",
"aiAgents",
]);
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS)
.filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab))
.map(([tab, path]) => [path, tab as Tab]),
);
export function normalizeBasePath(basePath: string): string {
if (!basePath) {