Skip to content

Commit 76aa830

Browse files
committed
fix(ui): restore sidebar navigation contract
1 parent 6441858 commit 76aa830

4 files changed

Lines changed: 100 additions & 64 deletions

File tree

ui/src/app-navigation-groups.test.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,55 @@
1-
// Control UI tests cover navigation groups behavior.
1+
// Control UI tests cover sidebar pinned-route customization behavior.
22
import { describe, expect, it } from "vitest";
33
import {
4+
DEFAULT_SIDEBAR_PINNED_ROUTES,
45
SETTINGS_NAVIGATION_ROUTES,
5-
SIDEBAR_SECTIONS,
6+
SIDEBAR_NAV_ROUTES,
67
isSettingsNavigationRoute,
7-
isRouteInSidebarSection,
8+
normalizeSidebarPinnedRoutes,
9+
sidebarMoreRoutes,
810
} from "./app-navigation.ts";
911
import { routeIdFromPath } from "./app-routes.ts";
1012

11-
describe("SIDEBAR_SECTIONS", () => {
12-
it("collapses detailed settings slices into one sidebar entry", () => {
13-
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
14-
expect(settings?.routes).toEqual(["config"]);
13+
describe("sidebar pinned routes", () => {
14+
it("defaults to a small pinned set drawn from the customizable routes", () => {
15+
expect(DEFAULT_SIDEBAR_PINNED_ROUTES.length).toBeLessThan(SIDEBAR_NAV_ROUTES.length);
16+
for (const routeId of DEFAULT_SIDEBAR_PINNED_ROUTES) {
17+
expect(SIDEBAR_NAV_ROUTES).toContain(routeId);
18+
}
19+
});
20+
21+
it("keeps managed worktrees in the customizable sidebar", () => {
22+
expect(SIDEBAR_NAV_ROUTES).toContain("worktrees");
23+
});
24+
25+
it("keeps channel management and settings slices out of the customizable sidebar", () => {
26+
expect(SIDEBAR_NAV_ROUTES).not.toContain("channels");
27+
expect(SIDEBAR_NAV_ROUTES).not.toContain("config");
28+
expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels");
1529
expect(SETTINGS_NAVIGATION_ROUTES.every((routeId) => isSettingsNavigationRoute(routeId))).toBe(
1630
true,
1731
);
1832
});
1933

20-
it("keeps channel management out of the primary control sidebar", () => {
21-
const control = SIDEBAR_SECTIONS.find((group) => group.label === "control");
22-
expect(control?.routes).toEqual([
23-
"overview",
24-
"activity",
25-
"workboard",
26-
"worktrees",
27-
"instances",
28-
"sessions",
29-
"usage",
30-
"cron",
31-
]);
32-
expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels");
34+
it("normalizes persisted pinned routes, dropping unknown and duplicate entries", () => {
35+
expect(
36+
normalizeSidebarPinnedRoutes(["worktrees", "overview", "worktrees", "bogus", 7]),
37+
).toEqual(["worktrees", "overview"]);
38+
expect(normalizeSidebarPinnedRoutes([])).toEqual([]);
3339
});
3440

35-
it("keeps the settings group active for nested settings routes", () => {
36-
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
37-
if (!settings) {
38-
throw new Error("Expected settings group");
39-
}
41+
it("falls back to null for non-list values so callers use defaults", () => {
42+
expect(normalizeSidebarPinnedRoutes(undefined)).toBeNull();
43+
expect(normalizeSidebarPinnedRoutes({ overview: true })).toBeNull();
44+
expect(normalizeSidebarPinnedRoutes("overview")).toBeNull();
45+
});
4046

41-
expect(isRouteInSidebarSection(settings, "appearance")).toBe(true);
42-
expect(isRouteInSidebarSection(settings, "channels")).toBe(true);
43-
expect(isRouteInSidebarSection(settings, "debug")).toBe(true);
44-
expect(isRouteInSidebarSection(settings, "chat")).toBe(false);
47+
it("puts every unpinned nav route into the More section", () => {
48+
const pinned = ["overview", "worktrees"] as const;
49+
const more = sidebarMoreRoutes(pinned);
50+
expect(more).not.toContain("overview");
51+
expect(more).not.toContain("worktrees");
52+
expect(new Set([...pinned, ...more])).toEqual(new Set(SIDEBAR_NAV_ROUTES));
4553
});
4654

4755
it("routes every published settings slice", () => {

ui/src/app-navigation.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("navigationIconForRoute", () => {
3636
overview: "barChart",
3737
activity: "activity",
3838
workboard: "folder",
39+
worktrees: "folder",
3940
channels: "link",
4041
instances: "radio",
4142
sessions: "fileText",
@@ -74,6 +75,7 @@ describe("titleForRoute", () => {
7475
overview: "Overview",
7576
activity: "Activity",
7677
workboard: "Workboard",
78+
worktrees: "Worktrees",
7779
channels: "Channels",
7880
instances: "Instances",
7981
sessions: "Sessions",
@@ -106,6 +108,7 @@ describe("subtitleForRoute", () => {
106108
overview: "Status, entry points, health.",
107109
activity: "Browser-local tool activity summaries.",
108110
workboard: "Agent work queue and session handoff.",
111+
worktrees: "Isolated agent task checkouts and recovery snapshots.",
109112
channels: "Channels and settings.",
110113
instances: "Connected clients and nodes.",
111114
sessions: "Active sessions and defaults.",
@@ -171,6 +174,7 @@ describe("pathForRoute", () => {
171174
it("returns correct path without base", () => {
172175
expect(pathForRoute("chat")).toBe("/chat");
173176
expect(pathForRoute("overview")).toBe("/overview");
177+
expect(pathForRoute("worktrees")).toBe("/worktrees");
174178
});
175179

176180
it("prepends base path", () => {
@@ -184,6 +188,7 @@ describe("routeIdFromPath", () => {
184188
expect(routeIdFromPath("/chat")).toBe("chat");
185189
expect(routeIdFromPath("/overview")).toBe("overview");
186190
expect(routeIdFromPath("/activity")).toBe("activity");
191+
expect(routeIdFromPath("/worktrees")).toBe("worktrees");
187192
expect(routeIdFromPath("/sessions")).toBe("sessions");
188193
expect(routeIdFromPath("/dreaming")).toBe("dreams");
189194
expect(routeIdFromPath("/dreams")).toBe("dreams");

ui/src/app-navigation.ts

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,62 @@ import { t } from "./i18n/index.ts";
55

66
export type NavigationRouteId = RouteId;
77

8-
type SidebarSection = {
9-
label: string;
10-
routes: readonly NavigationRouteId[];
11-
};
12-
138
type NavigationItem = {
149
[TRouteId in NavigationRouteId]: IconName;
1510
};
1611

17-
export const SIDEBAR_SECTIONS = [
18-
{ label: "chat", routes: ["chat"] },
19-
{
20-
label: "control",
21-
routes: [
22-
"overview",
23-
"activity",
24-
"workboard",
25-
"worktrees",
26-
"instances",
27-
"sessions",
28-
"usage",
29-
"cron",
30-
],
31-
},
32-
{ label: "agent", routes: ["agents", "skills", "skill-workshop", "nodes", "dreams"] },
33-
{ label: "settings", routes: ["config"] },
34-
] as const satisfies readonly SidebarSection[];
12+
// The sidebar shows a small user-customizable pinned set; every other nav route
13+
// lives in the collapsed "More" section. Chat is reachable through the session
14+
// list and Settings/Docs live in the sidebar footer, so neither is listed here.
15+
export const SIDEBAR_NAV_ROUTES = [
16+
"overview",
17+
"activity",
18+
"workboard",
19+
"worktrees",
20+
"instances",
21+
"sessions",
22+
"usage",
23+
"cron",
24+
"agents",
25+
"skills",
26+
"skill-workshop",
27+
"nodes",
28+
"dreams",
29+
] as const satisfies readonly NavigationRouteId[];
30+
31+
export type SidebarNavRoute = (typeof SIDEBAR_NAV_ROUTES)[number];
32+
33+
// Sessions are the sidebar's core content; Overview is the only page pinned by
34+
// default. Users pin more via the customize menu.
35+
export const DEFAULT_SIDEBAR_PINNED_ROUTES = [
36+
"overview",
37+
] as const satisfies readonly SidebarNavRoute[];
38+
39+
/**
40+
* Normalize a persisted pinned-route list. Returns null when the value is not a
41+
* list (caller falls back to defaults); unknown or duplicate entries are dropped
42+
* so prefs survive route renames/removals without a migration.
43+
*/
44+
export function normalizeSidebarPinnedRoutes(value: unknown): SidebarNavRoute[] | null {
45+
if (!Array.isArray(value)) {
46+
return null;
47+
}
48+
const pinned: SidebarNavRoute[] = [];
49+
for (const entry of value) {
50+
if (
51+
typeof entry === "string" &&
52+
(SIDEBAR_NAV_ROUTES as readonly string[]).includes(entry) &&
53+
!pinned.includes(entry as SidebarNavRoute)
54+
) {
55+
pinned.push(entry as SidebarNavRoute);
56+
}
57+
}
58+
return pinned;
59+
}
60+
61+
export function sidebarMoreRoutes(pinned: readonly SidebarNavRoute[]): SidebarNavRoute[] {
62+
return SIDEBAR_NAV_ROUTES.filter((routeId) => !pinned.includes(routeId));
63+
}
3564

3665
export const SETTINGS_NAVIGATION_ROUTES = [
3766
"config",
@@ -78,16 +107,6 @@ export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
78107
return (SETTINGS_NAVIGATION_ROUTES as readonly NavigationRouteId[]).includes(routeId);
79108
}
80109

81-
export function isRouteInSidebarSection(
82-
section: SidebarSection,
83-
routeId: NavigationRouteId,
84-
): boolean {
85-
if (section.label === "settings") {
86-
return isSettingsNavigationRoute(routeId);
87-
}
88-
return section.routes.includes(routeId);
89-
}
90-
91110
export function navigationIconForRoute(routeId: NavigationRouteId): IconName {
92111
return NAVIGATION_ICONS[routeId] ?? "folder";
93112
}

ui/src/e2e/sidebar-customization.e2e.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,19 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
9898
const menu = sidebar.getByRole("menu", { name: "Customize sidebar" });
9999
const overviewItem = menu.getByRole("menuitemcheckbox", { name: "Overview" });
100100
await expect.poll(() => overviewItem.getAttribute("aria-checked")).toBe("true");
101+
const worktreesItem = menu.getByRole("menuitemcheckbox", { name: "Worktrees" });
102+
await expect.poll(() => worktreesItem.getAttribute("aria-checked")).toBe("false");
101103
await expect
102104
.poll(() => overviewItem.evaluate((element) => element === document.activeElement))
103105
.toBe(true);
104106
await captureUiProof(page, "02-customize-menu.png");
105107

108+
await worktreesItem.click();
109+
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Overview", "Worktrees"]);
106110
await overviewItem.click();
107-
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual([]);
111+
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Worktrees"]);
108112
await page.reload();
109-
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual([]);
113+
await expect.poll(() => trimmedTextContents(pinnedItems)).toEqual(["Worktrees"]);
110114
await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("true");
111115
await expect
112116
.poll(() =>

0 commit comments

Comments
 (0)