@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.
- package/package.json +2 -2
- package/src/app/action-slot/action-slot.tsx +4 -0
- package/src/app/app-tree/app-tree.tsx +35 -122
- package/src/app/app.tsx +15 -4
- package/src/app/tree-move/tree-move.tsx +135 -1
- package/src/components/ui/table.tsx +82 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +19 -3
- package/src/data/intel-data-provider/intel-data-provider.types.ts +7 -1
- package/src/entry-picker/entry-picker.tsx +166 -0
- package/src/flows/flows.tsx +183 -183
- package/src/flows/node-icon/node-icon.ts +13 -7
- package/src/flows/node-palette/node-palette.tsx +63 -23
- package/src/folder-contents/folder-contents.tsx +106 -0
- package/src/i18n/en.json +36 -19
- package/src/knowledge/knowledge.tsx +47 -38
- package/src/knowledge-table/knowledge-table.tsx +23 -11
- package/src/resource-menu/resource-menu.tsx +110 -65
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
|
@@ -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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
88
|
-
"knowledge.
|
|
89
|
-
"knowledge.
|
|
90
|
-
"knowledge.
|
|
91
|
-
"knowledge.
|
|
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.
|
|
142
|
-
"tools.
|
|
143
|
-
"tools.
|
|
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.
|
|
164
|
-
"flows.
|
|
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.
|
|
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.
|
|
175
|
-
"flows.node.
|
|
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.
|
|
180
|
-
"
|
|
181
|
-
"
|
|
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
|
-
{
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
</
|
|
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")}
|