@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.
- package/components.json +7 -1
- package/package.json +3 -1
- package/src/app/action-slot/action-slot.tsx +23 -0
- package/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/src/app/app-tree/app-tree.tsx +798 -0
- package/src/app/app.tsx +99 -54
- package/src/app/header-search/header-search.tsx +291 -0
- package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/src/app/tree-move/tree-move.tsx +197 -0
- package/src/app/user-footer/user-footer.tsx +103 -0
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/collapsible.tsx +20 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +162 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +693 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
- package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +684 -368
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +174 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n/en.json +188 -52
- package/src/knowledge/knowledge.tsx +133 -709
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +129 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +580 -0
- package/src/router/router.tsx +4 -0
- package/src/router/selection-search.ts +43 -0
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +91 -51
- package/src/theme/theme.ts +24 -0
- package/src/tools/tools.tsx +175 -158
package/src/app/app.tsx
CHANGED
|
@@ -1,62 +1,107 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
</
|
|
57
|
-
|
|
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
|
+
}
|