@anchrd/intel-ui 0.8.2 → 0.8.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -6,7 +6,6 @@ import {
6
6
  FileText,
7
7
  Folder,
8
8
  FolderOpen,
9
- Paperclip,
10
9
  Plus,
11
10
  Table,
12
11
  Upload,
@@ -36,6 +35,7 @@ import {
36
35
  } from "@/components/ui/sidebar";
37
36
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
38
37
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
38
+ import { kindIcons } from "@/kind-icon.ts";
39
39
  import { Modal } from "@/modal/modal.tsx";
40
40
  import { useIntelRouterContext } from "@/router/router-context.ts";
41
41
  import { selectedFrom } from "@/router/selection-search.ts";
@@ -43,14 +43,6 @@ import { selectedFrom } from "@/router/selection-search.ts";
43
43
  type NewKind = "folder" | "document" | "table" | "flow";
44
44
  type Creating = { parentId: string | null; kind: NewKind };
45
45
 
46
- const icons = {
47
- folder: Folder,
48
- document: FileText,
49
- attachment: Paperclip,
50
- table: Table,
51
- flow: Workflow,
52
- } as const;
53
-
54
46
  // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
55
47
  const MaxUploadBytes = 15_000_000;
56
48
 
@@ -385,7 +377,7 @@ export function AppTree() {
385
377
  const expandable = entry.openable;
386
378
  const isOpen =
387
379
  expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
388
- const Icon = isOpen && isFolder ? FolderOpen : icons[entry.kind];
380
+ const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
389
381
  const area = entry.type === "flow" ? "/flows" : "/knowledge";
390
382
  const isActive = location.select === entry.id && location.pathname === area;
391
383
  // A row's plus files into that row's place: inside a folder, beside anything else.
@@ -10,15 +10,17 @@ import {
10
10
  TableRow,
11
11
  } from "@/components/ui/table.tsx";
12
12
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
13
+ import { kindIcons } from "@/kind-icon.ts";
14
+ import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
13
15
  import { useIntelRouterContext } from "@/router/router-context.ts";
14
16
 
15
- const icons: Record<TreeEntry["kind"], string> = {
16
- folder: "knowledge.kind.folder",
17
- document: "knowledge.kind.document",
18
- attachment: "knowledge.kind.attachment",
19
- table: "knowledge.kind.table",
20
- flow: "knowledge.kind.flow",
21
- };
17
+ // The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
18
+ // second source of truth — a rename works on the whole record, not on a summary of it.
19
+ function targetOf(entry: TreeEntry): ResourceTarget {
20
+ return entry.type === "flow"
21
+ ? { type: "flow", flow: entry.flow }
22
+ : { type: "knowledge", node: entry.node };
23
+ }
22
24
 
23
25
  /**
24
26
  * A folder's own screen: what lies directly in it, as a table.
@@ -27,7 +29,13 @@ const icons: Record<TreeEntry["kind"], string> = {
27
29
  * `listTreeChildren`. Not a second call and not a second cache entry: two readers of one folder that
28
30
  * fetch separately show it differently the moment one of them is stale, and that is the kind of
29
31
  * difference a customer finds before anybody here does. The other half of the bargain is free —
30
- * every invalidation the tree already does after a create, a move or an archive lands here too.
32
+ * every invalidation the tree already does after a create, a move or an archive lands here too, and
33
+ * that now includes the ones this screen's own menu makes: `ResourceMenu` invalidates
34
+ * `["tree", parentId]`, which is this level.
35
+ *
36
+ * ⚠️ There is no "Kind" column since issue 101. The icon says what a row is, in the same picture the
37
+ * sidebar draws two centimetres to its left, and `sr-only` says it in words for everyone who does
38
+ * not read pictures. A column repeating the icon in prose costs the width the title needs.
31
39
  */
32
40
  export function FolderContents({ folderId }: { folderId: string }) {
33
41
  const { data, i18n } = useIntelRouterContext();
@@ -61,46 +69,75 @@ export function FolderContents({ folderId }: { folderId: string }) {
61
69
  }
62
70
 
63
71
  return (
64
- <div className="min-h-0 flex-1 overflow-y-auto p-4">
65
- <Table>
66
- <TableHeader>
67
- <TableRow>
68
- <TableHead>{i18n.t("common.title")}</TableHead>
69
- <TableHead>{i18n.t("knowledge.kind")}</TableHead>
70
- <TableHead>{i18n.t("knowledge.changed")}</TableHead>
71
- </TableRow>
72
- </TableHeader>
73
- <TableBody>
74
- {entries.map((entry) => {
75
- const changed = entry.type === "flow" ? entry.flow.updatedAt : entry.node.updatedAt;
76
- return (
77
- <TableRow key={entry.id}>
78
- {/* ⚠️ The button carries the whole cell rather than the cell carrying an onClick.
79
- A row that only reacts to a mouse is a row nobody can reach with a keyboard, and
80
- a `<tr onClick>` has no name to read out either. */}
81
- <TableCell className="p-0">
82
- <button
83
- type="button"
84
- onClick={() =>
85
- void navigate({
86
- to: entry.type === "flow" ? "/flows" : "/knowledge",
87
- search: { select: entry.id },
88
- })
89
- }
90
- className="w-full px-2 py-2 text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
91
- >
92
- {entry.title}
93
- </button>
94
- </TableCell>
95
- <TableCell className="text-muted-foreground">{i18n.t(icons[entry.kind])}</TableCell>
96
- <TableCell className="text-muted-foreground">
97
- {new Date(changed).toLocaleDateString()}
98
- </TableCell>
99
- </TableRow>
100
- );
101
- })}
102
- </TableBody>
103
- </Table>
72
+ <div className="min-h-0 flex-1 overflow-y-auto p-6">
73
+ <div className="overflow-hidden rounded-lg border bg-card">
74
+ <Table>
75
+ <TableHeader className="[&_tr]:bg-muted/40">
76
+ <TableRow className="hover:bg-transparent">
77
+ <TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
78
+ {i18n.t("common.title")}
79
+ </TableHead>
80
+ <TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
81
+ {i18n.t("knowledge.changed")}
82
+ </TableHead>
83
+ {/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
84
+ by its headers. The word is there; only the eye is spared it. */}
85
+ <TableHead className="h-9 w-14">
86
+ <span className="sr-only">{i18n.t("knowledge.rowActions")}</span>
87
+ </TableHead>
88
+ </TableRow>
89
+ </TableHeader>
90
+ <TableBody>
91
+ {entries.map((entry) => {
92
+ const changed = entry.type === "flow" ? entry.flow.updatedAt : entry.node.updatedAt;
93
+ const Icon = kindIcons[entry.kind];
94
+ return (
95
+ <TableRow key={entry.id} className="group/row hover:bg-muted">
96
+ {/* ⚠️ The button carries the whole cell rather than the cell carrying an onClick.
97
+ A row that only reacts to a mouse is a row nobody can reach with a keyboard, and
98
+ a `<tr onClick>` has no name to read out either. */}
99
+ <TableCell className="p-0">
100
+ <button
101
+ type="button"
102
+ onClick={() =>
103
+ void navigate({
104
+ to: entry.type === "flow" ? "/flows" : "/knowledge",
105
+ search: { select: entry.id },
106
+ })
107
+ }
108
+ className="flex w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
109
+ >
110
+ {/* The icon gets a tile of its own so five kinds under one another read as a
111
+ column, instead of five loose glyphs at five different widths. */}
112
+ <span
113
+ aria-hidden="true"
114
+ className="grid size-8 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
115
+ >
116
+ <Icon className="size-4" />
117
+ </span>
118
+ <span className="min-w-0 truncate font-medium group-hover/row:underline">
119
+ {entry.title}
120
+ </span>
121
+ {/* The icon carries the type on screen; this carries it everywhere else, and
122
+ it is why a flow needs no suffix in its name to be told from a document. */}
123
+ <span className="sr-only">{i18n.t(`knowledge.kind.${entry.kind}`)}</span>
124
+ </button>
125
+ </TableCell>
126
+ <TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
127
+ {new Date(changed).toLocaleDateString(i18n.locale)}
128
+ </TableCell>
129
+ {/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
130
+ for here. #58 collected every action in one place so that place is always the
131
+ same; a folder listing offering three of the six would be a second place. */}
132
+ <TableCell className="px-3 text-right">
133
+ <ResourceMenu target={targetOf(entry)} variant="row" />
134
+ </TableCell>
135
+ </TableRow>
136
+ );
137
+ })}
138
+ </TableBody>
139
+ </Table>
140
+ </div>
104
141
  </div>
105
142
  );
106
143
  }
package/src/i18n/en.json CHANGED
@@ -85,8 +85,8 @@
85
85
  "knowledge.select": "Select a document or folder to work with it.",
86
86
  "knowledge.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
87
87
  "knowledge.folderFailed": "This folder could not be read.",
88
- "knowledge.kind": "Kind",
89
88
  "knowledge.changed": "Changed",
89
+ "knowledge.rowActions": "Actions",
90
90
  "knowledge.kind.folder": "Folder",
91
91
  "knowledge.kind.document": "Document",
92
92
  "knowledge.kind.attachment": "Upload",
@@ -157,6 +157,9 @@
157
157
  "tools.inputSchema": "Input schema",
158
158
  "tools.outputSchema": "Result schema",
159
159
  "tools.destructiveShort": "Destructive",
160
+ "tools.destructiveCount": "{count} destructive",
161
+ "tools.toolCount": "{count} tools",
162
+ "tools.toolCountOne": "1 tool",
160
163
  "tools.liveNote": "This list is a live query with your own portal access. Intel stores neither the tools nor who may use them; the portal decides both.",
161
164
  "tools.arguments": "Arguments",
162
165
  "tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
@@ -0,0 +1,19 @@
1
+ import { FileText, Folder, type LucideIcon, Paperclip, Table, Workflow } from "lucide-react";
2
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
3
+
4
+ /**
5
+ * The one place a kind becomes a picture.
6
+ *
7
+ * ⚠️ Two screens draw the same row — the sidebar tree and the folder's own table — and they must
8
+ * draw it the same. A second map beside this one drifts the first time a kind is added: the tree
9
+ * would show the new icon and the folder screen the old one, for the same thing on the same screen.
10
+ * The picture is also only half the answer; both places still name the kind for a reader who cannot
11
+ * see it (`tree.kind.*`, `knowledge.kind.*`), because an icon has no text.
12
+ */
13
+ export const kindIcons: Record<TreeEntry["kind"], LucideIcon> = {
14
+ folder: Folder,
15
+ document: FileText,
16
+ attachment: Paperclip,
17
+ table: Table,
18
+ flow: Workflow,
19
+ };
@@ -102,11 +102,14 @@ export function resourceErrorKey(error: unknown): string {
102
102
  /**
103
103
  * The three-dot menu.
104
104
  *
105
- * `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
106
- * the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
107
- * thing in that line (#53). `row` is the sidebar form hidden until the row is hovered or something
108
- * in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
109
- * the only difference between the two places, and re-deriving it would be the harder half.
105
+ * `variant` is only how the trigger is painted, never which entries it holds. `title` is a plain
106
+ * icon button at the end of a title line, put there by `TitleRow` and by nobody else, so it is
107
+ * always the last thing in that line (#53). `row` is the form for a row in a list quiet until the
108
+ * row is hovered or something in it takes focus — and since issue 101 that list is the folder table.
109
+ *
110
+ * ⚠️ `row` needs a `group/row` on the row it sits in, or it stays invisible to the mouse: the
111
+ * reveal is `group-hover/row`, and a group that is not there never hovers. Below `md` and under
112
+ * focus it is shown regardless, because hover is not a gesture every input has.
110
113
  */
111
114
  export function ResourceMenu({
112
115
  target,
@@ -218,7 +221,7 @@ export function ResourceMenu({
218
221
  aria-label={i18n.t("resource.menu", { title })}
219
222
  className={
220
223
  variant === "row"
221
- ? "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
224
+ ? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
222
225
  : "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
223
226
  }
224
227
  >
@@ -19,6 +19,30 @@ function toolOrigin(name: string): string | null {
19
19
  return boundary > 0 ? name.slice(0, boundary) : null;
20
20
  }
21
21
 
22
+ interface ToolGroup {
23
+ // `null` is a tool the portal answers without a namespace: its origin is the portal itself.
24
+ origin: string | null;
25
+ items: ToolCapability[];
26
+ }
27
+
28
+ // The catalog arrives as one flat list of a hundred-odd names, and the namespace is the only thing
29
+ // that says which server a tool belongs to. Grouping is therefore not decoration — it is the one
30
+ // piece of structure the wire carries, made visible (#99).
31
+ //
32
+ // The order is the portal's own: a group appears where its first tool appears. Sorting here would
33
+ // replace an answer with a preference, and the portal already returns a server's tools together.
34
+ function groupByOrigin(items: ToolCapability[]): ToolGroup[] {
35
+ const groups = new Map<string, ToolGroup>();
36
+ for (const item of items) {
37
+ const origin = toolOrigin(item.name);
38
+ const key = origin ?? "";
39
+ const group = groups.get(key) ?? { origin, items: [] };
40
+ group.items.push(item);
41
+ groups.set(key, group);
42
+ }
43
+ return [...groups.values()];
44
+ }
45
+
22
46
  // Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
23
47
  // visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
24
48
  // the marker in the URL only survives until the next navigation — the browser session remembers it
@@ -140,19 +164,16 @@ export function Tools() {
140
164
  />
141
165
  ) : (
142
166
  <>
143
- <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
144
- <ul className="divide-y">
145
- {catalog.data.items.map((capability) => (
146
- <li key={capability.name}>
147
- <ToolEntry
148
- capability={capability}
149
- i18n={i18n}
150
- open={capability.name === requested}
151
- />
152
- </li>
153
- ))}
154
- </ul>
155
- </section>
167
+ <div className="space-y-3">
168
+ {groupByOrigin(catalog.data.items).map((group) => (
169
+ <ServerGroup
170
+ key={group.origin ?? ""}
171
+ group={group}
172
+ i18n={i18n}
173
+ requested={requested}
174
+ />
175
+ ))}
176
+ </div>
156
177
  <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
157
178
  </>
158
179
  )}
@@ -187,6 +208,74 @@ function Notice({
187
208
  );
188
209
  }
189
210
 
211
+ // One server, one box, closed until asked. Closed is the point: a hundred rows spread over a
212
+ // handful of servers is a list nobody reads, and which server a tool speaks to is the first thing
213
+ // somebody wants to know about it (#99).
214
+ function ServerGroup({
215
+ group,
216
+ i18n,
217
+ requested,
218
+ }: {
219
+ group: ToolGroup;
220
+ i18n: I18n;
221
+ requested: string | null;
222
+ }) {
223
+ // A hit from the header search names a tool, not a server, and the box it sits in has to open
224
+ // with it — otherwise Enter on a hit lands on a screen where nothing happened.
225
+ const holdsRequested = group.items.some((item) => item.name === requested);
226
+ const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
227
+ const label = group.origin ?? i18n.t("tools.originPortal");
228
+ const count =
229
+ group.items.length === 1
230
+ ? i18n.t("tools.toolCountOne")
231
+ : i18n.t("tools.toolCount", { count: group.items.length });
232
+ // The badges beside the name read as one run-on word when a screen reader concatenates them
233
+ // ("wiki1 tool"), so the control says its own name instead of being read off its contents.
234
+ const spoken = [`${i18n.t("tools.origin")}: ${label}`, count];
235
+ if (destructive > 0) spoken.push(i18n.t("tools.destructiveCount", { count: destructive }));
236
+
237
+ return (
238
+ <Collapsible defaultOpen={holdsRequested} asChild>
239
+ <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
240
+ <CollapsibleTrigger
241
+ aria-label={spoken.join(", ")}
242
+ className="group flex w-full items-center gap-3 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
243
+ >
244
+ <ChevronRight
245
+ aria-hidden="true"
246
+ className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
247
+ />
248
+ <span className="min-w-0 flex-1">
249
+ <span className="block truncate text-sm font-semibold">{label}</span>
250
+ </span>
251
+ {/* Closed, the box hides every destructive badge inside it. The count says so on the lid
252
+ rather than making somebody open each server to find out. */}
253
+ {destructive > 0 && (
254
+ <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
255
+ <AlertTriangle aria-hidden="true" className="size-3.5" />
256
+ {i18n.t("tools.destructiveCount", { count: destructive })}
257
+ </span>
258
+ )}
259
+ <span className="shrink-0 text-xs text-muted-foreground">{count}</span>
260
+ </CollapsibleTrigger>
261
+ <CollapsibleContent>
262
+ <ul className="divide-y border-t">
263
+ {group.items.map((capability) => (
264
+ <li key={capability.name}>
265
+ <ToolEntry
266
+ capability={capability}
267
+ i18n={i18n}
268
+ open={capability.name === requested}
269
+ />
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ </CollapsibleContent>
274
+ </section>
275
+ </Collapsible>
276
+ );
277
+ }
278
+
190
279
  // One tool, one disclosure. The row is the only control in the list: it opens the schema and does
191
280
  // nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
192
281
  function ToolEntry({
@@ -198,11 +287,9 @@ function ToolEntry({
198
287
  i18n: I18n;
199
288
  open: boolean;
200
289
  }) {
201
- const origin = toolOrigin(capability.name);
202
-
203
290
  return (
204
291
  <Collapsible defaultOpen={open}>
205
- <CollapsibleTrigger className="group flex w-full items-start gap-4 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
292
+ <CollapsibleTrigger className="group flex w-full items-start gap-4 px-5 py-3 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
206
293
  <ChevronRight
207
294
  aria-hidden="true"
208
295
  className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
@@ -210,10 +297,6 @@ function ToolEntry({
210
297
  <span className="min-w-0 flex-1">
211
298
  <span className="flex flex-wrap items-center gap-2">
212
299
  <span className="text-sm font-medium">{capability.title ?? capability.name}</span>
213
- <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
214
- <span className="sr-only">{i18n.t("tools.origin")}: </span>
215
- {origin ?? i18n.t("tools.originPortal")}
216
- </span>
217
300
  {capability.annotations.destructiveHint && (
218
301
  <span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
219
302
  <AlertTriangle aria-hidden="true" className="size-3.5" />
@@ -228,14 +311,14 @@ function ToolEntry({
228
311
  {capability.name}
229
312
  </span>
230
313
  )}
231
- {capability.description && (
232
- <span className="mt-2 block text-xs text-muted-foreground">
233
- {capability.description}
234
- </span>
235
- )}
236
314
  </span>
237
315
  </CollapsibleTrigger>
316
+ {/* ⚠️ The description belongs here, not in the closed row. Held open it was the whole reason
317
+ the list could not be scanned: a paragraph per tool, a hundred times over. */}
238
318
  <CollapsibleContent className="space-y-3 px-5 pb-4 pl-13">
319
+ {capability.description && (
320
+ <p className="text-xs text-muted-foreground">{capability.description}</p>
321
+ )}
239
322
  <Schema label={i18n.t("tools.inputSchema")} value={capability.inputSchema} />
240
323
  {capability.outputSchema && (
241
324
  <Schema label={i18n.t("tools.outputSchema")} value={capability.outputSchema} />