@anchrd/intel-ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/action-slot/action-slot.tsx +23 -0
  4. package/src/app/app-sidebar/app-sidebar.tsx +71 -0
  5. package/src/app/app-tree/app-tree.tsx +798 -0
  6. package/src/app/app.tsx +99 -54
  7. package/src/app/header-search/header-search.tsx +291 -0
  8. package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  9. package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  10. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  11. package/src/app/tree-move/tree-move.tsx +197 -0
  12. package/src/app/user-footer/user-footer.tsx +103 -0
  13. package/src/app/view-toggle/view-toggle.tsx +77 -0
  14. package/src/blocknote-view/blocknote-view.tsx +19 -2
  15. package/src/branding/favicon.default.svg +2 -2
  16. package/src/branding/favicon.svg +2 -2
  17. package/src/components/ui/breadcrumb.tsx +102 -0
  18. package/src/components/ui/button.tsx +64 -0
  19. package/src/components/ui/collapsible.tsx +20 -0
  20. package/src/components/ui/command.tsx +160 -0
  21. package/src/components/ui/dialog.tsx +143 -0
  22. package/src/components/ui/dropdown-menu.tsx +162 -0
  23. package/src/components/ui/input.tsx +21 -0
  24. package/src/components/ui/separator.tsx +26 -0
  25. package/src/components/ui/sheet.tsx +136 -0
  26. package/src/components/ui/sidebar.tsx +693 -0
  27. package/src/components/ui/skeleton.tsx +13 -0
  28. package/src/components/ui/tooltip.tsx +51 -0
  29. package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
  30. package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
  31. package/src/document-link/document-link.tsx +132 -0
  32. package/src/flow-runs/flow-runs.tsx +225 -0
  33. package/src/flows/flows.tsx +684 -368
  34. package/src/flows/node-icon/node-icon.ts +28 -0
  35. package/src/flows/node-palette/node-palette.tsx +174 -0
  36. package/src/flows/node-palette/node-palette.types.ts +15 -0
  37. package/src/graph-pane/graph-pane.tsx +44 -0
  38. package/src/hooks/use-mobile.ts +19 -0
  39. package/src/i18n/en.json +188 -52
  40. package/src/knowledge/knowledge.tsx +133 -709
  41. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  42. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  43. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  44. package/src/knowledge-table/knowledge-table.tsx +129 -0
  45. package/src/main.tsx +2 -2
  46. package/src/resource-menu/resource-menu.tsx +580 -0
  47. package/src/router/router.tsx +4 -0
  48. package/src/router/selection-search.ts +43 -0
  49. package/src/save-button/save-button.tsx +103 -0
  50. package/src/styles.css +91 -51
  51. package/src/theme/theme.ts +24 -0
  52. package/src/tools/tools.tsx +175 -158
package/src/app/app.tsx CHANGED
@@ -1,62 +1,107 @@
1
- import { useMutation } from "@tanstack/react-query";
2
- import { Link, Outlet } from "@tanstack/react-router";
3
- import { BookOpen, LogOut, Network, Wrench } from "lucide-react";
4
- import { AppLogo } from "@/branding/branding.tsx";
5
- import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
1
+ import { Link, Outlet, useRouterState } from "@tanstack/react-router";
2
+ import { useRef, useState } from "react";
3
+ import { AppSidebar } from "@/app/app-sidebar/app-sidebar.tsx";
4
+ import { HeaderSearch } from "@/app/header-search/header-search.tsx";
5
+ import { createSidebarPreferences } from "@/app/sidebar-preferences/sidebar-preferences.ts";
6
+ import {
7
+ Breadcrumb,
8
+ BreadcrumbItem,
9
+ BreadcrumbLink,
10
+ BreadcrumbList,
11
+ BreadcrumbPage,
12
+ BreadcrumbSeparator,
13
+ } from "@/components/ui/breadcrumb";
14
+ import { Separator } from "@/components/ui/separator";
15
+ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
6
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
7
17
 
