@anchrd/intel-ui 0.8.1 → 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.1",
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",
@@ -147,6 +147,9 @@
147
147
  "tools.emptyHelp": "The portal answered, and it offers your account no tools. An administrator decides in the portal which servers exist and who may reach them.",
148
148
  "tools.unreachable": "Portal not reachable",
149
149
  "tools.unreachableHelp": "The company MCP portal did not answer, so Intel cannot tell which tools you have. Nothing is stored here; try again once the portal responds.",
150
+ "tools.connectExpired": "The portal sign-in took too long",
151
+ "tools.connectExpiredHelp": "The connection to the company MCP portal has to be completed within a few minutes, and this one ran out while the authorization screen was open. Nothing went wrong and nothing was refused — start it again and confirm the servers when the screen appears.",
152
+ "tools.connectRetry": "Start again",
150
153
  "tools.connectFailed": "Portal sign-in failed",
151
154
  "tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — reload to try again, and tell an administrator if it stays.",
152
155
  "tools.origin": "Origin",
@@ -154,6 +157,9 @@
154
157
  "tools.inputSchema": "Input schema",
155
158
  "tools.outputSchema": "Result schema",
156
159
  "tools.destructiveShort": "Destructive",
160
+ "tools.destructiveCount": "{count} destructive",
161
+ "tools.toolCount": "{count} tools",
162
+ "tools.toolCountOne": "1 tool",
157
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.",
158
164
  "tools.arguments": "Arguments",
159
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
  >
@@ -1,7 +1,7 @@
1
1
  import type { ToolCapability } from "@anchrd/intel-contract";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
- import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Wrench } from "lucide-react";
4
+ import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
5
5
  import * as React from "react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@@ -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
@@ -96,6 +120,19 @@ export function Tools() {
96
120
  ) : !catalog.data.portalConnected ? (
97
121
  signingIn ? (
98
122
  <p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
123
+ ) : connectError === "portal_sign_in_expired" ? (
124
+ // Ran out of time on the consent screen. Nothing is broken and nobody was refused, so
125
+ // neither of the other two sentences fits — and this one is the only case the reader
126
+ // can fix alone, by starting over (#95).
127
+ <Notice
128
+ icon={<Timer aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
129
+ title={i18n.t("tools.connectExpired")}
130
+ help={i18n.t("tools.connectExpiredHelp")}
131
+ >
132
+ <Button variant="outline" onClick={() => data.startPortalSignIn("/tools")}>
133
+ {i18n.t("tools.connectRetry")}
134
+ </Button>
135
+ </Notice>
99
136
  ) : accessDenied || !refused ? (
100
137
  // The end of the silent walk for somebody no Access policy carries. It is a sentence
101
138
  // about access, not an invitation to connect: there is nothing they could click that
@@ -127,19 +164,16 @@ export function Tools() {
127
164
  />
128
165
  ) : (
129
166
  <>
130
- <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
131
- <ul className="divide-y">
132
- {catalog.data.items.map((capability) => (
133
- <li key={capability.name}>
134
- <ToolEntry
135
- capability={capability}
136
- i18n={i18n}
137
- open={capability.name === requested}
138
- />
139
- </li>
140
- ))}
141
- </ul>
142
- </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>
143
177
  <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
144
178
  </>
145
179
  )}
@@ -174,6 +208,74 @@ function Notice({
174
208
  );
175
209
  }
176
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
+
177
279
  // One tool, one disclosure. The row is the only control in the list: it opens the schema and does
178
280
  // nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
179
281
  function ToolEntry({
@@ -185,11 +287,9 @@ function ToolEntry({
185
287
  i18n: I18n;
186
288
  open: boolean;
187
289
  }) {
188
- const origin = toolOrigin(capability.name);
189
-
190
290
  return (
191
291
  <Collapsible defaultOpen={open}>
192
- <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">
193
293
  <ChevronRight
194
294
  aria-hidden="true"
195
295
  className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
@@ -197,10 +297,6 @@ function ToolEntry({
197
297
  <span className="min-w-0 flex-1">
198
298
  <span className="flex flex-wrap items-center gap-2">
199
299
  <span className="text-sm font-medium">{capability.title ?? capability.name}</span>
200
- <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
201
- <span className="sr-only">{i18n.t("tools.origin")}: </span>
202
- {origin ?? i18n.t("tools.originPortal")}
203
- </span>
204
300
  {capability.annotations.destructiveHint && (
205
301
  <span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
206
302
  <AlertTriangle aria-hidden="true" className="size-3.5" />
@@ -215,14 +311,14 @@ function ToolEntry({
215
311
  {capability.name}
216
312
  </span>
217
313
  )}
218
- {capability.description && (
219
- <span className="mt-2 block text-xs text-muted-foreground">
220
- {capability.description}
221
- </span>
222
- )}
223
314
  </span>
224
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. */}
225
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
+ )}
226
322
  <Schema label={i18n.t("tools.inputSchema")} value={capability.inputSchema} />
227
323
  {capability.outputSchema && (
228
324
  <Schema label={i18n.t("tools.outputSchema")} value={capability.outputSchema} />