@anchrd/intel-ui 0.8.8 → 0.9.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/README.md +2 -2
- package/package.json +5 -2
- package/src/agent/agent-avatar/agent-avatar.tsx +34 -0
- package/src/agent/agent-calendar/agent-calendar.tsx +79 -0
- package/src/agent/agent-chat/agent-chat.tsx +116 -0
- package/src/agent/agent-cron/agent-cron.ts +132 -0
- package/src/agent/agent-definition/agent-definition.ts +64 -0
- package/src/agent/agent-entry-title/agent-entry-title.ts +29 -0
- package/src/agent/agent-log/agent-log.tsx +159 -0
- package/src/agent/agent-models/agent-models.ts +63 -0
- package/src/agent/agent-profile/agent-profile.tsx +512 -0
- package/src/agent/agent-state/agent-state.ts +48 -0
- package/src/agent/agent.tsx +361 -0
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +151 -23
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +16 -16
- package/src/app/tree-move/tree-move.tsx +10 -10
- package/src/archive/archive.tsx +8 -8
- package/src/components/ui/avatar.tsx +39 -0
- package/src/components/ui/select.tsx +163 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/data/agent-runtime/agent-runtime.ts +93 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +201 -120
- package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
- package/src/entry-picker/entry-picker.tsx +53 -17
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +20 -20
- package/src/folder-contents/folder-contents.tsx +7 -7
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/hooks/use-capabilities.ts +22 -0
- package/src/i18n/en.json +148 -64
- package/src/kind-icon.ts +5 -2
- package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
- package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
- package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
- package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
- package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +46 -29
- package/src/resource-menu/resource-menu.tsx +105 -62
- package/src/router/router.tsx +5 -5
- package/src/title-row/title-row.tsx +13 -5
- package/vite.config.ts +4 -0
- /package/src/{knowledge-graph/knowledge-graph.ts → node-graph/node-graph.ts} +0 -0
|
@@ -1,23 +1,29 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Node } from "@anchrd/intel-contract";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
4
|
import { Download, History, Paperclip } from "lucide-react";
|
|
5
5
|
import { lazy, Suspense, useState } from "react";
|
|
6
|
+
import { AgentAvatar } from "@/agent/agent-avatar/agent-avatar.tsx";
|
|
6
7
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
7
8
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
8
9
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
9
10
|
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
10
11
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
11
|
-
import {
|
|
12
|
+
import { NodeTablePanel } from "@/node-table/node-table.tsx";
|
|
12
13
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
13
14
|
import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
|
|
14
15
|
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
default: (await import("@/
|
|
17
|
+
const NodeEditor = lazy(async () => ({
|
|
18
|
+
default: (await import("@/node-editor/node-editor.tsx")).NodeEditor,
|
|
19
|
+
}));
|
|
20
|
+
// Lazy for the same reason the editor is: assistant-ui and its stream client are the agent page's
|
|
21
|
+
// alone, and every other screen would otherwise pay for them on first paint.
|
|
22
|
+
const AgentPanel = lazy(async () => ({
|
|
23
|
+
default: (await import("@/agent/agent.tsx")).AgentPanel,
|
|
18
24
|
}));
|
|
19
25
|
|
|
20
|
-
export function
|
|
26
|
+
export function Nodes() {
|
|
21
27
|
const { data, i18n } = useIntelRouterContext();
|
|
22
28
|
const queryClient = useQueryClient();
|
|
23
29
|
const navigate = useNavigate();
|
|
@@ -32,8 +38,8 @@ export function Knowledge() {
|
|
|
32
38
|
const selectedId = selection.id;
|
|
33
39
|
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
34
40
|
const document = useQuery({
|
|
35
|
-
queryKey: ["
|
|
36
|
-
queryFn: () => data.
|
|
41
|
+
queryKey: ["node", selectedId],
|
|
42
|
+
queryFn: () => data.getNode(selectedId ?? ""),
|
|
37
43
|
enabled: Boolean(selectedId),
|
|
38
44
|
});
|
|
39
45
|
const selected = document.data?.node ?? null;
|
|
@@ -47,7 +53,7 @@ export function Knowledge() {
|
|
|
47
53
|
});
|
|
48
54
|
return (
|
|
49
55
|
<div className="flex h-full min-h-0 flex-col">
|
|
50
|
-
{/* The screen owns the one action that belongs to
|
|
56
|
+
{/* The screen owns the one action that belongs to the tree as a whole. Creating things is
|
|
51
57
|
the tree's, in the sidebar, at the place a new thing is meant to go.
|
|
52
58
|
|
|
53
59
|
⚠️ Mounted only where there is a graph to switch to. A switch on a document would point at
|
|
@@ -67,21 +73,21 @@ export function Knowledge() {
|
|
|
67
73
|
query={relations}
|
|
68
74
|
select={(node) =>
|
|
69
75
|
void navigate({
|
|
70
|
-
to: node.kind === "flow" ? "/flows" : "/
|
|
76
|
+
to: node.kind === "flow" ? "/flows" : "/nodes",
|
|
71
77
|
search: { select: node.id },
|
|
72
78
|
})
|
|
73
79
|
}
|
|
74
80
|
/>
|
|
75
81
|
) : !selectedId ? (
|
|
76
82
|
<div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
|
|
77
|
-
{i18n.t("
|
|
83
|
+
{i18n.t("node.select")}
|
|
78
84
|
</div>
|
|
79
85
|
) : document.isPending ? (
|
|
80
86
|
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
81
87
|
) : document.isError || !selected ? (
|
|
82
88
|
<div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
83
89
|
<div className="space-y-3">
|
|
84
|
-
<p className="text-destructive">{i18n.t("
|
|
90
|
+
<p className="text-destructive">{i18n.t("node.loadFailed")}</p>
|
|
85
91
|
<button
|
|
86
92
|
type="button"
|
|
87
93
|
onClick={() => void document.refetch()}
|
|
@@ -106,7 +112,8 @@ export function Knowledge() {
|
|
|
106
112
|
<TitleRow
|
|
107
113
|
title={selected.title}
|
|
108
114
|
description={selected.description}
|
|
109
|
-
target={{ type: "
|
|
115
|
+
target={{ type: "node", node: selected }}
|
|
116
|
+
leading={selected.kind === "agent" ? <AgentAvatar title={selected.title} /> : null}
|
|
110
117
|
>
|
|
111
118
|
{selected.kind !== "folder" && (
|
|
112
119
|
<TooltipProvider delayDuration={300}>
|
|
@@ -114,13 +121,13 @@ export function Knowledge() {
|
|
|
114
121
|
<TooltipTrigger
|
|
115
122
|
type="button"
|
|
116
123
|
onClick={() => setVersionsOpen((value) => !value)}
|
|
117
|
-
aria-label={i18n.t("
|
|
124
|
+
aria-label={i18n.t("node.versions")}
|
|
118
125
|
aria-expanded={versionsOpen}
|
|
119
126
|
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
127
|
>
|
|
121
128
|
<History aria-hidden="true" className="size-4" />
|
|
122
129
|
</TooltipTrigger>
|
|
123
|
-
<TooltipContent>{i18n.t("
|
|
130
|
+
<TooltipContent>{i18n.t("node.versions")}</TooltipContent>
|
|
124
131
|
</Tooltip>
|
|
125
132
|
</TooltipProvider>
|
|
126
133
|
)}
|
|
@@ -130,7 +137,7 @@ export function Knowledge() {
|
|
|
130
137
|
query={relations}
|
|
131
138
|
select={(node) =>
|
|
132
139
|
void navigate({
|
|
133
|
-
to: node.kind === "flow" ? "/flows" : "/
|
|
140
|
+
to: node.kind === "flow" ? "/flows" : "/nodes",
|
|
134
141
|
search: { select: node.id },
|
|
135
142
|
})
|
|
136
143
|
}
|
|
@@ -140,20 +147,30 @@ export function Knowledge() {
|
|
|
140
147
|
) : selected.kind === "attachment" ? (
|
|
141
148
|
<AttachmentPanel node={selected} />
|
|
142
149
|
) : selected.kind === "table" ? (
|
|
143
|
-
<
|
|
150
|
+
<NodeTablePanel node={selected} />
|
|
151
|
+
) : selected.kind === "agent" ? (
|
|
152
|
+
// ⚠️ Before the editor branch, and never reaching it: an agent's version body is a
|
|
153
|
+
// definition document, not prose, and BlockNote would try to open JSON as a text.
|
|
154
|
+
<Suspense
|
|
155
|
+
fallback={
|
|
156
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
157
|
+
}
|
|
158
|
+
>
|
|
159
|
+
<AgentPanel key={selected.id} node={selected} />
|
|
160
|
+
</Suspense>
|
|
144
161
|
) : document.data ? (
|
|
145
162
|
<Suspense
|
|
146
163
|
fallback={
|
|
147
164
|
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
148
165
|
}
|
|
149
166
|
>
|
|
150
|
-
<
|
|
167
|
+
<NodeEditor
|
|
151
168
|
key={document.data.node.id}
|
|
152
169
|
data={data}
|
|
153
170
|
document={document.data}
|
|
154
171
|
i18n={i18n}
|
|
155
172
|
onSaved={(saved) => {
|
|
156
|
-
queryClient.setQueryData(["
|
|
173
|
+
queryClient.setQueryData(["node", selected.id], saved);
|
|
157
174
|
}}
|
|
158
175
|
/>
|
|
159
176
|
</Suspense>
|
|
@@ -168,10 +185,10 @@ export function Knowledge() {
|
|
|
168
185
|
);
|
|
169
186
|
}
|
|
170
187
|
|
|
171
|
-
function AttachmentPanel({ node }: { node:
|
|
188
|
+
function AttachmentPanel({ node }: { node: Node }) {
|
|
172
189
|
const { data, i18n } = useIntelRouterContext();
|
|
173
190
|
const download = useMutation({
|
|
174
|
-
mutationFn: () => data.
|
|
191
|
+
mutationFn: () => data.getNodeAttachment(node.id),
|
|
175
192
|
onSuccess: (blob) => {
|
|
176
193
|
const url = URL.createObjectURL(blob);
|
|
177
194
|
const anchor = document.createElement("a");
|
|
@@ -188,7 +205,7 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
|
|
|
188
205
|
<div className="max-w-md rounded-xl border bg-background p-8 text-center shadow-sm">
|
|
189
206
|
<Paperclip aria-hidden="true" className="mx-auto size-10 text-primary" />
|
|
190
207
|
<h3 className="mt-4 font-semibold">{node.title}</h3>
|
|
191
|
-
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("
|
|
208
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("node.attachmentHelp")}</p>
|
|
192
209
|
<button
|
|
193
210
|
type="button"
|
|
194
211
|
onClick={() => download.mutate()}
|
|
@@ -196,11 +213,11 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
|
|
|
196
213
|
className="mt-5 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
197
214
|
>
|
|
198
215
|
<Download aria-hidden="true" className="size-4" />
|
|
199
|
-
{download.isPending ? i18n.t("common.loading") : i18n.t("
|
|
216
|
+
{download.isPending ? i18n.t("common.loading") : i18n.t("node.download")}
|
|
200
217
|
</button>
|
|
201
218
|
{download.isError ? (
|
|
202
219
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
203
|
-
{i18n.t("
|
|
220
|
+
{i18n.t("node.downloadFailed")}
|
|
204
221
|
</p>
|
|
205
222
|
) : null}
|
|
206
223
|
</div>
|
|
@@ -211,16 +228,16 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
|
|
|
211
228
|
function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
|
|
212
229
|
const { data, i18n } = useIntelRouterContext();
|
|
213
230
|
const versions = useQuery({
|
|
214
|
-
queryKey: ["
|
|
215
|
-
queryFn: () => data.
|
|
231
|
+
queryKey: ["node-versions", nodeId],
|
|
232
|
+
queryFn: () => data.listNodeVersions(nodeId),
|
|
216
233
|
});
|
|
217
234
|
return (
|
|
218
235
|
<aside
|
|
219
|
-
aria-label={i18n.t("
|
|
236
|
+
aria-label={i18n.t("node.versions")}
|
|
220
237
|
className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
|
|
221
238
|
>
|
|
222
239
|
<div className="flex items-start justify-between gap-4">
|
|
223
|
-
<h3 className="font-semibold">{i18n.t("
|
|
240
|
+
<h3 className="font-semibold">{i18n.t("node.versions")}</h3>
|
|
224
241
|
<button
|
|
225
242
|
type="button"
|
|
226
243
|
onClick={close}
|
|
@@ -235,14 +252,14 @@ function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
|
|
|
235
252
|
) : null}
|
|
236
253
|
{versions.isError ? (
|
|
237
254
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
238
|
-
{i18n.t("
|
|
255
|
+
{i18n.t("node.operationFailed")}
|
|
239
256
|
</p>
|
|
240
257
|
) : null}
|
|
241
258
|
<ol className="mt-4 space-y-3">
|
|
242
259
|
{versions.data?.items.map((version) => (
|
|
243
260
|
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
244
261
|
<span className="font-medium">
|
|
245
|
-
{i18n.t("
|
|
262
|
+
{i18n.t("node.version", { sequence: version.sequence })}
|
|
246
263
|
</span>
|
|
247
264
|
<time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
|
|
248
265
|
{new Intl.DateTimeFormat(i18n.locale, {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Flow,
|
|
3
3
|
FlowValidation,
|
|
4
|
-
|
|
4
|
+
Node,
|
|
5
|
+
NodeKind,
|
|
5
6
|
ResourceVerb,
|
|
6
|
-
|
|
7
|
+
UnreadableNodes,
|
|
7
8
|
} from "@anchrd/intel-contract";
|
|
8
9
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
10
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
Archive,
|
|
12
13
|
CornerLeftUp,
|
|
13
14
|
Ellipsis,
|
|
15
|
+
FolderDown,
|
|
14
16
|
Link2,
|
|
15
17
|
Pencil,
|
|
16
18
|
Share2,
|
|
@@ -46,9 +48,7 @@ import { selectedFrom } from "@/router/selection-search.ts";
|
|
|
46
48
|
* is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
|
|
47
49
|
* still promises something is there.
|
|
48
50
|
*/
|
|
49
|
-
export type ResourceTarget =
|
|
50
|
-
| { type: "knowledge"; node: KnowledgeNode }
|
|
51
|
-
| { type: "flow"; flow: Flow };
|
|
51
|
+
export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
|
|
52
52
|
|
|
53
53
|
// The same thing as a tree row, because that is what a move works on: a row with a place in the
|
|
54
54
|
// shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
|
|
@@ -57,26 +57,41 @@ function entryOf(target: ResourceTarget): TreeEntry {
|
|
|
57
57
|
return target.type === "flow"
|
|
58
58
|
? flowEntry(target.flow)
|
|
59
59
|
: {
|
|
60
|
-
type: "
|
|
60
|
+
type: "node",
|
|
61
61
|
id: target.node.id,
|
|
62
62
|
title: target.node.title,
|
|
63
63
|
kind: target.node.kind,
|
|
64
|
-
//
|
|
64
|
+
// A menu moves a row; whether it can be expanded plays no part in that.
|
|
65
65
|
openable: false,
|
|
66
66
|
node: target.node,
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function idOf(target: ResourceTarget): string {
|
|
71
|
-
return target.type === "
|
|
71
|
+
return target.type === "node" ? target.node.id : target.flow.id;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function titleOf(target: ResourceTarget): string {
|
|
75
|
-
return target.type === "
|
|
75
|
+
return target.type === "node" ? target.node.title : target.flow.title;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function parentOfTarget(target: ResourceTarget): string | null {
|
|
79
|
-
return target.type === "
|
|
79
|
+
return target.type === "node" ? target.node.parentId : target.flow.parentId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Which sentence explains a verb, for THIS kind of thing.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ "Run" does not mean the same thing twice. On a folder it grants starting the flows filed in
|
|
86
|
+
* it; on an agent it grants using the agent — through its chat, its schedules and its MCP address.
|
|
87
|
+
* Until #143 there was one sentence for both, and on an agent it named flows that have nothing to
|
|
88
|
+
* do with the grant being made. A key per kind, falling back to the general one, so a kind that
|
|
89
|
+
* needs no special wording needs no entry.
|
|
90
|
+
*/
|
|
91
|
+
export function verbHintKey(verb: ResourceVerb, kind: NodeKind): string {
|
|
92
|
+
return verb === "execute" && kind === "agent"
|
|
93
|
+
? "node.verbHint.execute.agent"
|
|
94
|
+
: `node.verbHint.${verb}`;
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
// ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
|
|
@@ -91,7 +106,7 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
91
106
|
case "update_conflict":
|
|
92
107
|
case "flow_update_conflict":
|
|
93
108
|
return "resource.conflict";
|
|
94
|
-
case "
|
|
109
|
+
case "node_forbidden":
|
|
95
110
|
case "flow_edit_forbidden":
|
|
96
111
|
return "resource.forbidden";
|
|
97
112
|
default:
|
|
@@ -141,7 +156,7 @@ export function ResourceMenu({
|
|
|
141
156
|
|
|
142
157
|
const id = idOf(target);
|
|
143
158
|
const title = titleOf(target);
|
|
144
|
-
const node = target.type === "
|
|
159
|
+
const node = target.type === "node" ? target.node : null;
|
|
145
160
|
// A folder holds things; it is not itself retrieved, and nothing is written into it that could
|
|
146
161
|
// link anywhere. Both entries are therefore absent on one rather than offered and inert.
|
|
147
162
|
const retrievable = node !== null && node.kind !== "folder";
|
|
@@ -155,10 +170,10 @@ export function ResourceMenu({
|
|
|
155
170
|
await Promise.all([
|
|
156
171
|
queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
|
|
157
172
|
queryClient.invalidateQueries({
|
|
158
|
-
queryKey: target.type === "flow" ? ["flow", id] : ["
|
|
173
|
+
queryKey: target.type === "flow" ? ["flow", id] : ["node", id],
|
|
159
174
|
}),
|
|
160
175
|
queryClient.invalidateQueries({
|
|
161
|
-
queryKey: [target.type === "flow" ? "flows" : "
|
|
176
|
+
queryKey: [target.type === "flow" ? "flows" : "node-graph"],
|
|
162
177
|
}),
|
|
163
178
|
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
164
179
|
]);
|
|
@@ -177,7 +192,7 @@ export function ResourceMenu({
|
|
|
177
192
|
});
|
|
178
193
|
return;
|
|
179
194
|
}
|
|
180
|
-
await data.
|
|
195
|
+
await data.updateNode({
|
|
181
196
|
nodeId: target.node.id,
|
|
182
197
|
baseUpdatedAt: target.node.updatedAt,
|
|
183
198
|
title: next,
|
|
@@ -204,7 +219,7 @@ export function ResourceMenu({
|
|
|
204
219
|
});
|
|
205
220
|
return;
|
|
206
221
|
}
|
|
207
|
-
await data.
|
|
222
|
+
await data.archiveNode({
|
|
208
223
|
nodeId: target.node.id,
|
|
209
224
|
baseUpdatedAt: target.node.updatedAt,
|
|
210
225
|
archived: true,
|
|
@@ -216,16 +231,36 @@ export function ResourceMenu({
|
|
|
216
231
|
// The row is gone from the tree. If it was also what the screen was showing, the screen has
|
|
217
232
|
// nothing left to show either.
|
|
218
233
|
//
|
|
219
|
-
// ⚠️ It drops the selection where it stands rather than always landing on /
|
|
234
|
+
// ⚠️ It drops the selection where it stands rather than always landing on /nodes: a flow
|
|
220
235
|
// archived from the flow screen would otherwise throw the reader onto another screen for no
|
|
221
|
-
// reason they could see. A flow reached through the shared tree stays on /
|
|
236
|
+
// reason they could see. A flow reached through the shared tree stays on /nodes, which is
|
|
222
237
|
// where it was being read.
|
|
223
238
|
if (selected === id) {
|
|
224
|
-
await navigate({ to: pathname === "/flows" ? "/flows" : "/
|
|
239
|
+
await navigate({ to: pathname === "/flows" ? "/flows" : "/nodes", search: {} });
|
|
225
240
|
}
|
|
226
241
|
},
|
|
227
242
|
});
|
|
228
243
|
|
|
244
|
+
// The export as the user takes it home (#136): the streamed zip as a saved file, named after the
|
|
245
|
+
// thing itself. The same object-URL dance the attachment download does, because it is the same
|
|
246
|
+
// gesture — the data provider streams, the browser saves.
|
|
247
|
+
const exportBundle = useMutation({
|
|
248
|
+
mutationFn: async () =>
|
|
249
|
+
target.type === "flow"
|
|
250
|
+
? await data.exportFlowBundle(target.flow.id)
|
|
251
|
+
: await data.exportNodeBundle(target.node.id),
|
|
252
|
+
onSuccess: (blob) => {
|
|
253
|
+
const url = URL.createObjectURL(blob);
|
|
254
|
+
const anchor = document.createElement("a");
|
|
255
|
+
anchor.href = url;
|
|
256
|
+
anchor.download = `${title}.zip`;
|
|
257
|
+
document.body.append(anchor);
|
|
258
|
+
anchor.click();
|
|
259
|
+
anchor.remove();
|
|
260
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
229
264
|
const failure = rename.isError
|
|
230
265
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
231
266
|
: archive.isError
|
|
@@ -255,6 +290,13 @@ export function ResourceMenu({
|
|
|
255
290
|
<CornerLeftUp aria-hidden="true" />
|
|
256
291
|
{i18n.t("tree.move.action")}
|
|
257
292
|
</DropdownMenuItem>
|
|
293
|
+
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
294
|
+
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
295
|
+
not on its content. */}
|
|
296
|
+
<DropdownMenuItem onSelect={() => exportBundle.mutate()}>
|
|
297
|
+
<FolderDown aria-hidden="true" />
|
|
298
|
+
{i18n.t("resource.export")}
|
|
299
|
+
</DropdownMenuItem>
|
|
258
300
|
{/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
|
|
259
301
|
which is why it is in the menu and not in the title line (#72). */}
|
|
260
302
|
{node === null ? (
|
|
@@ -267,7 +309,7 @@ export function ResourceMenu({
|
|
|
267
309
|
{linkable ? (
|
|
268
310
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
269
311
|
<Link2 aria-hidden="true" />
|
|
270
|
-
{i18n.t("
|
|
312
|
+
{i18n.t("node.links")}
|
|
271
313
|
</DropdownMenuItem>
|
|
272
314
|
) : null}
|
|
273
315
|
{/* A flow has no share of its own: it is reached through the folder it is filed in, and a
|
|
@@ -276,7 +318,7 @@ export function ResourceMenu({
|
|
|
276
318
|
{node ? (
|
|
277
319
|
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
278
320
|
<Share2 aria-hidden="true" />
|
|
279
|
-
{i18n.t("
|
|
321
|
+
{i18n.t("node.share")}
|
|
280
322
|
</DropdownMenuItem>
|
|
281
323
|
) : null}
|
|
282
324
|
{/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
|
|
@@ -297,6 +339,9 @@ export function ResourceMenu({
|
|
|
297
339
|
{failure !== undefined && failure !== null ? (
|
|
298
340
|
<MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
|
|
299
341
|
) : null}
|
|
342
|
+
{/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
|
|
343
|
+
arrive, and "the change was not saved" would send the reader looking for a change. */}
|
|
344
|
+
{exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
|
|
300
345
|
{/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
|
|
301
346
|
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
302
347
|
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
@@ -341,8 +386,8 @@ export function ResourceMenu({
|
|
|
341
386
|
submit={(next) => rename.mutate(next)}
|
|
342
387
|
/>
|
|
343
388
|
) : null}
|
|
344
|
-
{sharing && node ? <
|
|
345
|
-
{linksOpen && node ? <
|
|
389
|
+
{sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
|
|
390
|
+
{linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
|
|
346
391
|
</>
|
|
347
392
|
);
|
|
348
393
|
}
|
|
@@ -423,27 +468,27 @@ function RenameDialog({
|
|
|
423
468
|
* the same predicate as every other read. A relationship reaching into a part of the tree they may
|
|
424
469
|
* not enter is absent rather than shown without a name.
|
|
425
470
|
*/
|
|
426
|
-
function
|
|
471
|
+
function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
427
472
|
const { data, i18n } = useIntelRouterContext();
|
|
428
473
|
const navigate = useNavigate();
|
|
429
474
|
const links = useQuery({
|
|
430
|
-
queryKey: ["
|
|
431
|
-
queryFn: () => data.
|
|
475
|
+
queryKey: ["node-links", node.id],
|
|
476
|
+
queryFn: () => data.listNodeLinks(node.id),
|
|
432
477
|
});
|
|
433
478
|
// The dialog needs the names of the documents at the other end, and the graph is the one
|
|
434
479
|
// authorized list of them the UI can ask for without walking the whole tree again. It is asked
|
|
435
480
|
// for here rather than by the screen, because here is where it is read.
|
|
436
|
-
const
|
|
437
|
-
queryKey: ["
|
|
438
|
-
queryFn: () => data.
|
|
481
|
+
const nodeGraph = useQuery({
|
|
482
|
+
queryKey: ["node-graph"],
|
|
483
|
+
queryFn: () => data.getNodeGraph(),
|
|
439
484
|
});
|
|
440
485
|
const titles = new Map(
|
|
441
|
-
(
|
|
486
|
+
(nodeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
|
|
442
487
|
);
|
|
443
488
|
|
|
444
489
|
return (
|
|
445
|
-
<Modal title={i18n.t("
|
|
446
|
-
<p className="mb-4 text-sm text-muted-foreground">{i18n.t("
|
|
490
|
+
<Modal title={i18n.t("node.links")} close={close}>
|
|
491
|
+
<p className="mb-4 text-sm text-muted-foreground">{i18n.t("node.linksHelp")}</p>
|
|
447
492
|
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
|
448
493
|
{links.data?.items.map((link) => {
|
|
449
494
|
const outgoing = link.sourceNodeId === node.id;
|
|
@@ -454,7 +499,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
454
499
|
type="button"
|
|
455
500
|
onClick={() => {
|
|
456
501
|
close();
|
|
457
|
-
void navigate({ to: "/
|
|
502
|
+
void navigate({ to: "/nodes", search: { select: otherId } });
|
|
458
503
|
}}
|
|
459
504
|
className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
460
505
|
>
|
|
@@ -463,7 +508,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
463
508
|
{titles.get(otherId) ?? otherId}
|
|
464
509
|
</span>
|
|
465
510
|
<span className="text-xs text-muted-foreground">
|
|
466
|
-
{outgoing ? i18n.t("
|
|
511
|
+
{outgoing ? i18n.t("node.linkOutgoing") : i18n.t("node.linkIncoming")}
|
|
467
512
|
</span>
|
|
468
513
|
</span>
|
|
469
514
|
</button>
|
|
@@ -471,7 +516,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
471
516
|
);
|
|
472
517
|
})}
|
|
473
518
|
{links.data?.items.length === 0 && (
|
|
474
|
-
<li className="text-sm text-muted-foreground">{i18n.t("
|
|
519
|
+
<li className="text-sm text-muted-foreground">{i18n.t("node.noLinks")}</li>
|
|
475
520
|
)}
|
|
476
521
|
</ul>
|
|
477
522
|
</Modal>
|
|
@@ -482,28 +527,28 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
482
527
|
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
483
528
|
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
484
529
|
// has nothing to run.
|
|
485
|
-
function
|
|
530
|
+
function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
486
531
|
const { data, i18n } = useIntelRouterContext();
|
|
487
532
|
const queryClient = useQueryClient();
|
|
488
533
|
const [email, setEmail] = useState("");
|
|
489
534
|
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
490
535
|
// What the grant just made does not cover. It survives the form being cleared, because it is the
|
|
491
536
|
// answer to the question the user just asked and they need a moment to read it.
|
|
492
|
-
const [unreadable, setUnreadable] = useState<
|
|
537
|
+
const [unreadable, setUnreadable] = useState<UnreadableNodes | null>(null);
|
|
493
538
|
const grants = useQuery({
|
|
494
|
-
queryKey: ["
|
|
495
|
-
queryFn: () => data.
|
|
539
|
+
queryKey: ["node-grants", node.id],
|
|
540
|
+
queryFn: () => data.listGrants(node.id),
|
|
496
541
|
});
|
|
497
542
|
const share = useMutation({
|
|
498
543
|
// One request per verb, because one grant is one verb. The keys differ so a retry of the whole
|
|
499
544
|
// form replays each verb on its own rather than collapsing them into one.
|
|
500
545
|
mutationFn: async () => {
|
|
501
|
-
let last:
|
|
546
|
+
let last: UnreadableNodes | null = null;
|
|
502
547
|
for (const verb of verbs) {
|
|
503
548
|
// The last verb's answer is the one kept: every request describes the access in force after
|
|
504
549
|
// it, so the newest is the only one still true.
|
|
505
550
|
last = (
|
|
506
|
-
await data.
|
|
551
|
+
await data.shareNode({
|
|
507
552
|
resourceId: node.id,
|
|
508
553
|
principal: { type: "email", email },
|
|
509
554
|
verb,
|
|
@@ -523,46 +568,44 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
523
568
|
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
524
569
|
// things to be silently wrong about, access is the worst.
|
|
525
570
|
onSettled: async () => {
|
|
526
|
-
await queryClient.invalidateQueries({ queryKey: ["
|
|
571
|
+
await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
|
|
527
572
|
},
|
|
528
573
|
});
|
|
529
574
|
const revoke = useMutation({
|
|
530
575
|
mutationFn: (grantId: string) =>
|
|
531
|
-
data.
|
|
576
|
+
data.revokeGrant({
|
|
532
577
|
resourceId: node.id,
|
|
533
578
|
grantId,
|
|
534
579
|
idempotencyKey: crypto.randomUUID(),
|
|
535
580
|
}),
|
|
536
581
|
onSettled: async () => {
|
|
537
|
-
await queryClient.invalidateQueries({ queryKey: ["
|
|
582
|
+
await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
|
|
538
583
|
},
|
|
539
584
|
});
|
|
540
585
|
|
|
541
586
|
return (
|
|
542
|
-
<Modal title={i18n.t("
|
|
587
|
+
<Modal title={i18n.t("node.share")} close={close}>
|
|
543
588
|
{(share.isError || revoke.isError || grants.isError) && (
|
|
544
589
|
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
545
|
-
{i18n.t("
|
|
590
|
+
{i18n.t("node.shareFailed")}
|
|
546
591
|
</p>
|
|
547
592
|
)}
|
|
548
593
|
{/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
|
|
549
|
-
given. A
|
|
594
|
+
given. A node reference across the folder edge is a possible failure, not a way around
|
|
550
595
|
permissions, so nothing here blocks anything (ADR-0004 §4). */}
|
|
551
596
|
{unreadable && (unreadable.titles.length > 0 || unreadable.hidden > 0) && (
|
|
552
597
|
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
553
598
|
{unreadable.titles.length > 0 ? (
|
|
554
599
|
<p>
|
|
555
|
-
{i18n.t("
|
|
600
|
+
{i18n.t("node.shareUnreadable", { titles: unreadable.titles.join(", ") })}
|
|
556
601
|
{unreadable.hidden > 0
|
|
557
|
-
? ` ${i18n.t("
|
|
602
|
+
? ` ${i18n.t("node.shareUnreadableMore", { count: unreadable.hidden })}`
|
|
558
603
|
: ""}
|
|
559
604
|
</p>
|
|
560
605
|
) : (
|
|
561
|
-
<p>{i18n.t("
|
|
606
|
+
<p>{i18n.t("node.shareUnreadableHidden", { count: unreadable.hidden })}</p>
|
|
562
607
|
)}
|
|
563
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
564
|
-
{i18n.t("knowledge.shareUnreadableHint")}
|
|
565
|
-
</p>
|
|
608
|
+
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
|
|
566
609
|
</div>
|
|
567
610
|
)}
|
|
568
611
|
<ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
|
|
@@ -576,15 +619,15 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
576
619
|
? grant.principal.email
|
|
577
620
|
: grant.principal.type === "user"
|
|
578
621
|
? grant.principal.id
|
|
579
|
-
: i18n.t("
|
|
622
|
+
: i18n.t("node.organization")}
|
|
580
623
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
581
|
-
{i18n.t(`
|
|
624
|
+
{i18n.t(`node.verb.${grant.verb}`)}
|
|
582
625
|
</span>
|
|
583
626
|
</span>
|
|
584
627
|
<button
|
|
585
628
|
type="button"
|
|
586
629
|
onClick={() => revoke.mutate(grant.id)}
|
|
587
|
-
aria-label={i18n.t("
|
|
630
|
+
aria-label={i18n.t("node.revokeShare")}
|
|
588
631
|
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
589
632
|
>
|
|
590
633
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
@@ -592,7 +635,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
592
635
|
</li>
|
|
593
636
|
))}
|
|
594
637
|
{grants.data?.items.length === 0 && (
|
|
595
|
-
<li className="text-sm text-muted-foreground">{i18n.t("
|
|
638
|
+
<li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
|
|
596
639
|
)}
|
|
597
640
|
</ul>
|
|
598
641
|
<form
|
|
@@ -603,7 +646,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
603
646
|
className="space-y-4 border-t pt-5"
|
|
604
647
|
>
|
|
605
648
|
<label className="block text-sm font-medium">
|
|
606
|
-
{i18n.t("
|
|
649
|
+
{i18n.t("node.email")}
|
|
607
650
|
<input
|
|
608
651
|
type="email"
|
|
609
652
|
required
|
|
@@ -613,7 +656,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
613
656
|
/>
|
|
614
657
|
</label>
|
|
615
658
|
<fieldset className="space-y-2">
|
|
616
|
-
<legend className="text-sm font-medium">{i18n.t("
|
|
659
|
+
<legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
|
|
617
660
|
{(grants.data?.applicableVerbs ?? []).map((verb) => (
|
|
618
661
|
<label key={verb} className="flex items-center gap-2 text-sm">
|
|
619
662
|
<input
|
|
@@ -628,9 +671,9 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
628
671
|
}
|
|
629
672
|
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
630
673
|
/>
|
|
631
|
-
<span>{i18n.t(`
|
|
674
|
+
<span>{i18n.t(`node.verb.${verb}`)}</span>
|
|
632
675
|
<span className="text-xs text-muted-foreground">
|
|
633
|
-
{i18n.t(
|
|
676
|
+
{i18n.t(verbHintKey(verb, node.kind))}
|
|
634
677
|
</span>
|
|
635
678
|
</label>
|
|
636
679
|
))}
|
|
@@ -640,7 +683,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
|
|
|
640
683
|
disabled={share.isPending || verbs.length === 0}
|
|
641
684
|
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
642
685
|
>
|
|
643
|
-
{i18n.t("
|
|
686
|
+
{i18n.t("node.shareAction")}
|
|
644
687
|
</button>
|
|
645
688
|
</form>
|
|
646
689
|
</Modal>
|