@anchrd/intel-ui 0.6.0 → 0.7.2
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/app-tree/app-tree.tsx +15 -3
- package/src/components/ui/table.tsx +82 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +3 -0
- package/src/entry-picker/entry-picker.tsx +166 -0
- package/src/flows/flows.tsx +154 -149
- package/src/flows/node-icon/node-icon.ts +13 -7
- package/src/flows/node-palette/node-palette.tsx +33 -19
- package/src/folder-contents/folder-contents.tsx +106 -0
- package/src/i18n/en.json +33 -15
- package/src/knowledge/knowledge.tsx +2 -3
- package/src/knowledge-graph/graph-notice.tsx +21 -0
- package/src/knowledge-graph/knowledge-graph.tsx +2 -10
- package/src/resource-menu/resource-menu.tsx +55 -45
|
@@ -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",
|
|
@@ -116,7 +120,6 @@
|
|
|
116
120
|
"view.showEditor": "Back to the editor",
|
|
117
121
|
"view.showRuns": "Show this flow's runs",
|
|
118
122
|
"view.graph": "Relation graph",
|
|
119
|
-
"view.graphDescription": "{nodes} nodes and {edges} relationships you are allowed to see.",
|
|
120
123
|
"view.graphTruncated": "{omitted} more left out — this view draws at most {limit}.",
|
|
121
124
|
"view.graphEmpty": "Nothing here that you may see.",
|
|
122
125
|
"view.graphFailed": "The graph could not be loaded.",
|
|
@@ -158,25 +161,40 @@
|
|
|
158
161
|
"flows.publish": "Publish",
|
|
159
162
|
"flows.publishNothing": "Nothing new to publish",
|
|
160
163
|
"flows.publishNeeded": "Publish — no run can start until you do",
|
|
161
|
-
"flows.
|
|
162
|
-
"flows.
|
|
164
|
+
"flows.validate": "Check whether it would run",
|
|
165
|
+
"flows.validateReady": "This flow would start now.",
|
|
166
|
+
"flows.validateWhen": "Checked {when}. Tool access is asked with your own token each time, so this answer is a snapshot.",
|
|
167
|
+
"flows.problem.flow_execute_forbidden": "You may not run this flow",
|
|
168
|
+
"flows.problem.flow_not_published": "The flow is not published",
|
|
169
|
+
"flows.problem.flow_graph_invalid": "The graph cannot be run",
|
|
170
|
+
"flows.problem.flow_tools_unavailable": "A tool is out of reach",
|
|
171
|
+
"flows.problem.flow_knowledge_forbidden": "Material a step reads is out of reach",
|
|
172
|
+
"flows.problem.flow_subflow_unavailable": "A called flow is unavailable",
|
|
173
|
+
"flows.problem.flow_subflow_not_published": "A called flow is not published",
|
|
163
174
|
"flows.addNode": "Add a step",
|
|
164
175
|
"flows.closePalette": "Close the step bar",
|
|
165
176
|
"flows.nodePalette": "Step types",
|
|
166
177
|
"flows.node.trigger": "Start",
|
|
167
178
|
"flows.startExists": "A flow has exactly one start, and this one already has it.",
|
|
168
179
|
"flows.node.instruction": "Instruction",
|
|
169
|
-
"flows.node.
|
|
180
|
+
"flows.node.folder": "Folder",
|
|
181
|
+
"flows.node.document": "Document",
|
|
182
|
+
"flows.node.upload": "Upload",
|
|
183
|
+
"flows.node.table": "Table",
|
|
170
184
|
"flows.node.tool": "Tool",
|
|
171
185
|
"flows.node.condition": "Condition",
|
|
172
|
-
"flows.node.
|
|
173
|
-
"flows.node.
|
|
174
|
-
"flows.node.output": "Output",
|
|
186
|
+
"flows.node.subflow": "Flow",
|
|
187
|
+
"flows.node.output": "End",
|
|
175
188
|
"flows.instruction": "Instruction for the AI or person",
|
|
176
189
|
"flows.instructionHint": "Conditions, checks and branches can be described here in words. You do not need a separate node for every small decision.",
|
|
177
|
-
"flows.
|
|
178
|
-
"
|
|
179
|
-
"
|
|
190
|
+
"flows.linkEmpty": "Nothing chosen yet",
|
|
191
|
+
"picker.search": "Search by name",
|
|
192
|
+
"picker.top": "All folders",
|
|
193
|
+
"picker.open": "Open {title}",
|
|
194
|
+
"picker.nothing": "Nothing here",
|
|
195
|
+
"flows.layer.marker": "Markers",
|
|
196
|
+
"flows.layer.step": "Steps",
|
|
197
|
+
"flows.layer.link": "Links",
|
|
180
198
|
"flows.subflowTarget": "Flow to call",
|
|
181
199
|
"flows.selectSubflow": "Select a flow to call",
|
|
182
200
|
"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,6 +6,7 @@ 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
12
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -135,9 +136,7 @@ export function Knowledge() {
|
|
|
135
136
|
}
|
|
136
137
|
/>
|
|
137
138
|
) : selected.kind === "folder" ? (
|
|
138
|
-
<
|
|
139
|
-
{i18n.t("knowledge.folderHelp")}
|
|
140
|
-
</div>
|
|
139
|
+
<FolderContents folderId={selected.id} />
|
|
141
140
|
) : selected.kind === "attachment" ? (
|
|
142
141
|
<AttachmentPanel node={selected} />
|
|
143
142
|
) : selected.kind === "table" ? (
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RelationGraph } from "@anchrd/intel-contract";
|
|
2
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one line above a graph, and it appears only when there is something to say (#88).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ It counted what was drawn directly below it — "3 nodes and 0 relationships you are allowed to
|
|
8
|
+
* see" — permanently, above every graph. Three nodes are three nodes; the reader sees them. What
|
|
9
|
+
* they cannot see is what the view stopped short of, and that is what stayed.
|
|
10
|
+
*
|
|
11
|
+
* Its own file because `knowledge-graph.tsx` pulls Sigma in at module level, and Sigma needs WebGL:
|
|
12
|
+
* a case about this line could not render there at all.
|
|
13
|
+
*/
|
|
14
|
+
export function GraphNotice({ data, i18n }: { data: RelationGraph; i18n: I18n }) {
|
|
15
|
+
if (data.omitted === 0) return null;
|
|
16
|
+
return (
|
|
17
|
+
<p className="border-b px-6 py-3 text-sm text-muted-foreground">
|
|
18
|
+
{i18n.t("view.graphTruncated", { omitted: data.omitted, limit: data.limit })}
|
|
19
|
+
</p>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -3,6 +3,7 @@ import { ControlsContainer, SigmaContainer, useCamera, useRegisterEvents } from
|
|
|
3
3
|
import { LocateFixed, Minus, Plus } from "lucide-react";
|
|
4
4
|
import { useEffect, useMemo } from "react";
|
|
5
5
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
|
+
import { GraphNotice } from "./graph-notice.tsx";
|
|
6
7
|
import { createRelationGraph, resolveGraphColor } from "./knowledge-graph.ts";
|
|
7
8
|
|
|
8
9
|
function labelColors() {
|
|
@@ -77,16 +78,7 @@ export function RelationGraphView({
|
|
|
77
78
|
const byId = useMemo(() => new Map(data.nodes.map((node) => [node.id, node])), [data.nodes]);
|
|
78
79
|
return (
|
|
79
80
|
<section aria-label={i18n.t("view.graph")} className="flex min-h-0 flex-1 flex-col">
|
|
80
|
-
<
|
|
81
|
-
{i18n.t("view.graphDescription", { nodes: data.nodes.length, edges: data.edges.length })}
|
|
82
|
-
{/* ⚠️ The cut-off is said out loud. A graph that quietly stopped drawing would read as "this
|
|
83
|
-
folder holds nothing else", which is worse than a graph that admits it is partial. */}
|
|
84
|
-
{data.omitted > 0 ? (
|
|
85
|
-
<span className="ml-2 rounded-md border px-2 py-0.5 text-xs">
|
|
86
|
-
{i18n.t("view.graphTruncated", { omitted: data.omitted, limit: data.limit })}
|
|
87
|
-
</span>
|
|
88
|
-
) : null}
|
|
89
|
-
</p>
|
|
81
|
+
<GraphNotice data={data} i18n={i18n} />
|
|
90
82
|
<div className="min-h-0 flex-1 bg-muted/20">
|
|
91
83
|
{data.nodes.length === 0 ? (
|
|
92
84
|
<div className="grid h-full place-items-center p-8 text-sm text-muted-foreground">
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
ContextPolicy,
|
|
3
2
|
Flow,
|
|
3
|
+
FlowValidation,
|
|
4
4
|
KnowledgeNode,
|
|
5
5
|
ResourceVerb,
|
|
6
6
|
UnreadableKnowledge,
|
|
7
7
|
} from "@anchrd/intel-contract";
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Archive,
|
|
12
|
+
CornerLeftUp,
|
|
13
|
+
Ellipsis,
|
|
14
|
+
Link2,
|
|
15
|
+
Pencil,
|
|
16
|
+
Share2,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
Trash2,
|
|
19
|
+
} from "lucide-react";
|
|
11
20
|
import type * as React from "react";
|
|
12
21
|
import { useState } from "react";
|
|
13
22
|
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
@@ -15,12 +24,7 @@ import {
|
|
|
15
24
|
DropdownMenu,
|
|
16
25
|
DropdownMenuContent,
|
|
17
26
|
DropdownMenuItem,
|
|
18
|
-
DropdownMenuRadioGroup,
|
|
19
|
-
DropdownMenuRadioItem,
|
|
20
27
|
DropdownMenuSeparator,
|
|
21
|
-
DropdownMenuSub,
|
|
22
|
-
DropdownMenuSubContent,
|
|
23
|
-
DropdownMenuSubTrigger,
|
|
24
28
|
DropdownMenuTrigger,
|
|
25
29
|
} from "@/components/ui/dropdown-menu";
|
|
26
30
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
@@ -93,8 +97,6 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
|
|
97
|
-
|
|
98
100
|
/**
|
|
99
101
|
* The three-dot menu.
|
|
100
102
|
*
|
|
@@ -122,6 +124,14 @@ export function ResourceMenu({
|
|
|
122
124
|
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
123
125
|
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
124
126
|
const move = useTreeMove();
|
|
127
|
+
// ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
|
|
128
|
+
// under a key it would be re-served later as if it still held, and the one thing it must never
|
|
129
|
+
// claim is that a moment which has passed is still true.
|
|
130
|
+
const [validation, setValidation] = useState<FlowValidation | null>(null);
|
|
131
|
+
const validate = useMutation({
|
|
132
|
+
mutationFn: () => data.validateFlow(id),
|
|
133
|
+
onSuccess: setValidation,
|
|
134
|
+
});
|
|
125
135
|
|
|
126
136
|
const id = idOf(target);
|
|
127
137
|
const title = titleOf(target);
|
|
@@ -192,26 +202,11 @@ export function ResourceMenu({
|
|
|
192
202
|
},
|
|
193
203
|
});
|
|
194
204
|
|
|
195
|
-
const setContextPolicy = useMutation({
|
|
196
|
-
mutationFn: async (contextPolicy: ContextPolicy) => {
|
|
197
|
-
if (target.type === "flow") throw new Error("A flow has no retrieval mode");
|
|
198
|
-
await data.updateKnowledge({
|
|
199
|
-
nodeId: target.node.id,
|
|
200
|
-
baseUpdatedAt: target.node.updatedAt,
|
|
201
|
-
contextPolicy,
|
|
202
|
-
idempotencyKey: crypto.randomUUID(),
|
|
203
|
-
});
|
|
204
|
-
},
|
|
205
|
-
onSuccess: refresh,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
205
|
const failure = rename.isError
|
|
209
206
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
210
207
|
: archive.isError
|
|
211
208
|
? archive.error
|
|
212
|
-
:
|
|
213
|
-
? setContextPolicy.error
|
|
214
|
-
: undefined;
|
|
209
|
+
: undefined;
|
|
215
210
|
|
|
216
211
|
return (
|
|
217
212
|
<>
|
|
@@ -236,27 +231,15 @@ export function ResourceMenu({
|
|
|
236
231
|
<CornerLeftUp aria-hidden="true" />
|
|
237
232
|
{i18n.t("tree.move.action")}
|
|
238
233
|
</DropdownMenuItem>
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<DropdownMenuSubContent>
|
|
247
|
-
<DropdownMenuRadioGroup
|
|
248
|
-
value={node.contextPolicy}
|
|
249
|
-
onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
|
|
250
|
-
>
|
|
251
|
-
{contextPolicies.map((policy) => (
|
|
252
|
-
<DropdownMenuRadioItem key={policy} value={policy}>
|
|
253
|
-
{i18n.t(`knowledge.context.${policy}`)}
|
|
254
|
-
</DropdownMenuRadioItem>
|
|
255
|
-
))}
|
|
256
|
-
</DropdownMenuRadioGroup>
|
|
257
|
-
</DropdownMenuSubContent>
|
|
258
|
-
</DropdownMenuSub>
|
|
234
|
+
{/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
|
|
235
|
+
which is why it is in the menu and not in the title line (#72). */}
|
|
236
|
+
{node === null ? (
|
|
237
|
+
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
238
|
+
<ShieldCheck aria-hidden="true" />
|
|
239
|
+
{i18n.t("flows.validate")}
|
|
240
|
+
</DropdownMenuItem>
|
|
259
241
|
) : null}
|
|
242
|
+
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
260
243
|
{linkable ? (
|
|
261
244
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
262
245
|
<Link2 aria-hidden="true" />
|
|
@@ -295,6 +278,33 @@ export function ResourceMenu({
|
|
|
295
278
|
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
296
279
|
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
297
280
|
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
281
|
+
{/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
|
|
282
|
+
to ask twice — and a success says so out loud, because an empty menu after a click reads
|
|
283
|
+
as "nothing happened" rather than "nothing is in the way". */}
|
|
284
|
+
{validation ? (
|
|
285
|
+
<Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
|
|
286
|
+
{validation.problems.length === 0 ? (
|
|
287
|
+
<p className="text-sm">{i18n.t("flows.validateReady")}</p>
|
|
288
|
+
) : (
|
|
289
|
+
<ul className="space-y-2">
|
|
290
|
+
{validation.problems.map((problem) => (
|
|
291
|
+
<li key={problem.code} className="rounded-md border p-3 text-sm">
|
|
292
|
+
<span className="block font-medium">
|
|
293
|
+
{i18n.t(`flows.problem.${problem.code}`)}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="mt-1 block text-xs text-muted-foreground">{problem.detail}</span>
|
|
296
|
+
</li>
|
|
297
|
+
))}
|
|
298
|
+
</ul>
|
|
299
|
+
)}
|
|
300
|
+
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
301
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
302
|
+
{i18n.t("flows.validateWhen", {
|
|
303
|
+
when: new Date(validation.checkedAt).toLocaleString(),
|
|
304
|
+
})}
|
|
305
|
+
</p>
|
|
306
|
+
</Modal>
|
|
307
|
+
) : null}
|
|
298
308
|
{move.dialog}
|
|
299
309
|
{renaming ? (
|
|
300
310
|
<RenameDialog
|