Skip to content

Commit b660c82

Browse files
committed
feat(ui): add right panel customization
Add a small typed right panel registry so core tabs and status sections can be treated as configurable panel items. The right panel now lets users show, hide, and reorder tabs and Status subpanels, with preferences persisted in localStorage. Keep the implementation internal for now rather than adding a plugin loader; NeuralNomadsAI#193's full external plugin architecture is larger than this UI customization layer. Add registry unit coverage for ordering, visibility, duplicate ids, moves, and malformed persisted data. Validation: npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui; git diff --check
1 parent 4c50829 commit b660c82

15 files changed

Lines changed: 786 additions & 156 deletions

File tree

packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx

Lines changed: 327 additions & 81 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import {
5+
applyRightPanelItemCustomization,
6+
collectRightPanelItems,
7+
moveRightPanelItem,
8+
parseRightPanelCustomization,
9+
setRightPanelItemHidden,
10+
type RightPanelItem,
11+
type RightPanelTabModule,
12+
} from "./registry"
13+
14+
const item = (id: string, order: number, alwaysVisible = false): RightPanelItem => ({ id, labelKey: id, order, alwaysVisible })
15+
const tab = (id: string, order: number): RightPanelTabModule => ({ ...item(id, order), render: () => undefined as any })
16+
17+
describe("right panel registry", () => {
18+
it("collects and orders module items", () => {
19+
const items = collectRightPanelItems(
20+
[
21+
{ id: "core", tabs: [tab("status", 40), tab("changes", 10)] },
22+
{ id: "plugin", tabs: [tab("custom", 30)] },
23+
],
24+
"tabs",
25+
)
26+
27+
assert.deepEqual(items.map((entry) => entry.id), ["changes", "custom", "status"])
28+
})
29+
30+
it("rejects duplicate item ids", () => {
31+
assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs"))
32+
})
33+
34+
it("applies visibility and user order", () => {
35+
const visible = applyRightPanelItemCustomization(
36+
[item("changes", 10), item("files", 20), item("status", 30)],
37+
["status", "changes"],
38+
["files"],
39+
)
40+
41+
assert.deepEqual(visible.map((entry) => entry.id), ["status", "changes"])
42+
})
43+
44+
it("keeps always-visible items visible", () => {
45+
const visible = applyRightPanelItemCustomization([item("configure", 100, true)], [], ["configure"])
46+
47+
assert.deepEqual(visible.map((entry) => entry.id), ["configure"])
48+
})
49+
50+
it("moves items in normalized order", () => {
51+
assert.deepEqual(moveRightPanelItem(["changes", "status", "files"], ["changes", "files", "status"], "status", -1), [
52+
"status",
53+
"changes",
54+
"files",
55+
])
56+
})
57+
58+
it("parses invalid customization safely", () => {
59+
assert.deepEqual(parseRightPanelCustomization("{"), {
60+
tabOrder: [],
61+
hiddenTabIds: [],
62+
statusSectionOrder: [],
63+
hiddenStatusSectionIds: [],
64+
})
65+
})
66+
67+
it("toggles hidden ids", () => {
68+
assert.deepEqual(setRightPanelItemHidden(["files"], "status", true), ["files", "status"])
69+
assert.deepEqual(setRightPanelItemHidden(["files"], "files", false), [])
70+
})
71+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { JSX } from "solid-js"
2+
3+
export interface RightPanelItem {
4+
id: string
5+
labelKey: string
6+
order: number
7+
alwaysVisible?: boolean
8+
}
9+
10+
export interface RightPanelTabModule extends RightPanelItem {
11+
render: () => JSX.Element
12+
}
13+
14+
export interface RightPanelSectionModule extends RightPanelItem {
15+
tooltipKey: string
16+
defaultExpanded?: boolean
17+
render: () => JSX.Element
18+
}
19+
20+
export interface RightPanelModule {
21+
id: string
22+
tabs?: readonly RightPanelTabModule[]
23+
statusSections?: readonly RightPanelSectionModule[]
24+
}
25+
26+
export interface RightPanelCustomization {
27+
tabOrder: string[]
28+
hiddenTabIds: string[]
29+
statusSectionOrder: string[]
30+
hiddenStatusSectionIds: string[]
31+
}
32+
33+
export const DEFAULT_RIGHT_PANEL_CUSTOMIZATION: RightPanelCustomization = {
34+
tabOrder: [],
35+
hiddenTabIds: [],
36+
statusSectionOrder: [],
37+
hiddenStatusSectionIds: [],
38+
}
39+
40+
export function parseRightPanelCustomization(value: string | null): RightPanelCustomization {
41+
if (!value) return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION }
42+
try {
43+
const parsed = JSON.parse(value) as Partial<RightPanelCustomization>
44+
return {
45+
tabOrder: readStringArray(parsed.tabOrder),
46+
hiddenTabIds: readStringArray(parsed.hiddenTabIds),
47+
statusSectionOrder: readStringArray(parsed.statusSectionOrder),
48+
hiddenStatusSectionIds: readStringArray(parsed.hiddenStatusSectionIds),
49+
}
50+
} catch {
51+
return { ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION }
52+
}
53+
}
54+
55+
export function collectRightPanelItems<T extends RightPanelItem>(modules: readonly RightPanelModule[], key: "tabs" | "statusSections"): T[] {
56+
const items = modules.flatMap((module) => [...((module[key] ?? []) as unknown as readonly T[])])
57+
const seen = new Set<string>()
58+
for (const item of items) {
59+
if (!item.id) throw new Error(`Right panel ${key} must define an id`)
60+
if (seen.has(item.id)) throw new Error(`Duplicate right panel ${key} id: ${item.id}`)
61+
seen.add(item.id)
62+
}
63+
return sortRightPanelItems(items, [])
64+
}
65+
66+
export function applyRightPanelItemCustomization<T extends RightPanelItem>(
67+
items: readonly T[],
68+
orderedIds: readonly string[],
69+
hiddenIds: readonly string[],
70+
): T[] {
71+
const hidden = new Set(hiddenIds)
72+
const visible = items.filter((item) => item.alwaysVisible || !hidden.has(item.id))
73+
return sortRightPanelItems(visible.length > 0 ? visible : items.slice(0, 1), orderedIds)
74+
}
75+
76+
export function moveRightPanelItem(orderedIds: readonly string[], allIds: readonly string[], id: string, direction: -1 | 1): string[] {
77+
const order = normalizeOrder(orderedIds, allIds)
78+
const index = order.indexOf(id)
79+
const nextIndex = index + direction
80+
if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return order
81+
const next = [...order]
82+
;[next[index], next[nextIndex]] = [next[nextIndex], next[index]]
83+
return next
84+
}
85+
86+
export function setRightPanelItemHidden(hiddenIds: readonly string[], id: string, hidden: boolean): string[] {
87+
const next = new Set(hiddenIds)
88+
if (hidden) next.add(id)
89+
else next.delete(id)
90+
return [...next]
91+
}
92+
93+
function sortRightPanelItems<T extends RightPanelItem>(items: readonly T[], orderedIds: readonly string[]): T[] {
94+
const rank = new Map(orderedIds.map((id, index) => [id, index]))
95+
return [...items].sort((left, right) => {
96+
const leftRank = rank.get(left.id)
97+
const rightRank = rank.get(right.id)
98+
if (leftRank !== undefined || rightRank !== undefined) {
99+
return (leftRank ?? Number.MAX_SAFE_INTEGER) - (rightRank ?? Number.MAX_SAFE_INTEGER)
100+
}
101+
return left.order - right.order || left.id.localeCompare(right.id)
102+
})
103+
}
104+
105+
function normalizeOrder(orderedIds: readonly string[], allIds: readonly string[]): string[] {
106+
const known = new Set(allIds)
107+
const ordered = orderedIds.filter((id, index) => known.has(id) && orderedIds.indexOf(id) === index)
108+
return [...ordered, ...allIds.filter((id) => !ordered.includes(id))]
109+
}
110+
111+
function readStringArray(value: unknown): string[] {
112+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []
113+
}

0 commit comments

Comments
 (0)