18
+ const sections = [
19
+ { path: "/knowledge", labelKey: "nav.knowledge" },
20
+ { path: "/flows", labelKey: "nav.flows" },
21
+ { path: "/tools", labelKey: "nav.tools" },
22
+ ] as const;
23
+
8
24
  export function App() {
9
- const { data, i18n } = useIntelRouterContext();
10
- const logout = useMutation({
11
- mutationFn: () => data.logout(),
12
- onSuccess: () => window.location.assign(loginPath()),
13
- });
14
- const navigation = [
15
- { to: "/knowledge" as const, label: i18n.t("nav.knowledge"), icon: BookOpen },
16
- { to: "/flows" as const, label: i18n.t("nav.flows"), icon: Network },
17
- { to: "/tools" as const, label: i18n.t("nav.tools"), icon: Wrench },
18
- ];
25
+ const { i18n } = useIntelRouterContext();
26
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
27
+ const current = sections.find(
28
+ (section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
29
+ );
30
+
31
+ // Read once: the stored preference is the starting point, after which the shell's own state is
32
+ // the truth. Re-reading on every render would fight the drag.
33
+ const preferences = useRef(createSidebarPreferences()).current;
34
+ const stored = useRef(preferences.read()).current;
35
+ const [open, setOpen] = useState(stored.open);
36
+ const [width, setWidth] = useState(stored.width);
19
37
 
20
38
  return (
21
- <div className="min-h-screen bg-background text-foreground">
22
- <aside className="fixed inset-y-0 left-0 z-20 flex w-64 flex-col border-r border-sidebar-border bg-sidebar p-4 text-sidebar-foreground">
23
- <Link to="/knowledge" className="mb-8 flex items-center gap-3 px-2 font-semibold">
24
- <AppLogo className="size-9" />
25
- <span>{i18n.t("app.name")}</span>
26
- </Link>
27
- <nav aria-label={i18n.t("nav.primary")} className="space-y-1">
28
- {navigation.map(({ to, label, icon: Icon }) => (
29
- <Link
30
- key={to}
31
- to={to}
32
- activeProps={{ "aria-current": "page" }}
33
- className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-accent-foreground"
34
- >
35
- <Icon aria-hidden="true" className="size-4" />
36
- {label}
37
- </Link>
38
- ))}
39
- </nav>
40
- <div className="mt-auto space-y-2">
41
- {logout.isError ? (
42
- <p role="alert" className="px-3 text-sm text-destructive">
43
- {i18n.t("auth.signOutFailed")}
44
- </p>
45
- ) : null}
46
- <button
47
- type="button"
48
- onClick={() => logout.mutate()}
49
- disabled={logout.isPending}
50
- className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:opacity-60"
51
- >
52
- <LogOut aria-hidden="true" className="size-4" />
53
- {i18n.t("auth.signOut")}
54
- </button>
39
+ // ⚠️ `h-svh` is what keeps the box inside the screen. The registry's wrapper only carries
40
+ // `min-h-svh`, so its height follows its content and the inset, an `inset` variant with a
41
+ // margin all round, then stands 16px taller than the viewport at best and grows with a long
42
+ // document at worst. Both times the lower border and its two rounded corners end up under the
43
+ // fold and the whole page scrolls. With a definite height the inset stretches to
44
+ // `100svh - 16px`, and the overflow below belongs to the container around the outlet, which
45
+ // already carries `min-h-0 overflow-auto` for exactly that. Set here rather than in
46
+ // `components/ui/sidebar.tsx`: that is registry code and an edit in it is silently gone at the
47
+ // next sync.
48
+ <SidebarProvider
49
+ className="h-svh"
50
+ open={open}
51
+ onOpenChange={(next) => {
52
+ setOpen(next);
53
+ preferences.write({ open: next, width });
54
+ }}
55
+ style={{ "--sidebar-width": `${width}px` } as React.CSSProperties}
56
+ >
57
+ <AppSidebar
58
+ width={width}
59
+ onWidthChange={(next) => {
60
+ setWidth(next);
61
+ preferences.write({ open, width: next });
62
+ }}
63
+ />
64
+ {/* ⚠️ `min-w-0` is not cosmetic: the inset is a flex item beside the sidebar and carries
65
+ `min-width: auto`, so it never shrinks below its content's min-content width. One wide
66
+ child (a flow canvas, a table of raw JSON) would widen the page instead of scrolling
67
+ inside its own container. Learned in gate, GATE-76 D. */}
68
+ {/* `overflow-hidden` is the second half of the rounding: the box is rounded but nothing
69
+ clipped its children, so an opaque panel inside it — the flow editor's inspector carries
70
+ `bg-card` painted its own square corner over the rounded one. Invisible in the light
71
+ theme, where card and background are the same white, and plain to see in the dark one.
72
+ Scrolling is unaffected: the container around the outlet below owns it. */}
73
+ <SidebarInset className="min-w-0 overflow-hidden md:border">
74
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
75
+ <SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
76
+ <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
77
+ <Breadcrumb>
78
+ <BreadcrumbList>
79
+ <BreadcrumbItem>
80
+ <BreadcrumbLink asChild>
81
+ <Link to="/knowledge">{i18n.t("app.name")}</Link>
82
+ </BreadcrumbLink>
83
+ </BreadcrumbItem>
84
+ {current ? (
85
+ <>
86
+ <BreadcrumbSeparator />
87
+ <BreadcrumbItem>
88
+ <BreadcrumbPage>{i18n.t(current.labelKey)}</BreadcrumbPage>
89
+ </BreadcrumbItem>
90
+ </>
91
+ ) : null}
92
+ </BreadcrumbList>
93
+ </Breadcrumb>
94
+ {/* The bar for area-owned actions. A screen renders into it through `ActionSlot`;
95
+ search is the shell's own, because it has to open from every screen alike and belongs
96
+ to none of them. It stays first, so a screen's actions line up to its right. */}
97
+ <div data-slot="header-actions" className="ml-auto flex items-center gap-2">
98
+ <HeaderSearch />
99
+ </div>
100
+ </header>
101
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto">
102
+ <Outlet />
55
103
  </div>
56
- </aside>
57
- <main className="ml-64 min-h-screen">
58
- <Outlet />
59
- </main>
60
- </div>
104
+ </SidebarInset>
105
+ </SidebarProvider>
61
106
  );
62
107
  }
@@ -0,0 +1,291 @@
1
+ import type { Flow, ToolCapability } from "@anchrd/intel-contract";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useNavigate } from "@tanstack/react-router";
4
+ import { FileText, Search, Workflow, Wrench } from "lucide-react";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from "@/components/ui/command";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from "@/components/ui/dialog";
21
+ import { useIntelRouterContext } from "@/router/router-context.ts";
22
+
23
+ const Kinds = ["knowledge", "flow", "tool"] as const;
24
+ type Kind = (typeof Kinds)[number];
25
+
26
+ // Enough to answer "where was that again" without turning the dialog into a result page.
27
+ const ResultLimit = 8;
28
+
29
+ const areaFor = { knowledge: "/knowledge", flow: "/flows", tool: "/tools" } as const;
30
+
31
+ function isMac(): boolean {
32
+ return typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
33
+ }
34
+
35
+ // ⚠️ The deliberate boundary of this ticket, named rather than hidden: Intel has a search endpoint
36
+ // for Knowledge only, so Knowledge hits come from `searchKnowledge()` and the server decides both
37
+ // what matches and what may be seen. Flows and Tools have no such endpoint — they are the same
38
+ // authorized lists the screens render, narrowed here in the client.
39
+ //
40
+ // That is safe in the one direction that matters: `listFlows()` returns what the caller may see
41
+ // (the flow repository's `listVisible`), and `listTools()` is one live `tools/list` with the user's
42
+ // own portal token. The client narrows a permitted set; it can never widen one, and no permission
43
+ // decision is taken here.
44
+ //
45
+ // When either list outgrows what a browser should hold, that is a new ticket for a search endpoint
46
+ // — not a silent regression of this one.
47
+ function matches(query: string, ...fields: (string | null)[]): boolean {
48
+ const needle = query.toLocaleLowerCase();
49
+ return fields.some((field) => field?.toLocaleLowerCase().includes(needle));
50
+ }
51
+
52
+ export function HeaderSearch() {
53
+ const { data, i18n } = useIntelRouterContext();
54
+ const navigate = useNavigate();
55
+ const [open, setOpen] = useState(false);
56
+ const [text, setText] = useState("");
57
+ const [kind, setKind] = useState<Kind | null>(null);
58
+ const query = text.trim();
59
+ const restoreTo = useRef<HTMLElement | null>(null);
60
+
61
+ function change(next: boolean) {
62
+ if (next) restoreTo.current = document.activeElement as HTMLElement | null;
63
+ setOpen(next);
64
+ }
65
+
66
+ // The same combination opens and closes the dialog. Radix does not swallow the key while it is
67
+ // open, so one toggling listener on the window covers both directions from every screen.
68
+ useEffect(() => {
69
+ function onKeyDown(event: KeyboardEvent) {
70
+ if (event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey) {
71
+ return;
72
+ }
73
+ event.preventDefault();
74
+ if (!open) restoreTo.current = document.activeElement as HTMLElement | null;
75
+ setOpen(!open);
76
+ }
77
+ window.addEventListener("keydown", onKeyDown);
78
+ return () => window.removeEventListener("keydown", onKeyDown);
79
+ }, [open]);
80
+
81
+ const shows = (candidate: Kind) => kind === null || kind === candidate;
82
+ // A filter that excludes a kind does not ask for it either: no request, and therefore no group
83
+ // that could show up empty.
84
+ const knowledge = useQuery({
85
+ queryKey: ["knowledge-search", query],
86
+ queryFn: () => data.searchKnowledge({ query, limit: ResultLimit }),
87
+ enabled: open && shows("knowledge") && query.length > 0,
88
+ });
89
+ // ⚠️ Deliberately the whole list, not one folder of the tree: a search that only saw the level
90
+ // someone happened to have open would miss the flow they are looking for. The sidebar tree asks
91
+ // per folder and keeps its own keys; only the Tools key is shared with the screen that fills it.
92
+ const flows = useQuery({
93
+ queryKey: ["flows"],
94
+ queryFn: () => data.listFlows(),
95
+ enabled: open && shows("flow") && query.length > 0,
96
+ });
97
+ const tools = useQuery({
98
+ queryKey: ["tools"],
99
+ queryFn: () => data.listTools(),
100
+ enabled: open && shows("tool") && query.length > 0,
101
+ });
102
+
103
+ const active = [
104
+ ...(shows("knowledge") ? [knowledge] : []),
105
+ ...(shows("flow") ? [flows] : []),
106
+ ...(shows("tool") ? [tools] : []),
107
+ ];
108
+ const searching = query.length > 0 && active.some((source) => source.isPending);
109
+ const failed = query.length > 0 && active.some((source) => source.isError);
110
+
111
+ const knowledgeHits = shows("knowledge") ? (knowledge.data?.items ?? []) : [];
112
+ const flowHits: Flow[] = shows("flow")
113
+ ? (flows.data?.items ?? [])
114
+ .filter((flow) => matches(query, flow.title, flow.description))
115
+ .slice(0, ResultLimit)
116
+ : [];
117
+ const toolHits: ToolCapability[] = shows("tool")
118
+ ? (tools.data?.items ?? [])
119
+ .filter((tool) => matches(query, tool.name, tool.title, tool.description))
120
+ .slice(0, ResultLimit)
121
+ : [];
122
+ const total = knowledgeHits.length + flowHits.length + toolHits.length;
123
+
124
+ function reach(hit: Kind, select: string) {
125
+ setOpen(false);
126
+ void navigate({ to: areaFor[hit], search: { select } });
127
+ }
128
+
129
+ return (
130
+ <>
131
+ <button
132
+ type="button"
133
+ onClick={() => change(true)}
134
+ aria-label={i18n.t("search.open")}
135
+ aria-keyshortcuts="Meta+K Control+K"
136
+ className="flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring sm:w-56"
137
+ >
138
+ <Search aria-hidden="true" className="size-4 shrink-0" />
139
+ <span className="truncate">{i18n.t("search.open")}</span>
140
+ {/* The hint repeats what `aria-keyshortcuts` already announces, so the accessible name
141
+ stays the label above rather than "Search ⌘K". */}
142
+ <kbd className="ml-auto hidden rounded border bg-muted px-1.5 font-sans text-[10px] sm:inline">
143
+ {isMac() ? "⌘K" : "Ctrl K"}
144
+ </kbd>
145
+ </button>
146
+
147
+ {/* ⚠️ Composed from `Dialog` and `Command` rather than from the registry's `CommandDialog`
148
+ for two reasons that both matter here: `CommandDialog` forwards its extra props to the
149
+ dialog root, so `shouldFilter` would never reach cmdk, and it renders its title outside
150
+ `DialogContent`, where it does not name the dialog for a screen reader. */}
151
+ <Dialog open={open} onOpenChange={change}>
152
+ <DialogContent
153
+ className="overflow-hidden p-0"
154
+ // ⚠️ Radix hands focus back to the dialog's trigger. This dialog is usually opened by a
155
+ // shortcut and has no trigger at all, so without this the caret would land on the body
156
+ // and a keyboard user would start over at the top of the page.
157
+ onCloseAutoFocus={(event) => {
158
+ event.preventDefault();
159
+ restoreTo.current?.focus();
160
+ }}
161
+ >
162
+ <DialogHeader className="sr-only">
163
+ <DialogTitle>{i18n.t("search.title")}</DialogTitle>
164
+ <DialogDescription>{i18n.t("search.description")}</DialogDescription>
165
+ </DialogHeader>
166
+ {/* Every source has already decided what matches — the server for Knowledge, the client
167
+ filter above for the two lists. Letting cmdk filter again would hide rows the server
168
+ returned, because a row's value is its identity rather than its text. */}
169
+ <Command shouldFilter={false}>
170
+ <CommandInput
171
+ value={text}
172
+ onValueChange={setText}
173
+ placeholder={i18n.t("search.placeholder")}
174
+ />
175
+ <fieldset className="flex flex-wrap gap-1 border-b px-3 py-2">
176
+ <legend className="sr-only">{i18n.t("search.filter")}</legend>
177
+ {[null, ...Kinds].map((candidate) => (
178
+ <button
179
+ key={candidate ?? "all"}
180
+ type="button"
181
+ aria-pressed={kind === candidate}
182
+ onClick={() => setKind(candidate)}
183
+ // ⚠️ cmdk turns Enter into "open the highlighted result" on the command root,
184
+ // without looking at where the key came from. Unstopped, Enter on a filter would
185
+ // leave the dialog for a hit instead of narrowing the list.
186
+ onKeyDown={(event) => {
187
+ if (event.key === "Enter" || event.key === " ") event.stopPropagation();
188
+ }}
189
+ className="rounded-full border px-2.5 py-1 text-xs outline-none aria-pressed:bg-primary aria-pressed:text-primary-foreground focus-visible:ring-2 focus-visible:ring-ring"
190
+ >
191
+ {i18n.t(candidate === null ? "search.all" : `search.kind.${candidate}`)}
192
+ </button>
193
+ ))}
194
+ </fieldset>
195
+ <CommandList>
196
+ {query.length === 0 ? (
197
+ <p className="py-6 text-center text-sm text-muted-foreground">
198
+ {i18n.t("search.hint")}
199
+ </p>
200
+ ) : null}
201
+ {/* Three states that never read as one another: still asking, could not ask, and asked
202
+ with nothing to show. A failing source is an alert even when another one answered. */}
203
+ {failed ? (
204
+ <div role="alert" className="space-y-3 px-3 py-6 text-center text-sm">
205
+ <p className="text-destructive">{i18n.t("search.failed")}</p>
206
+ <button
207
+ type="button"
208
+ onClick={() => {
209
+ for (const source of active) if (source.isError) void source.refetch();
210
+ }}
211
+ className="rounded-md border px-2.5 py-1 text-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
212
+ >
213
+ {i18n.t("common.retry")}
214
+ </button>
215
+ </div>
216
+ ) : null}
217
+ {searching ? (
218
+ <p role="status" className="py-6 text-center text-sm text-muted-foreground">
219
+ {i18n.t("search.searching")}
220
+ </p>
221
+ ) : null}
222
+ {!searching && !failed && query.length > 0 && total === 0 ? (
223
+ <CommandEmpty>{i18n.t("search.empty", { query })}</CommandEmpty>
224
+ ) : null}
225
+
226
+ {knowledgeHits.length > 0 ? (
227
+ <CommandGroup heading={i18n.t("search.kind.knowledge")}>
228
+ {knowledgeHits.map((citation) => (
229
+ <CommandItem
230
+ key={`${citation.nodeId}:${citation.versionId}`}
231
+ value={`knowledge:${citation.nodeId}:${citation.versionId}`}
232
+ onSelect={() => reach("knowledge", citation.nodeId)}
233
+ >
234
+ <FileText aria-hidden="true" />
235
+ <span className="min-w-0 flex-1">
236
+ <span className="block truncate">{citation.title}</span>
237
+ <span className="block truncate text-xs text-muted-foreground">
238
+ {citation.passage}
239
+ </span>
240
+ </span>
241
+ </CommandItem>
242
+ ))}
243
+ </CommandGroup>
244
+ ) : null}
245
+ {flowHits.length > 0 ? (
246
+ <CommandGroup heading={i18n.t("search.kind.flow")}>
247
+ {flowHits.map((flow) => (
248
+ <CommandItem
249
+ key={flow.id}
250
+ value={`flow:${flow.id}`}
251
+ onSelect={() => reach("flow", flow.id)}
252
+ >
253
+ <Workflow aria-hidden="true" />
254
+ <span className="min-w-0 flex-1">
255
+ <span className="block truncate">{flow.title}</span>
256
+ {flow.description ? (
257
+ <span className="block truncate text-xs text-muted-foreground">
258
+ {flow.description}
259
+ </span>
260
+ ) : null}
261
+ </span>
262
+ </CommandItem>
263
+ ))}
264
+ </CommandGroup>
265
+ ) : null}
266
+ {toolHits.length > 0 ? (
267
+ <CommandGroup heading={i18n.t("search.kind.tool")}>
268
+ {toolHits.map((tool) => (
269
+ <CommandItem
270
+ key={tool.name}
271
+ value={`tool:${tool.name}`}
272
+ onSelect={() => reach("tool", tool.name)}
273
+ >
274
+ <Wrench aria-hidden="true" />
275
+ <span className="min-w-0 flex-1">
276
+ <span className="block truncate">{tool.title ?? tool.name}</span>
277
+ <span className="block truncate font-mono text-xs text-muted-foreground">
278
+ {tool.name}
279
+ </span>
280
+ </span>
281
+ </CommandItem>
282
+ ))}
283
+ </CommandGroup>
284
+ ) : null}
285
+ </CommandList>
286
+ </Command>
287
+ </DialogContent>
288
+ </Dialog>
289
+ </>
290
+ );
291
+ }
@@ -0,0 +1,68 @@
1
+ import type {
2
+ SidebarPreferenceDeps,
3
+ SidebarPreferenceStore,
4
+ SidebarPreferences,
5
+ } from "./sidebar-preferences.types.ts";
6
+
7
+ // Below the minimum the tree is unreadable and the sidebar cannot be dragged back open by eye;
8
+ // above the maximum the content box stops being the larger half. Both are the reason the drag
9
+ // exists at all, so neither is configurable.
10
+ export const MinSidebarWidth = 180;
11
+ export const MaxSidebarWidth = 520;
12
+ export const DefaultSidebarWidth = 256;
13
+
14
+ const storageKey = "intel.sidebar";
15
+ const defaults: SidebarPreferences = { open: true, width: DefaultSidebarWidth };
16
+
17
+ export function clampSidebarWidth(width: number): number {
18
+ if (!Number.isFinite(width)) return DefaultSidebarWidth;
19
+ return Math.min(MaxSidebarWidth, Math.max(MinSidebarWidth, Math.round(width)));
20
+ }
21
+
22
+ function browserStorage(): Pick<Storage, "getItem" | "setItem"> | null {
23
+ // Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
24
+ // typeof check. Losing the preference is acceptable; losing the shell is not.
25
+ try {
26
+ return typeof window === "undefined" ? null : window.localStorage;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // The shell's own client state: not server data, so it stays out of TanStack Query, but it has to
33
+ // survive a page change — which in a SPA includes a full reload — so it is written through.
34
+ export function createSidebarPreferences(deps: SidebarPreferenceDeps = {}): SidebarPreferenceStore {
35
+ const storage = deps.storage === undefined ? browserStorage() : deps.storage;
36
+
37
+ return {
38
+ read() {
39
+ let raw: string | null = null;
40
+ try {
41
+ raw = storage?.getItem(storageKey) ?? null;
42
+ } catch {
43
+ return defaults;
44
+ }
45
+ if (raw === null) return defaults;
46
+ let parsed: unknown;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ } catch {
50
+ return defaults;
51
+ }
52
+ if (typeof parsed !== "object" || parsed === null) return defaults;
53
+ const value = parsed as Partial<SidebarPreferences>;
54
+ return {
55
+ open: typeof value.open === "boolean" ? value.open : defaults.open,
56
+ width: clampSidebarWidth(typeof value.width === "number" ? value.width : defaults.width),
57
+ };
58
+ },
59
+ write(preferences) {
60
+ try {
61
+ storage?.setItem(
62
+ storageKey,
63
+ JSON.stringify({ open: preferences.open, width: clampSidebarWidth(preferences.width) }),
64
+ );
65
+ } catch {}
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,15 @@
1
+ export interface SidebarPreferences {
2
+ open: boolean;
3
+ width: number;
4
+ }
5
+
6
+ export interface SidebarPreferenceStore {
7
+ read(): SidebarPreferences;
8
+ write(preferences: SidebarPreferences): void;
9
+ }
10
+
11
+ export interface SidebarPreferenceDeps {
12
+ // A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
13
+ // same thing: a test passes null, a browser without localStorage produces null on its own.
14
+ storage?: Pick<Storage, "getItem" | "setItem"> | null;
15
+ }
@@ -0,0 +1,86 @@
1
+ import { useRef } from "react";
2
+ import {
3
+ clampSidebarWidth,
4
+ MaxSidebarWidth,
5
+ MinSidebarWidth,
6
+ } from "@/app/sidebar-preferences/sidebar-preferences.ts";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const KeyboardStep = 16;
10
+
11
+ // The registry has no resizable sidebar, so this is the one piece of the shell that is not a
12
+ // primitive. It is an ARIA window splitter rather than a bare div: a drag-only handle exists for
13
+ // nobody on a keyboard, and the tree behind it is the entire navigation.
14
+ export function SidebarResizeHandle({
15
+ width,
16
+ label,
17
+ onWidthChange,
18
+ }: {
19
+ width: number;
20
+ label: string;
21
+ onWidthChange(width: number): void;
22
+ }) {
23
+ const drag = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null);
24
+
25
+ return (
26
+ // The WAI-ARIA window-splitter pattern is a focusable separator carrying a value. The rule's
27
+ // suggested `<hr>` cannot take focus, which would remove the keyboard operation this handle
28
+ // exists to provide.
29
+ // biome-ignore lint/a11y/useSemanticElements: a focusable window splitter cannot be an <hr>
30
+ <button
31
+ type="button"
32
+ role="separator"
33
+ aria-orientation="vertical"
34
+ aria-label={label}
35
+ aria-valuenow={width}
36
+ aria-valuemin={MinSidebarWidth}
37
+ aria-valuemax={MaxSidebarWidth}
38
+ className={cn(
39
+ "absolute inset-y-0 -right-1 z-20 hidden w-2 cursor-col-resize outline-none md:block",
40
+ "after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2",
41
+ "hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring",
42
+ "focus-visible:ring-2 focus-visible:ring-sidebar-ring",
43
+ // Collapsed there is no sidebar left to drag. The shell already unmounts the whole panel
44
+ // then; this keeps the handle from reappearing should it ever be mounted collapsed.
45
+ "group-data-[state=collapsed]:hidden",
46
+ )}
47
+ onPointerDown={(event) => {
48
+ drag.current = {
49
+ pointerId: event.pointerId,
50
+ startX: event.clientX,
51
+ startWidth: width,
52
+ };
53
+ event.currentTarget.setPointerCapture(event.pointerId);
54
+ }}
55
+ onPointerMove={(event) => {
56
+ const active = drag.current;
57
+ if (!active || active.pointerId !== event.pointerId) return;
58
+ // Measured against the width the drag started from, never against the live value: reading
59
+ // back the clamped width would make the handle drift away from the cursor at the limits.
60
+ onWidthChange(clampSidebarWidth(active.startWidth + (event.clientX - active.startX)));
61
+ }}
62
+ onPointerUp={(event) => {
63
+ drag.current = null;
64
+ event.currentTarget.releasePointerCapture(event.pointerId);
65
+ }}
66
+ onPointerCancel={() => {
67
+ drag.current = null;
68
+ }}
69
+ onKeyDown={(event) => {
70
+ const next =
71
+ event.key === "ArrowLeft"
72
+ ? width - KeyboardStep
73
+ : event.key === "ArrowRight"
74
+ ? width + KeyboardStep
75
+ : event.key === "Home"
76
+ ? MinSidebarWidth
77
+ : event.key === "End"
78
+ ? MaxSidebarWidth
79
+ : null;
80
+ if (next === null) return;
81
+ event.preventDefault();
82
+ onWidthChange(clampSidebarWidth(next));
83
+ }}
84
+ />
85
+ );
86
+ }