@anchrd/intel-ui 0.5.0 → 0.7.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.
@@ -1,6 +1,8 @@
1
+ import { flowNodeLayer } from "@anchrd/intel-contract";
1
2
  import { Plus, X } from "lucide-react";
2
- import { useId, useRef, useState } from "react";
3
+ import { Fragment, useId, useRef, useState } from "react";
3
4
  import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
5
+ import { cn } from "@/lib/utils.ts";
4
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
5
7
  import type { NodePaletteProps, PaletteKind } from "./node-palette.types.ts";
6
8
 
@@ -111,11 +113,29 @@ export function NodePalette<K extends PaletteKind>({
111
113
  }
112
114
  }
113
115
 
114
- // ⚠️ `w-max` below: an absolutely positioned box shrink-wraps its widest child, and collapsed that
115
- // is the 36px trigger. Without it the open bar inherits those 36px as its own maximum and folds
116
- // seven entries into a single column — the flex row wraps after every item.
116
+ // Open, the trigger is the first segment of the bar itself rather than a second box above it: one
117
+ // closes a bar where it is. Two details carry that and neither is decoration:
118
+ //
119
+ // ⚠️ The negative margin is the frame's own padding plus its border. Opening hands the frame from
120
+ // the trigger to the row around it, and a frame drawn inside would push the trigger down and right
121
+ // by exactly that sum. The margin lets the row grow outwards instead, so the 36px one clicks stays
122
+ // on the same pixel open and collapsed. Change `p-1` and this has to change with it.
123
+ //
124
+ // ⚠️ The toolbar stays an element of its own inside the row: it is what `aria-controls` names and
125
+ // what the arrow keys walk, and the trigger must not become an eighth entry in that ring.
126
+ //
127
+ // The `w-max` that used to stand here went with the structure it guarded. While the bar was a
128
+ // block below the trigger, the shrink-wrapped box took the collapsed 36px as its maximum and
129
+ // folded the entries into a column; one flex row is sized from its content in either state, which
130
+ // was measured at 1280px and at 420px with the class and without it (#55).
117
131
  return (
118
- <div className="absolute left-4 top-4 z-10 w-max max-w-[calc(100%-2rem)]">
132
+ <div
133
+ className={cn(
134
+ "absolute left-4 top-4 z-10 max-w-[calc(100%-2rem)]",
135
+ open &&
136
+ "-m-[calc(0.25rem+1px)] flex flex-wrap items-center gap-1 rounded-lg border bg-card/95 p-1 shadow-sm backdrop-blur",
137
+ )}
138
+ >
119
139
  <button
120
140
  ref={triggerRef}
121
141
  type="button"
@@ -123,7 +143,10 @@ export function NodePalette<K extends PaletteKind>({
123
143
  aria-controls={listId}
124
144
  onClick={() => (open ? close() : setOpen(true))}
125
145
  onKeyDown={onTriggerKeyDown}
126
- className="inline-flex size-9 items-center justify-center rounded-lg border bg-card/95 text-card-foreground shadow-sm outline-none backdrop-blur hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
146
+ className={cn(
147
+ "inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-card-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
148
+ !open && "border bg-card/95 shadow-sm backdrop-blur",
149
+ )}
127
150
  >
128
151
  {open ? (
129
152
  <X aria-hidden="true" className="size-4" />
@@ -143,28 +166,45 @@ export function NodePalette<K extends PaletteKind>({
143
166
  aria-orientation="horizontal"
144
167
  aria-label={i18n.t("flows.nodePalette")}
145
168
  onKeyDown={onListKeyDown}
146
- className="mt-2 flex max-w-full flex-wrap gap-1 rounded-lg border bg-card/95 p-2 shadow-sm backdrop-blur"
169
+ // `min-w-0` so a narrow bar makes the entries wrap among themselves instead of forcing the
170
+ // whole row wider than the canvas allows.
171
+ className="flex min-w-0 max-w-full flex-wrap gap-1"
147
172
  >
148
173
  {kinds.map((kind, index) => {
149
174
  const Icon: NodeIcon = nodeIcon[kind];
150
175
  const reason = disabledReason?.(kind) ?? null;
176
+ // ⚠️ A separator before the first entry of each layer after the first (D25). It is
177
+ // decoration, not structure: the toolbar's keyboard walks `[data-palette-item]`, so a
178
+ // divider in between must not be one — otherwise the arrows would stop on a line.
179
+ const opensLayer =
180
+ index > 0 && flowNodeLayer[kind] !== flowNodeLayer[kinds[index - 1] ?? kind];
151
181
  return (
152
- <button
153
- key={kind}
154
- type="button"
155
- data-palette-item=""
156
- disabled={reason !== null}
157
- title={reason ?? undefined}
158
- tabIndex={index === active ? 0 : -1}
159
- onClick={() => {
160
- setActive(index);
161
- add(kind);
162
- }}
163
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
164
- >
165
- <Icon aria-hidden="true" className="size-3.5" />
166
- {i18n.t(`flows.node.${kind}`)}
167
- </button>
182
+ <Fragment key={kind}>
183
+ {opensLayer ? (
184
+ <span
185
+ aria-hidden="true"
186
+ className="mx-1 my-1.5 w-px self-stretch bg-border"
187
+ title={i18n.t(`flows.layer.${flowNodeLayer[kind]}`)}
188
+ />
189
+ ) : null}
190
+ <button
191
+ type="button"
192
+ data-palette-item=""
193
+ disabled={reason !== null}
194
+ title={reason ?? undefined}
195
+ tabIndex={index === active ? 0 : -1}
196
+ onClick={() => {
197
+ setActive(index);
198
+ add(kind);
199
+ }}
200
+ // `h-9` is the trigger's height: sharing one row only reads as one row if the entries
201
+ // start on the trigger's top edge instead of floating in the middle of it.
202
+ className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
203
+ >
204
+ <Icon aria-hidden="true" className="size-3.5" />
205
+ {i18n.t(`flows.node.${kind}`)}
206
+ </button>
207
+ </Fragment>
168
208
  );
169
209
  })}
170
210
  </div>
@@ -0,0 +1,106 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { useNavigate } from "@tanstack/react-router";
3
+ import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from "@/components/ui/table.tsx";
12
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
13
+ import { useIntelRouterContext } from "@/router/router-context.ts";
14
+
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
+ };
22
+
23
+ /**
24
+ * A folder's own screen: what lies directly in it, as a table.
25
+ *
26
+ * ⚠️ It reads the SAME query the sidebar reads for the same folder — `treeLevelKey`, filled by
27
+ * `listTreeChildren`. Not a second call and not a second cache entry: two readers of one folder that
28
+ * fetch separately show it differently the moment one of them is stale, and that is the kind of
29
+ * 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.
31
+ */
32
+ export function FolderContents({ folderId }: { folderId: string }) {
33
+ const { data, i18n } = useIntelRouterContext();
34
+ const navigate = useNavigate();
35
+ const level = useQuery({
36
+ queryKey: treeLevelKey(folderId),
37
+ queryFn: () => data.listTreeChildren(folderId),
38
+ });
39
+
40
+ if (level.isPending) {
41
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
42
+ }
43
+ if (level.isError) {
44
+ return (
45
+ <p role="alert" className="p-6 text-sm text-destructive">
46
+ {i18n.t("knowledge.folderFailed")}
47
+ </p>
48
+ );
49
+ }
50
+
51
+ const entries = level.data ?? [];
52
+ // ⚠️ An empty folder keeps a sentence, unlike an empty row in the tree (#59). A folder IS this
53
+ // screen: a blank rectangle leaves someone who just created it with nowhere to go, which is the
54
+ // same reason `tree.empty` survives at the root.
55
+ if (entries.length === 0) {
56
+ return (
57
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
58
+ {i18n.t("knowledge.folderEmpty")}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ 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>
104
+ </div>
105
+ );
106
+ }
package/src/i18n/en.json CHANGED
@@ -84,11 +84,15 @@
84
84
  "knowledge.table.empty": "No rows yet. Rows are appended by flows and agents.",
85
85
  "knowledge.table.undefined": "This table has no columns yet.",
86
86
  "knowledge.select": "Select a document or folder to work with it.",
87
- "knowledge.folderHelp": "Select a child document, or create something inside this folder.",
88
- "knowledge.contextPolicy": "AI context policy",
89
- "knowledge.context.pinned": "Pinned in scope",
90
- "knowledge.context.relevant": "Load when relevant",
91
- "knowledge.context.explicit": "Only when referenced",
87
+ "knowledge.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
88
+ "knowledge.folderFailed": "This folder could not be read.",
89
+ "knowledge.kind": "Kind",
90
+ "knowledge.changed": "Changed",
91
+ "knowledge.kind.folder": "Folder",
92
+ "knowledge.kind.document": "Document",
93
+ "knowledge.kind.attachment": "Upload",
94
+ "knowledge.kind.table": "Table",
95
+ "knowledge.kind.flow": "Flow",
92
96
  "knowledge.share": "Share",
93
97
  "knowledge.shareAction": "Grant access",
94
98
  "knowledge.email": "Email address",
@@ -138,10 +142,9 @@
138
142
  "knowledge.link.unresolved": "Unavailable document",
139
143
  "knowledge.saveConflict": "This document changed elsewhere. Reload it before saving again.",
140
144
  "knowledge.saveError": "This document could not be saved. Reload and try again.",
141
- "tools.connect": "Connect the portal",
142
- "tools.reconnect": "Reconnect the portal",
143
- "tools.disconnected": "Portal not connected",
144
- "tools.disconnectedHelp": "Sign in to the company MCP portal once. The portal decides which servers you may use and holds their credentials; Intel never sees them.",
145
+ "tools.signingIn": "Signing you in to the company portal",
146
+ "tools.noAccess": "No access to the company portal",
147
+ "tools.noAccessHelp": "Your Intel account does not reach the company MCP portal, so there is nothing to show here. An administrator decides in the portal who may use which server; ask them to include you.",
145
148
  "tools.empty": "No tools available to you",
146
149
  "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.",
147
150
  "tools.unreachable": "Portal not reachable",
@@ -152,7 +155,6 @@
152
155
  "tools.outputSchema": "Result schema",
153
156
  "tools.destructiveShort": "Destructive",
154
157
  "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.",
155
- "tools.connectFailed": "The portal could not be connected with your current access.",
156
158
  "tools.arguments": "Arguments",
157
159
  "tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
158
160
  "flows.select": "Select a flow or create one to open the visual editor.",
@@ -160,25 +162,40 @@
160
162
  "flows.publish": "Publish",
161
163
  "flows.publishNothing": "Nothing new to publish",
162
164
  "flows.publishNeeded": "Publish — no run can start until you do",
163
- "flows.run": "Start run",
164
- "flows.runStarted": "Durable run started",
165
+ "flows.validate": "Check whether it would run",
166
+ "flows.validateReady": "This flow would start now.",
167
+ "flows.validateWhen": "Checked {when}. Tool access is asked with your own token each time, so this answer is a snapshot.",
168
+ "flows.problem.flow_execute_forbidden": "You may not run this flow",
169
+ "flows.problem.flow_not_published": "The flow is not published",
170
+ "flows.problem.flow_graph_invalid": "The graph cannot be run",
171
+ "flows.problem.flow_tools_unavailable": "A tool is out of reach",
172
+ "flows.problem.flow_knowledge_forbidden": "Material a step reads is out of reach",
173
+ "flows.problem.flow_subflow_unavailable": "A called flow is unavailable",
174
+ "flows.problem.flow_subflow_not_published": "A called flow is not published",
165
175
  "flows.addNode": "Add a step",
166
176
  "flows.closePalette": "Close the step bar",
167
177
  "flows.nodePalette": "Step types",
168
178
  "flows.node.trigger": "Start",
169
179
  "flows.startExists": "A flow has exactly one start, and this one already has it.",
170
180
  "flows.node.instruction": "Instruction",
171
- "flows.node.knowledge": "Knowledge",
181
+ "flows.node.folder": "Folder",
182
+ "flows.node.document": "Document",
183
+ "flows.node.upload": "Upload",
184
+ "flows.node.table": "Table",
172
185
  "flows.node.tool": "Tool",
173
186
  "flows.node.condition": "Condition",
174
- "flows.node.approval": "Approval",
175
- "flows.node.subflow": "Sub-flow",
176
- "flows.node.output": "Output",
187
+ "flows.node.subflow": "Flow",
188
+ "flows.node.output": "End",
177
189
  "flows.instruction": "Instruction for the AI or person",
178
190
  "flows.instructionHint": "Conditions, checks and branches can be described here in words. You do not need a separate node for every small decision.",
179
- "flows.knowledgeIds": "Knowledge references",
180
- "flows.knowledgeEmpty": "Create a knowledge item before adding this step.",
181
- "flows.knowledgeQuery": "Semantic retrieval hint",
191
+ "flows.linkEmpty": "Nothing chosen yet",
192
+ "picker.search": "Search by name",
193
+ "picker.top": "All folders",
194
+ "picker.open": "Open {title}",
195
+ "picker.nothing": "Nothing here",
196
+ "flows.layer.marker": "Markers",
197
+ "flows.layer.step": "Steps",
198
+ "flows.layer.link": "Links",
182
199
  "flows.subflowTarget": "Flow to call",
183
200
  "flows.selectSubflow": "Select a flow to call",
184
201
  "flows.subflowRule": "A flow may call a flow in its own folder or below it, or one in a folder every user may run. Publishing names the reason if it may not.",
@@ -6,11 +6,12 @@ import { lazy, Suspense, useState } from "react";
6
6
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
7
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
8
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9
+ import { FolderContents } from "@/folder-contents/folder-contents.tsx";
9
10
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
10
11
  import { KnowledgeTablePanel } from "@/knowledge-table/knowledge-table.tsx";
11
- import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
12
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
13
13
  import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
14
+ import { TitleRow } from "@/title-row/title-row.tsx";
14
15
 
15
16
  const KnowledgeEditor = lazy(async () => ({
16
17
  default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
@@ -57,7 +58,11 @@ export function Knowledge() {
57
58
  </ActionSlot>
58
59
  ) : null}
59
60
  <section className="relative flex min-h-0 min-w-0 flex-1 flex-col bg-card">
60
- {graphable && selection.graph ? (
61
+ {/* ⚠️ Only the root draws its graph on its own. A folder's graph hangs *under* the folder's
62
+ title line rather than in place of it (#58): the line is the one place its menu lives,
63
+ and a folder that could only be renamed while the graph happened to be switched off
64
+ would be a folder one cannot rename. */}
65
+ {selectedId === null && selection.graph ? (
61
66
  <GraphPane
62
67
  query={relations}
63
68
  select={(node) =>
@@ -89,45 +94,49 @@ export function Knowledge() {
89
94
  ) : (
90
95
  <>
91
96
  {/* The document's own line: its name, and beside it only what acts on this one
92
- document. What the four loose icons used to do is now one menu, the same one the
93
- tree row carries the point of #24 was that there be one place per action, not a
94
- second row of them here. Saving joins it from the strip it used to own below
95
- (`title-actions`), so the editor starts one screen row higher than it did.
97
+ document. What the four loose icons used to do is now one menu (#24) and since #58
98
+ this line is the only place that menu appears at all, for every kind including a
99
+ folder, whose whole screen is this line.
100
+ ⚠️ The order of the right-hand group is `TitleRow`'s (#53), not this screen's: the
101
+ version button is handed over as a child and lands before the menu whatever else
102
+ shows up later.
96
103
  ⚠️ The view switch is deliberately NOT here: it changes how the current area is
97
104
  shown, not the document, and it stays beside the breadcrumb where the area is
98
105
  named. */}
99
- <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
100
- <div className="min-w-0">
101
- <h2 className="truncate text-lg font-semibold">{selected.title}</h2>
102
- {selected.description && (
103
- <p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
104
- )}
105
- </div>
106
- <div className="flex shrink-0 items-center gap-2">
107
- {selected.kind !== "folder" && (
108
- <TooltipProvider delayDuration={300}>
109
- <Tooltip>
110
- <TooltipTrigger
111
- type="button"
112
- onClick={() => setVersionsOpen((value) => !value)}
113
- aria-label={i18n.t("knowledge.versions")}
114
- aria-expanded={versionsOpen}
115
- className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
116
- >
117
- <History aria-hidden="true" className="size-4" />
118
- </TooltipTrigger>
119
- <TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
120
- </Tooltip>
121
- </TooltipProvider>
122
- )}
123
- <ResourceMenu target={{ type: "knowledge", node: selected }} variant="title" />
124
- <div data-slot="title-actions" className="flex items-center gap-2" />
125
- </div>
126
- </div>
127
- {selected.kind === "folder" ? (
128
- <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
129
- {i18n.t("knowledge.folderHelp")}
130
- </div>
106
+ <TitleRow
107
+ title={selected.title}
108
+ description={selected.description}
109
+ target={{ type: "knowledge", node: selected }}
110
+ >
111
+ {selected.kind !== "folder" && (
112
+ <TooltipProvider delayDuration={300}>
113
+ <Tooltip>
114
+ <TooltipTrigger
115
+ type="button"
116
+ onClick={() => setVersionsOpen((value) => !value)}
117
+ aria-label={i18n.t("knowledge.versions")}
118
+ aria-expanded={versionsOpen}
119
+ className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
120
+ >
121
+ <History aria-hidden="true" className="size-4" />
122
+ </TooltipTrigger>
123
+ <TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
124
+ </Tooltip>
125
+ </TooltipProvider>
126
+ )}
127
+ </TitleRow>
128
+ {graphable && selection.graph ? (
129
+ <GraphPane
130
+ query={relations}
131
+ select={(node) =>
132
+ void navigate({
133
+ to: node.kind === "flow" ? "/flows" : "/knowledge",
134
+ search: { select: node.id },
135
+ })
136
+ }
137
+ />
138
+ ) : selected.kind === "folder" ? (
139
+ <FolderContents folderId={selected.id} />
131
140
  ) : selected.kind === "attachment" ? (
132
141
  <AttachmentPanel node={selected} />
133
142
  ) : selected.kind === "table" ? (
@@ -1,6 +1,7 @@
1
1
  import type { KnowledgeNode } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery } from "@tanstack/react-query";
3
3
  import { Download } from "lucide-react";
4
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
4
5
  import { useIntelRouterContext } from "@/router/router-context.ts";
5
6
 
6
7
  /**
@@ -43,25 +44,36 @@ export function KnowledgeTablePanel({ node }: { node: KnowledgeNode }) {
43
44
 
44
45
  return (
45
46
  <div className="flex min-h-0 flex-1 flex-col">
46
- <div className="flex items-center justify-between gap-4 border-b px-4 py-2">
47
- <p className="text-sm text-muted-foreground">
48
- {table.data
49
- ? i18n.t("knowledge.table.summary", {
50
- rows: table.data.rows.length,
51
- columns: table.data.columns.length,
52
- })
53
- : ""}
54
- </p>
47
+ {/* ⚠️ Both of these used to be a second bar of their own, directly under the title line (#54):
48
+ two headers stacked, and the table starting a whole row lower for it. A table is one
49
+ Knowledge kind among four, not a screen with its own chrome — so the count goes beside the
50
+ title as a quiet word and the export joins the buttons in the title line. They are rendered
51
+ from here rather than from the screen because this is where the query lives; the screen
52
+ would otherwise have to load a table it does not show. */}
53
+ <ActionSlot name="title-meta">
54
+ {table.data
55
+ ? i18n.t("knowledge.table.summary", {
56
+ rows: table.data.rows.length,
57
+ columns: table.data.columns.length,
58
+ })
59
+ : ""}
60
+ </ActionSlot>
61
+ <ActionSlot name="title-actions">
55
62
  <button
56
63
  type="button"
57
64
  onClick={() => download.mutate()}
65
+ // Unchanged: nothing to export before the header is known, and a button that answers with
66
+ // an empty file is worse than one that says it is not ready.
58
67
  disabled={download.isPending || !table.data?.columns.length}
59
- className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
68
+ className="inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
60
69
  >
61
70
  <Download aria-hidden="true" className="size-4" />
62
71
  {i18n.t("knowledge.table.download")}
63
72
  </button>
64
- </div>
73
+ </ActionSlot>
74
+ {/* ⚠️ The refusal stays down here, in the body, where a sentence has room to be read. The
75
+ title line has none — a failed export that only greyed a button in a header would be a
76
+ failure nobody is told about. */}
65
77
  {download.isError ? (
66
78
  <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
67
79
  {i18n.t("knowledge.downloadFailed")}