@anchrd/intel-ui 0.2.1 → 0.4.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 (33) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/app-sidebar/app-sidebar.tsx +56 -0
  4. package/src/app/app-tree/app-tree.tsx +427 -0
  5. package/src/app/app.tsx +84 -54
  6. package/src/app/header-actions/header-actions.tsx +15 -0
  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 +85 -0
  11. package/src/app/user-footer/user-footer.tsx +76 -0
  12. package/src/components/ui/breadcrumb.tsx +102 -0
  13. package/src/components/ui/button.tsx +64 -0
  14. package/src/components/ui/collapsible.tsx +20 -0
  15. package/src/components/ui/command.tsx +160 -0
  16. package/src/components/ui/dialog.tsx +143 -0
  17. package/src/components/ui/dropdown-menu.tsx +84 -0
  18. package/src/components/ui/input.tsx +21 -0
  19. package/src/components/ui/separator.tsx +26 -0
  20. package/src/components/ui/sheet.tsx +136 -0
  21. package/src/components/ui/sidebar.tsx +693 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/tooltip.tsx +51 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
  26. package/src/flows/flows.tsx +59 -142
  27. package/src/hooks/use-mobile.ts +19 -0
  28. package/src/i18n/en.json +48 -29
  29. package/src/knowledge/knowledge.tsx +133 -423
  30. package/src/router/router.tsx +4 -0
  31. package/src/router/selection-search.ts +19 -0
  32. package/src/styles.css +54 -51
  33. package/src/tools/tools.tsx +175 -158
@@ -0,0 +1,51 @@
1
+ import { Tooltip as TooltipPrimitive } from "radix-ui";
2
+ import type * as React from "react";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ function TooltipProvider({
7
+ delayDuration = 0,
8
+ ...props
9
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
10
+ return (
11
+ <TooltipPrimitive.Provider
12
+ data-slot="tooltip-provider"
13
+ delayDuration={delayDuration}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
20
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
21
+ }
22
+
23
+ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
24
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
25
+ }
26
+
27
+ function TooltipContent({
28
+ className,
29
+ sideOffset = 0,
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
33
+ return (
34
+ <TooltipPrimitive.Portal>
35
+ <TooltipPrimitive.Content
36
+ data-slot="tooltip-content"
37
+ sideOffset={sideOffset}
38
+ className={cn(
39
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
46
+ </TooltipPrimitive.Content>
47
+ </TooltipPrimitive.Portal>
48
+ );
49
+ }
50
+
51
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -16,6 +16,7 @@ import {
16
16
  KnowledgeNode,
17
17
  KnowledgeNodeList,
18
18
  KnowledgeVersionList,
19
+ ListFlowsInput,
19
20
  ListKnowledgeNodesInput,
20
21
  ProblemDetails,
21
22
  PublishFlowInput,
@@ -29,26 +30,22 @@ import {
29
30
  SaveKnowledgeVersionInput,
30
31
  SearchKnowledgeInput,
31
32
  SearchKnowledgeResult,
33
+ SessionUser,
32
34
  ShareFlowInput,
33
35
  ShareKnowledgeInput,
34
36
  StartFlowRunInput,
35
- TestToolInput,
36
37
  ToolCatalog,
37
- ToolTestResult,
38
+ UpdateFlowInput,
38
39
  UpdateKnowledgeNodeInput,
39
40
  } from "@anchrd/intel-contract";
40
41
  import { z } from "zod";
41
- import type { IntelDataProvider, KnowledgeTreeNode } from "./intel-data-provider.types.ts";
42
+ import type { IntelDataProvider, TreeEntry } from "./intel-data-provider.types.ts";
42
43
 
43
44
  const DeleteResult = z.strictObject({ deleted: z.boolean() });
44
45
 
45
46
  // Attachment uploads and downloads share this budget, so it is generous rather than snappy.
46
47
  const RequestTimeoutMs = 60_000;
47
48
 
48
- // Deeper nesting than this is not a tree anyone navigates; the limit exists to bound the request
49
- // fan-out on first paint, not to express a product rule.
50
- const MaxTreeDepth = 12;
51
-
52
49
  // The Worker serves the login route, so the path belongs to the data layer rather than to any view.
53
50
  export function loginPath(returnTo?: string): string {
54
51
  return returnTo ? `/auth/login?returnTo=${encodeURIComponent(returnTo)}` : "/auth/login";
@@ -106,6 +103,17 @@ export function createIntelDataProvider(
106
103
  return schema.parse(await (await response(path, init)).json());
107
104
  }
108
105
 
106
+ async function listFlows(input: ListFlowsInput = {}): Promise<z.infer<typeof FlowList>> {
107
+ const query = ListFlowsInput.parse(input);
108
+ // The parameter is only sent when the caller asked for one folder. Sending an empty one for
109
+ // "everything" would ask the server for the root instead.
110
+ const params = new URLSearchParams(
111
+ query.parentId === undefined ? {} : { parentId: query.parentId ?? "" },
112
+ );
113
+ const search = params.toString();
114
+ return await request(`/flows${search ? `?${search}` : ""}`, FlowList);
115
+ }
116
+
109
117
  async function listKnowledge(
110
118
  input: Partial<ListKnowledgeNodesInput> = {},
111
119
  ): Promise<z.infer<typeof KnowledgeNodeList>> {
@@ -115,31 +123,43 @@ export function createIntelDataProvider(
115
123
  return await request(`/knowledge?${params}`, KnowledgeNodeList);
116
124
  }
117
125
 
118
- // One request per folder, so a deep tree is an N+1 on the first paint. The depth limit bounds that
119
- // and, more importantly, makes a cyclic parentId impossible to hang on: without it a cycle would
120
- // recurse until the tab dies. `seen` stops a cycle at the first repeat rather than at the limit.
121
- async function loadChildren(
122
- parentId: string | null,
123
- depth = 0,
124
- seen: ReadonlySet<string> = new Set(),
125
- ): Promise<KnowledgeTreeNode[]> {
126
- if (depth >= MaxTreeDepth) return [];
127
- const { items } = await listKnowledge({ parentId });
128
- return await Promise.all(
129
- items.map(async (node) => ({
130
- ...node,
131
- children:
132
- node.kind === "folder" && !seen.has(node.id)
133
- ? await loadChildren(node.id, depth + 1, new Set([...seen, node.id]))
134
- : [],
135
- })),
136
- );
137
- }
138
-
139
126
  return {
127
+ async getSession() {
128
+ return await request("/session", SessionUser);
129
+ },
140
130
  listKnowledge,
141
- async loadKnowledgeTree() {
142
- return await loadChildren(null);
131
+ async listTreeChildren(parentId) {
132
+ // Both sides of one folder, asked for in parallel and merged here rather than on the server:
133
+ // the folder is shared, the two records are not, and no endpoint may return a row that is a
134
+ // bit of both (ADR-0004).
135
+ const [knowledge, flows] = await Promise.all([
136
+ listKnowledge({ parentId }),
137
+ listFlows({ parentId }),
138
+ ]);
139
+ const entries: TreeEntry[] = [
140
+ ...knowledge.items.map((node) => ({
141
+ type: "knowledge" as const,
142
+ id: node.id,
143
+ title: node.title,
144
+ kind: node.kind,
145
+ node,
146
+ })),
147
+ ...flows.items.map((flow) => ({
148
+ type: "flow" as const,
149
+ id: flow.id,
150
+ title: flow.title,
151
+ kind: "flow" as const,
152
+ flow,
153
+ })),
154
+ ];
155
+ // Folders first, then everything else by title: a document and a flow sit side by side, and
156
+ // the icon is what tells them apart.
157
+ return entries.sort(
158
+ (left, right) =>
159
+ Number(right.kind === "folder") - Number(left.kind === "folder") ||
160
+ left.title.localeCompare(right.title) ||
161
+ left.id.localeCompare(right.id),
162
+ );
143
163
  },
144
164
  async getKnowledge(nodeId) {
145
165
  return await request(`/knowledge/${encodeURIComponent(nodeId)}`, KnowledgeDocument);
@@ -240,9 +260,7 @@ export function createIntelDataProvider(
240
260
  { method: "POST", body: JSON.stringify(parsed) },
241
261
  );
242
262
  },
243
- async listFlows() {
244
- return await request("/flows", FlowList);
245
- },
263
+ listFlows,
246
264
  async getFlow(flowId) {
247
265
  return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
248
266
  },
@@ -252,6 +270,13 @@ export function createIntelDataProvider(
252
270
  body: JSON.stringify(CreateFlowInput.parse(input)),
253
271
  });
254
272
  },
273
+ async updateFlow(input) {
274
+ const parsed = UpdateFlowInput.parse(input);
275
+ return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, Flow, {
276
+ method: "PATCH",
277
+ body: JSON.stringify(parsed),
278
+ });
279
+ },
255
280
  async listFlowGrants(flowId) {
256
281
  return await request(`/flows/${encodeURIComponent(flowId)}/grants`, ResourceGrantList);
257
282
  },
@@ -308,12 +333,6 @@ export function createIntelDataProvider(
308
333
  async listTools() {
309
334
  return await request("/tools", ToolCatalog);
310
335
  },
311
- async testTool(input) {
312
- return await request("/tools/test", ToolTestResult, {
313
- method: "POST",
314
- body: JSON.stringify(TestToolInput.parse(input)),
315
- });
316
- },
317
336
 
318
337
  portalConnectUrl(returnTo = "/tools") {
319
338
  return `${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}`;
@@ -14,8 +14,10 @@ import type {
14
14
  KnowledgeLink,
15
15
  KnowledgeLinkList,
16
16
  KnowledgeNode,
17
+ KnowledgeNodeKind,
17
18
  KnowledgeNodeList,
18
19
  KnowledgeVersionList,
20
+ ListFlowsInput,
19
21
  ListKnowledgeNodesInput,
20
22
  PublishFlowInput,
21
23
  ResourceGrant,
@@ -28,22 +30,27 @@ import type {
28
30
  SaveKnowledgeVersionInput,
29
31
  SearchKnowledgeInput,
30
32
  SearchKnowledgeResult,
33
+ SessionUser,
31
34
  ShareFlowInput,
32
35
  ShareKnowledgeInput,
33
36
  StartFlowRunInput,
34
- TestToolInput,
35
37
  ToolCatalog,
36
- ToolTestResult,
38
+ UpdateFlowInput,
37
39
  UpdateKnowledgeNodeInput,
38
40
  } from "@anchrd/intel-contract";
39
41
 
40
- export interface KnowledgeTreeNode extends KnowledgeNode {
41
- children: KnowledgeTreeNode[];
42
- }
42
+ // One row of the shared tree. Knowledge and Flows share the folder, not their nature (ADR-0004), so
43
+ // this is a union that keeps each side's record whole — never a merged "node" that is a bit of both.
44
+ export type TreeEntry =
45
+ | { type: "knowledge"; id: string; title: string; kind: KnowledgeNodeKind; node: KnowledgeNode }
46
+ | { type: "flow"; id: string; title: string; kind: "flow"; flow: Flow };
43
47
 
44
48
  export interface IntelDataProvider {
49
+ getSession(): Promise<SessionUser>;
45
50
  listKnowledge(input?: Partial<ListKnowledgeNodesInput>): Promise<KnowledgeNodeList>;
46
- loadKnowledgeTree(): Promise<KnowledgeTreeNode[]>;
51
+ // One level of the shared tree: the documents and the flows filed in the same folder, in one
52
+ // sorted list. Per level rather than recursive, so opening a folder is what costs a request.
53
+ listTreeChildren(parentId: string | null): Promise<TreeEntry[]>;
47
54
  getKnowledge(nodeId: string): Promise<KnowledgeDocument>;
48
55
  createKnowledge(input: CreateKnowledgeNodeInput): Promise<KnowledgeNode>;
49
56
  getKnowledgeGraph(limit?: number): Promise<KnowledgeGraph>;
@@ -61,9 +68,10 @@ export interface IntelDataProvider {
61
68
  listKnowledgeGrants(resourceId: string): Promise<ResourceGrantList>;
62
69
  shareKnowledge(input: ShareKnowledgeInput): Promise<ResourceGrant>;
63
70
  revokeKnowledgeGrant(input: RevokeKnowledgeGrantInput): Promise<RevokeGrantResult>;
64
- listFlows(): Promise<FlowList>;
71
+ listFlows(input?: ListFlowsInput): Promise<FlowList>;
65
72
  getFlow(flowId: string): Promise<FlowDocument>;
66
73
  createFlow(input: CreateFlowInput): Promise<Flow>;
74
+ updateFlow(input: UpdateFlowInput): Promise<Flow>;
67
75
  listFlowGrants(flowId: string): Promise<ResourceGrantList>;
68
76
  shareFlow(input: ShareFlowInput): Promise<ResourceGrant>;
69
77
  revokeFlowGrant(input: RevokeFlowGrantInput): Promise<RevokeGrantResult>;
@@ -72,7 +80,8 @@ export interface IntelDataProvider {
72
80
  startFlow(input: StartFlowRunInput): Promise<FlowRunStep>;
73
81
  getFlowRun(runId: string): Promise<FlowRunStep>;
74
82
  completeFlowStep(input: CompleteFlowRunStepInput): Promise<FlowRunStep>;
83
+ // Reading the catalog is the whole of the tool surface here: calling a tool belongs to a flow
84
+ // or to the Intel MCP surface, not to the screen that shows what the portal offers.
75
85
  listTools(): Promise<ToolCatalog>;
76
- testTool(input: TestToolInput): Promise<ToolTestResult>;
77
86
  logout(): Promise<void>;
78
87
  }
@@ -1,5 +1,6 @@
1
- import type { FlowGraph, FlowNode, ResourceRole } from "@anchrd/intel-contract";
1
+ import type { FlowGraph, FlowNode, KnowledgeNode, ResourceRole } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useRouterState } from "@tanstack/react-router";
3
4
  import {
4
5
  addEdge,
5
6
  applyEdgeChanges,
@@ -23,7 +24,6 @@ import {
23
24
  CirclePlay,
24
25
  FileSearch,
25
26
  GitBranch,
26
- Plus,
27
27
  Save,
28
28
  Send,
29
29
  Share2,
@@ -33,9 +33,10 @@ import {
33
33
  Wrench,
34
34
  } from "lucide-react";
35
35
  import { useEffect, useMemo, useState } from "react";
36
- import type { KnowledgeTreeNode } from "@/data/intel-data-provider/intel-data-provider.types.ts";
36
+ import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
37
37
  import { Modal } from "@/modal/modal.tsx";
38
38
  import { useIntelRouterContext } from "@/router/router-context.ts";
39
+ import { selectedFrom } from "@/router/selection-search.ts";
39
40
 
40
41
  type CanvasNode = Node<{ node: FlowNode }, "intel">;
41
42
  type CanvasEdge = Edge;
@@ -229,21 +230,21 @@ function newNode(
229
230
  export function Flows() {
230
231
  const { data, i18n } = useIntelRouterContext();
231
232
  const queryClient = useQueryClient();
232
- const flows = useQuery({
233
- queryKey: ["flows"],
234
- queryFn: () => data.listFlows(),
235
- });
236
233
  const tools = useQuery({
237
234
  queryKey: ["tools"],
238
235
  queryFn: () => data.listTools(),
239
236
  });
237
+ // A knowledge step points at nodes, so the editor needs candidates. The graph is the authorized
238
+ // flat list of them; walking the folder tree for a picker would be the N+1 all over again.
240
239
  const knowledge = useQuery({
241
- queryKey: ["knowledge-tree"],
242
- queryFn: () => data.loadKnowledgeTree(),
240
+ queryKey: ["knowledge-graph"],
241
+ queryFn: () => data.getKnowledgeGraph(),
242
+ });
243
+ // The tree in the sidebar and the header search both name the flow to open through `?select=`.
244
+ const selectedFlowId = useRouterState({
245
+ select: (state) => selectedFrom(state.location.search),
243
246
  });
244
- const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
245
247
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
246
- const [creating, setCreating] = useState(false);
247
248
  const [sharing, setSharing] = useState(false);
248
249
  const document = useQuery({
249
250
  queryKey: ["flow", selectedFlowId],
@@ -274,9 +275,8 @@ export function Flows() {
274
275
  graph: graph(nodes, edges),
275
276
  idempotencyKey: crypto.randomUUID(),
276
277
  }),
277
- onSuccess: async (saved) => {
278
+ onSuccess: (saved) => {
278
279
  queryClient.setQueryData(["flow", saved.flow.id], saved);
279
- await queryClient.invalidateQueries({ queryKey: ["flows"] });
280
280
  },
281
281
  });
282
282
  const publish = useMutation({
@@ -287,10 +287,7 @@ export function Flows() {
287
287
  idempotencyKey: crypto.randomUUID(),
288
288
  }),
289
289
  onSuccess: async () => {
290
- await Promise.all([
291
- queryClient.invalidateQueries({ queryKey: ["flows"] }),
292
- queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
293
- ]);
290
+ await queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] });
294
291
  },
295
292
  });
296
293
  const run = useMutation({
@@ -311,80 +308,56 @@ export function Flows() {
311
308
  }
312
309
 
313
310
  return (
314
- <div className="flex h-screen min-h-0 flex-col">
315
- <header className="flex items-center justify-between gap-6 border-b px-8 py-5">
316
- <div>
317
- <h1 className="text-xl font-semibold tracking-tight">{i18n.t("flows.title")}</h1>
318
- <p className="mt-1 text-sm text-muted-foreground">{i18n.t("flows.description")}</p>
319
- </div>
320
- <div className="flex items-center gap-2">
321
- {selectedFlowId && (
322
- <>
323
- <button
324
- type="button"
325
- onClick={() => setSharing(true)}
326
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
327
- >
328
- <Share2 aria-hidden="true" className="size-4" /> {i18n.t("flows.share")}
329
- </button>
330
- <button
331
- type="button"
332
- onClick={() => save.mutate()}
333
- disabled={!canMutate || save.isPending}
334
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
335
- >
336
- <Save aria-hidden="true" className="size-4" /> {i18n.t("common.save")}
337
- </button>
338
- <button
339
- type="button"
340
- onClick={() => publish.mutate()}
341
- disabled={!canMutate || !document.data?.flow.currentVersionId || publish.isPending}
342
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
343
- >
344
- <Send aria-hidden="true" className="size-4" /> {i18n.t("flows.publish")}
345
- </button>
346
- <button
347
- type="button"
348
- onClick={() => run.mutate()}
349
- disabled={!canMutate || !document.data?.flow.publishedVersionId || run.isPending}
350
- className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
351
- >
352
- <Bot aria-hidden="true" className="size-4" /> {i18n.t("flows.run")}
353
- </button>
354
- </>
355
- )}
311
+ <div className="flex h-full min-h-0 flex-col">
312
+ {/* A flow's own actions, in the shell's action bar. Creating a flow is not among them: that
313
+ belongs to the tree, at the folder the flow is meant to live in. */}
314
+ {selectedFlowId ? (
315
+ <HeaderActions>
356
316
  <button
357
317
  type="button"
358
- onClick={() => setCreating(true)}
359
- className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
318
+ onClick={() => setSharing(true)}
319
+ className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
360
320
  >
361
- <Plus aria-hidden="true" className="size-4" /> {i18n.t("flows.new")}
321
+ <Share2 aria-hidden="true" className="size-4" /> {i18n.t("flows.share")}
362
322
  </button>
363
- </div>
364
- </header>
323
+ <button
324
+ type="button"
325
+ onClick={() => save.mutate()}
326
+ disabled={!canMutate || save.isPending}
327
+ className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
328
+ >
329
+ <Save aria-hidden="true" className="size-4" /> {i18n.t("common.save")}
330
+ </button>
331
+ <button
332
+ type="button"
333
+ onClick={() => publish.mutate()}
334
+ disabled={!canMutate || !document.data?.flow.currentVersionId || publish.isPending}
335
+ className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
336
+ >
337
+ <Send aria-hidden="true" className="size-4" /> {i18n.t("flows.publish")}
338
+ </button>
339
+ <button
340
+ type="button"
341
+ onClick={() => run.mutate()}
342
+ disabled={!canMutate || !document.data?.flow.publishedVersionId || run.isPending}
343
+ className="inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
344
+ >
345
+ <Bot aria-hidden="true" className="size-4" /> {i18n.t("flows.run")}
346
+ </button>
347
+ </HeaderActions>
348
+ ) : null}
365
349
  <div className="flex min-h-0 flex-1">
366
- <aside className="w-64 shrink-0 overflow-y-auto border-r bg-muted/20 p-3">
367
- {flows.data?.items.map((flow) => (
368
- <button
369
- key={flow.id}
370
- type="button"
371
- onClick={() => setSelectedFlowId(flow.id)}
372
- className={`mb-1 w-full rounded-md p-3 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring ${selectedFlowId === flow.id ? "bg-accent text-accent-foreground" : ""}`}
373
- >
374
- <span className="block truncate text-sm font-medium">{flow.title}</span>
375
- <span className="mt-1 block text-xs text-muted-foreground">
376
- {flow.publishedVersionId ? i18n.t("flows.published") : i18n.t("flows.draft")}
377
- </span>
378
- </button>
379
- ))}
380
- {flows.data?.items.length === 0 && (
381
- <p className="p-3 text-sm text-muted-foreground">{i18n.t("flows.empty")}</p>
382
- )}
383
- </aside>
384
350
  {!selectedFlowId ? (
385
351
  <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
386
352
  {i18n.t("flows.select")}
387
353
  </div>
354
+ ) : document.isError ? (
355
+ <div
356
+ role="alert"
357
+ className="grid flex-1 place-items-center p-8 text-center text-sm text-destructive"
358
+ >
359
+ {i18n.t("flows.operationFailed")}
360
+ </div>
388
361
  ) : !documentReady ? (
389
362
  <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
390
363
  {i18n.t("common.loading")}
@@ -402,7 +375,7 @@ export function Flows() {
402
375
  key={kind}
403
376
  type="button"
404
377
  onClick={() => {
405
- const item = newNode(kind, nodes.length, knowledge.data?.[0]?.id);
378
+ const item = newNode(kind, nodes.length, knowledge.data?.nodes[0]?.id);
406
379
  setNodes((current) => [
407
380
  ...current,
408
381
  {
@@ -449,7 +422,7 @@ export function Flows() {
449
422
  node={selectedNode}
450
423
  update={updateNode}
451
424
  tools={tools.data?.items ?? []}
452
- knowledge={knowledge.data ?? []}
425
+ knowledge={knowledge.data?.nodes ?? []}
453
426
  />
454
427
  </>
455
428
  )}
@@ -470,7 +443,6 @@ export function Flows() {
470
443
  {i18n.t("flows.operationFailed")}
471
444
  </p>
472
445
  )}
473
- {creating && <CreateFlow close={() => setCreating(false)} select={setSelectedFlowId} />}
474
446
  {sharing && selectedFlowId && (
475
447
  <ShareFlow flowId={selectedFlowId} close={() => setSharing(false)} />
476
448
  )}
@@ -599,7 +571,7 @@ function NodeInspector({
599
571
  node: CanvasNode | null;
600
572
  update(fn: (node: FlowNode) => FlowNode): void;
601
573
  tools: Array<{ name: string; title: string | null; fingerprint: string }>;
602
- knowledge: KnowledgeTreeNode[];
574
+ knowledge: KnowledgeNode[];
603
575
  }) {
604
576
  const { i18n } = useIntelRouterContext();
605
577
  if (!node)
@@ -659,11 +631,10 @@ function NodeInspector({
659
631
  <>
660
632
  <fieldset className="mt-4 rounded-lg border p-3">
661
633
  <legend className="px-1 text-sm font-medium">{i18n.t("flows.knowledgeIds")}</legend>
662
- {flattenKnowledge(knowledge).map((item) => (
634
+ {knowledge.map((item) => (
663
635
  <label
664
636
  key={item.id}
665
637
  className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted"
666
- style={{ paddingLeft: `${item.depth * 12 + 8}px` }}
667
638
  >
668
639
  <input
669
640
  type="checkbox"
@@ -812,16 +783,6 @@ function ToolArgumentsEditor({
812
783
  );
813
784
  }
814
785
 
815
- function flattenKnowledge(
816
- nodes: KnowledgeTreeNode[],
817
- depth = 0,
818
- ): Array<{ id: string; title: string; depth: number }> {
819
- return nodes.flatMap((node) => [
820
- { id: node.id, title: node.title, depth },
821
- ...flattenKnowledge(node.children, depth + 1),
822
- ]);
823
- }
824
-
825
786
  function Field({
826
787
  label,
827
788
  value,
@@ -863,47 +824,3 @@ function TextArea({
863
824
  </label>
864
825
  );
865
826
  }
866
-
867
- function CreateFlow({ close, select }: { close(): void; select(id: string): void }) {
868
- const { data, i18n } = useIntelRouterContext();
869
- const queryClient = useQueryClient();
870
- const [title, setTitle] = useState("");
871
- const create = useMutation({
872
- mutationFn: () =>
873
- data.createFlow({
874
- title,
875
- description: null,
876
- idempotencyKey: crypto.randomUUID(),
877
- }),
878
- onSuccess: async (flow) => {
879
- await queryClient.invalidateQueries({ queryKey: ["flows"] });
880
- select(flow.id);
881
- close();
882
- },
883
- });
884
- return (
885
- <Modal title={i18n.t("flows.new")} close={close}>
886
- <form
887
- onSubmit={(event) => {
888
- event.preventDefault();
889
- if (create.isPending) return;
890
- create.mutate();
891
- }}
892
- >
893
- <Field label={i18n.t("common.title")} value={title} setValue={setTitle} />
894
- {create.isError ? (
895
- <p role="alert" className="mt-3 text-sm text-destructive">
896
- {i18n.t("flows.operationFailed")}
897
- </p>
898
- ) : null}
899
- <button
900
- type="submit"
901
- disabled={create.isPending || title.trim().length === 0}
902
- className="mt-5 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
903
- >
904
- {create.isPending ? i18n.t("common.saving") : i18n.t("common.create")}
905
- </button>
906
- </form>
907
- </Modal>
908
- );
909
- }
@@ -0,0 +1,19 @@
1
+ import * as React from "react";
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12
+ };
13
+ mql.addEventListener("change", onChange);
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15
+ return () => mql.removeEventListener("change", onChange);
16
+ }, []);
17
+
18
+ return !!isMobile;
19
+ }