@anchrd/intel-ui 0.33.0 → 0.34.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/app-tree/app-tree.tsx +56 -5
- package/src/app/tree-move/tree-move.tsx +5 -5
- package/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/src/entry-picker/entry-picker.tsx +27 -4
- package/src/flow-runs/flow-runs.tsx +9 -1
- package/src/flows/flows.tsx +219 -108
- package/src/graph-pane/graph-pane.tsx +9 -0
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/i18n/es.json +5 -0
- package/src/node-editor/node-editor.tsx +24 -1
- package/src/nodes/nodes.tsx +31 -17
- package/src/resource-menu/resource-menu.tsx +17 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.21.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -150,7 +150,47 @@ export function AppTree() {
|
|
|
150
150
|
return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The levels that still have a row to hang on — `expanded` minus everything the tree no longer
|
|
155
|
+
* shows (#521).
|
|
156
|
+
*
|
|
157
|
+
* ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
|
|
158
|
+
* archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
|
|
159
|
+
* of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
|
|
160
|
+
* screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
|
|
161
|
+
* for, and the number grew with the length of the SESSION rather than with what is on screen.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
|
|
164
|
+
* is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
|
|
165
|
+
* can only correct the list one render too late — by which time the request has been made.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
|
|
168
|
+
* keep vouching for the levels under it, or a whole opened branch would survive its top.
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
|
|
171
|
+
* `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
|
|
172
|
+
* empty a moment ago still says `openable: false` at that instant — judging on it would close the
|
|
173
|
+
* folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
|
|
174
|
+
* keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
|
|
175
|
+
*/
|
|
176
|
+
const rowLevel = (entry: TreeEntry) =>
|
|
177
|
+
`${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
|
|
178
|
+
const rootLevel: Level = { id: null, type: "folder" };
|
|
179
|
+
const open: Level[] = [];
|
|
180
|
+
let frontier: Level[] = [rootLevel];
|
|
181
|
+
let waiting = [...expanded];
|
|
182
|
+
while (frontier.length > 0 && waiting.length > 0) {
|
|
183
|
+
const shown = new Set(
|
|
184
|
+
frontier.flatMap((level) =>
|
|
185
|
+
(queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
|
|
189
|
+
waiting = waiting.filter((level) => !frontier.includes(level));
|
|
190
|
+
open.push(...frontier);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parents: Level[] = [rootLevel, ...open];
|
|
154
194
|
const levels = useQueries({
|
|
155
195
|
queries: parents.map((parent) => ({
|
|
156
196
|
queryKey: levelKey(parent),
|
|
@@ -167,10 +207,19 @@ export function AppTree() {
|
|
|
167
207
|
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
168
208
|
const root = levels[0];
|
|
169
209
|
|
|
170
|
-
|
|
210
|
+
// ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
|
|
211
|
+
// above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
|
|
212
|
+
// `expanded` until the tab is closed — and a folder that comes back would come back open, which
|
|
213
|
+
// is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
|
|
214
|
+
// is an event, so there is no render to loop through.
|
|
215
|
+
function toggle(level: Level & { id: string }, wanted: boolean) {
|
|
171
216
|
setExpanded((current) => {
|
|
172
|
-
const rest = current.filter(
|
|
173
|
-
|
|
217
|
+
const rest = current.filter(
|
|
218
|
+
(entry) =>
|
|
219
|
+
(entry.id !== level.id || entry.type !== level.type) &&
|
|
220
|
+
open.some((live) => live.id === entry.id && live.type === entry.type),
|
|
221
|
+
);
|
|
222
|
+
return wanted ? [...rest, level] : rest;
|
|
174
223
|
});
|
|
175
224
|
}
|
|
176
225
|
|
|
@@ -441,8 +490,10 @@ export function AppTree() {
|
|
|
441
490
|
// reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
|
|
442
491
|
// on every folder and every flow, empty ones included.
|
|
443
492
|
const expandable = entry.openable;
|
|
493
|
+
// ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
|
|
494
|
+
// tree would draw a level it never asked for (#521).
|
|
444
495
|
const isOpen =
|
|
445
|
-
expandable &&
|
|
496
|
+
expandable && open.some((live) => live.id === entry.id && live.type === level.type);
|
|
446
497
|
const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
|
|
447
498
|
const area = entry.type === "flow" ? "/flows" : "/nodes";
|
|
448
499
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
@@ -47,11 +47,11 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
|
47
47
|
* level is not always the one the record names: since #429 a shared row also stands at the root
|
|
48
48
|
* (`levelsToClear`).
|
|
49
49
|
*
|
|
50
|
-
* ⚠️ What it costs is one request per
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* ⚠️ What it costs is one request per level the reader has OPEN — bounded by the screen, not by how
|
|
51
|
+
* long the tab has been sitting there. `app-tree.tsx` loads a level only while the row it hangs on
|
|
52
|
+
* still stands in the level above it, so a folder that was opened and has since left the tree —
|
|
53
|
+
* archived, moved away, its share revoked — takes its query with it rather than being re-read by
|
|
54
|
+
* every write for the rest of the session.
|
|
55
55
|
*
|
|
56
56
|
* The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
|
|
57
57
|
* anywhere, and the screen writing it does not know where.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Refusal } from "@/data/request-refusal/request-refusal.ts";
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a refusal is told with, on every surface that has one.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose share was
|
|
8
|
+
* revoked was left in front of "Loading…" — the worst answer of all, because it says the program is
|
|
9
|
+
* still working. They get a sentence and no retry button: the answer was final, and a button that
|
|
10
|
+
* changes nothing is an invitation to keep waiting.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same status
|
|
13
|
+
* for "no such node" and "not for you" on purpose — `requireVisible` in
|
|
14
|
+
* `packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that from the
|
|
15
|
+
* other side.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ One function rather than a block per branch, because two answers to one question on one
|
|
18
|
+
* surface IS #445. A screen's halves — the empty level, the selected item, the view beside it — are
|
|
19
|
+
* asked the same thing, and copies drift.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **Why this became shared here and not one copy earlier.** `nodes.tsx` (#445) and `flows.tsx`
|
|
22
|
+
* (#557) each carried an identical version, and #557 wrote its reason on the second one: this
|
|
23
|
+
* repository builds an abstraction after the third time it hurts, not the second. #568 is the third
|
|
24
|
+
* time — `?view=graph` and `?view=runs` are the third and fourth surface asking for the same
|
|
25
|
+
* sentence — so what would have been a third copy is this file. The rule did not bend; it was met.
|
|
26
|
+
*/
|
|
27
|
+
export function RefusalNotice({ refusal }: { refusal: Refusal }) {
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
return (
|
|
30
|
+
<div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
31
|
+
<p className="max-w-sm text-muted-foreground">
|
|
32
|
+
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -92,6 +92,24 @@ export function EntryPicker({
|
|
|
92
92
|
}, [wanted]);
|
|
93
93
|
const eligible = useMemo(() => nodes.filter(pickable), [nodes, pickable]);
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether an entry stands on the picker's start level.
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ The root is not `parentId === null` but the upper edge of what the reader may see — the same
|
|
99
|
+
* rule #429 gave the server in `levelPredicate`, at the second surface (#447). A recipient whose
|
|
100
|
+
* only access is a NESTED share carries a `parentId` pointing at a folder that is not in the list
|
|
101
|
+
* at all: under the old rule their start level was empty and STAYED empty, because the folder
|
|
102
|
+
* that would open it can never be clicked. Only searching reached them.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ Nothing is added by this. The list is what `getNodeGraph()` — and `listFlows()` where flows
|
|
105
|
+
* were asked for — answered, and the answer is already the authorized one. What changes is only
|
|
106
|
+
* which level an entry stands on.
|
|
107
|
+
*/
|
|
108
|
+
const rooted = useMemo(() => {
|
|
109
|
+
const present = new Set(nodes.map((node) => node.id));
|
|
110
|
+
return (entry: PickerEntry) => entry.parentId === null || !present.has(entry.parentId);
|
|
111
|
+
}, [nodes]);
|
|
112
|
+
|
|
95
113
|
// Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
|
|
96
114
|
// are the same list seen two ways, which is why a result can be picked from either.
|
|
97
115
|
const searching = query.trim().length > 0;
|
|
@@ -100,8 +118,10 @@ export function EntryPicker({
|
|
|
100
118
|
const needle = query.trim().toLowerCase();
|
|
101
119
|
return eligible.filter((node) => node.title.toLowerCase().includes(needle)).slice(0, 50);
|
|
102
120
|
}
|
|
103
|
-
return nodes.filter((node) =>
|
|
104
|
-
|
|
121
|
+
return nodes.filter((node) =>
|
|
122
|
+
openFolder === null ? rooted(node) : node.parentId === openFolder,
|
|
123
|
+
);
|
|
124
|
+
}, [searching, query, eligible, nodes, openFolder, rooted]);
|
|
105
125
|
|
|
106
126
|
// The way back up, as the chain of folders that leads to the open one.
|
|
107
127
|
const trail = useMemo(() => {
|
|
@@ -111,10 +131,13 @@ export function EntryPicker({
|
|
|
111
131
|
const folder = nodes.find((node) => node.id === current);
|
|
112
132
|
if (!folder) break;
|
|
113
133
|
chain.unshift(folder);
|
|
114
|
-
|
|
134
|
+
// ⚠️ The chain ends where the start level begins, and by the same rule that decides it. A
|
|
135
|
+
// folder at the upper edge has an ancestor the reader cannot see; walking into it would look
|
|
136
|
+
// for a level that does not exist here, and the "top" button above leads to this one.
|
|
137
|
+
current = rooted(folder) ? null : folder.parentId;
|
|
115
138
|
}
|
|
116
139
|
return chain;
|
|
117
|
-
}, [openFolder, nodes]);
|
|
140
|
+
}, [openFolder, nodes, rooted]);
|
|
118
141
|
|
|
119
142
|
const chosen = nodes.find((node) => node.id === value) ?? null;
|
|
120
143
|
|
|
@@ -2,6 +2,8 @@ import type { FlowRunSummary } from "@anchrd/intel-contract/flow-run";
|
|
|
2
2
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { ChevronDown, ChevronRight, CornerDownRight } from "lucide-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
6
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
5
7
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
8
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -45,6 +47,11 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
45
47
|
getNextPageParam: (last) => last.nextCursor,
|
|
46
48
|
});
|
|
47
49
|
const items = runs.data?.pages.flatMap((page) => page.items) ?? [];
|
|
50
|
+
// ⚠️ This list's own refusal, told here rather than upstream (#568). The screen above answers
|
|
51
|
+
// whether the flow may be opened; `listFlowRuns` answers whether its history may be read, and it
|
|
52
|
+
// can refuse on its own. Everything that is not a refusal keeps the sentence and the button
|
|
53
|
+
// below — a `502` really can answer differently next time.
|
|
54
|
+
const refusal = refusalOf(runs.error);
|
|
48
55
|
|
|
49
56
|
return (
|
|
50
57
|
<section aria-label={i18n.t("runs.title")} className="flex min-h-0 flex-1 flex-col">
|
|
@@ -68,7 +75,8 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
68
75
|
{runs.isPending && (
|
|
69
76
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
70
77
|
)}
|
|
71
|
-
{
|
|
78
|
+
{refusal && <RefusalNotice refusal={refusal} />}
|
|
79
|
+
{runs.isError && !refusal && (
|
|
72
80
|
<div role="alert" className="space-y-3 text-sm">
|
|
73
81
|
<p className="text-destructive">{i18n.t("runs.failedToLoad")}</p>
|
|
74
82
|
<button
|
package/src/flows/flows.tsx
CHANGED
|
@@ -24,15 +24,19 @@ import {
|
|
|
24
24
|
useReactFlow,
|
|
25
25
|
} from "@xyflow/react";
|
|
26
26
|
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
27
|
-
import { useEffect, useId, useMemo, useState } from "react";
|
|
27
|
+
import { createContext, useContext, useEffect, useId, useMemo, useState } from "react";
|
|
28
28
|
import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
|
|
29
29
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
30
30
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
31
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
32
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
33
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
31
34
|
import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
|
|
32
35
|
import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
|
|
33
36
|
import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
34
37
|
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
35
38
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
39
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
36
40
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
37
41
|
import { Modal } from "@/modal/modal.tsx";
|
|
38
42
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -90,9 +94,24 @@ const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
|
|
|
90
94
|
flow: "subflow",
|
|
91
95
|
};
|
|
92
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The tree links of this flow that name nothing any more, by resource id (#509).
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ A context rather than a field on the node's `data`, because the canvas nodes are React state
|
|
101
|
+
* that the loaded version owns: writing the answer of a query into them would put a second writer
|
|
102
|
+
* on that state and let a background refetch land in the middle of an edit. The set is read where
|
|
103
|
+
* it is drawn and nowhere else.
|
|
104
|
+
*/
|
|
105
|
+
const InvalidLinks = createContext<ReadonlySet<string>>(new Set());
|
|
106
|
+
|
|
93
107
|
function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
94
108
|
const i18n = useI18n();
|
|
95
109
|
const contract = data.node;
|
|
110
|
+
const invalidLinks = useContext(InvalidLinks);
|
|
111
|
+
// ⚠️ Said in a WORD, not by colour alone. A card that only turned red would say nothing to a
|
|
112
|
+
// colour-blind reader and nothing at all to a screen reader — and this is the one state on the
|
|
113
|
+
// canvas that stops the flow from running (#509).
|
|
114
|
+
const broken = isLinkNode(contract) && invalidLinks.has(contract.configuration.resourceId);
|
|
96
115
|
const Icon = nodeIcon[contract.kind];
|
|
97
116
|
const branching = contract.kind === "condition";
|
|
98
117
|
// ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
|
|
@@ -117,9 +136,14 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
|
117
136
|
<span className="min-w-0">
|
|
118
137
|
<span className="block truncate text-sm font-medium">{contract.label}</span>
|
|
119
138
|
{/* The kind through the catalog, not the raw key: the start node is called Start on the
|
|
120
|
-
canvas as well, and `trigger` would name an event that no longer exists (#39).
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
canvas as well, and `trigger` would name an event that no longer exists (#39).
|
|
140
|
+
⚠️ A broken link says so INSTEAD of naming its kind, because "Document" beside a
|
|
141
|
+
reference that names nothing is the sentence #509 is about: the card looked like every
|
|
142
|
+
valid link on the canvas. */}
|
|
143
|
+
<span
|
|
144
|
+
className={`block text-xs ${broken ? "text-destructive" : "text-muted-foreground"}`}
|
|
145
|
+
>
|
|
146
|
+
{broken ? i18n.t("flows.nodeInvalid") : i18n.t(`flows.node.${contract.kind}`)}
|
|
123
147
|
</span>
|
|
124
148
|
</span>
|
|
125
149
|
{riding === "follows" || riding === "pinned" ? (
|
|
@@ -348,6 +372,31 @@ function newNode(
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Which sentence a refused SAVE is told with.
|
|
377
|
+
*
|
|
378
|
+
* ⚠️ `400 flow_graph_invalid` and `409` are two different answers, and until #508 they shared one
|
|
379
|
+
* sentence — the conflict one. For a rejected graph that advice is not merely useless but
|
|
380
|
+
* destructive: reloading discards the unsaved canvas, and the next attempt is refused for exactly
|
|
381
|
+
* the same reason, so following it costs the work and changes nothing.
|
|
382
|
+
*
|
|
383
|
+
* The graph rule therefore shows the API's OWN sentence: only the server knows which step broke
|
|
384
|
+
* which rule, and it names that step by its label since #508. A conflict keeps the reload sentence,
|
|
385
|
+
* because there reloading IS the way out. Everything else gets neither — a timeout or a `502` is
|
|
386
|
+
* worth repeating, and nothing about it says the loaded version is stale.
|
|
387
|
+
*
|
|
388
|
+
* ⚠️ Not `resourceErrorKey`: that maps a code onto a catalog key, and this case has no key to map
|
|
389
|
+
* onto — its whole content is the server's sentence.
|
|
390
|
+
*/
|
|
391
|
+
function saveRefusalText(error: unknown, i18n: I18n): string {
|
|
392
|
+
const refusal = error instanceof IntelRequestError ? error : null;
|
|
393
|
+
if (refusal?.status === 400 && refusal.code === "flow_graph_invalid") {
|
|
394
|
+
return i18n.t("flows.saveInvalid", { detail: refusal.message });
|
|
395
|
+
}
|
|
396
|
+
if (refusal?.status === 409) return i18n.t("flows.saveConflict");
|
|
397
|
+
return i18n.t("flows.saveFailed");
|
|
398
|
+
}
|
|
399
|
+
|
|
351
400
|
// ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
|
|
352
401
|
// drop turns a screen point into a graph point — has to be called from a component UNDER it, and
|
|
353
402
|
// the hook lives in the same component that renders the canvas.
|
|
@@ -401,6 +450,21 @@ function FlowsEditor() {
|
|
|
401
450
|
queryKey: ["flows"],
|
|
402
451
|
queryFn: () => data.listFlows(),
|
|
403
452
|
});
|
|
453
|
+
/**
|
|
454
|
+
* The refusal of the top level, read off the query the picker already holds.
|
|
455
|
+
*
|
|
456
|
+
* ⚠️ No second query, and that is the whole difference to #445 next door: `["flows"]` is asked
|
|
457
|
+
* with or without a selection, so the answer for the empty screen is already in the cache. What
|
|
458
|
+
* the screen did was throw it away — `listTreeChildren` fails whole once `flows/read` is missing,
|
|
459
|
+
* the sidebar has said so since #430, and the main area beside it went on inviting the reader to
|
|
460
|
+
* pick a flow they cannot see (#557).
|
|
461
|
+
*
|
|
462
|
+
* ⚠️ Off the ERROR rather than off a missing answer. A sentence about a permission, shown while
|
|
463
|
+
* the request is still travelling, claims a refusal nobody has spoken yet — the mistake from #350,
|
|
464
|
+
* and `packages/ui/CLAUDE.md` has the rule: a missing answer is a reason to say less, never to
|
|
465
|
+
* refuse more. Until a refusal arrives, what stands is the invitation, which claims nothing.
|
|
466
|
+
*/
|
|
467
|
+
const rootRefusal = refusalOf(callable.error);
|
|
404
468
|
// The tree in the sidebar and the header search both name the flow to open through `?select=`;
|
|
405
469
|
// `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
|
|
406
470
|
const selection = useRouterState({
|
|
@@ -426,6 +490,10 @@ function FlowsEditor() {
|
|
|
426
490
|
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
|
427
491
|
enabled: Boolean(selectedFlowId),
|
|
428
492
|
});
|
|
493
|
+
// ⚠️ A refusal is not a failure. "Reload the latest version and try again." is advice for a
|
|
494
|
+
// network that came back; against a `403` or a `404` it sends somebody to reload a screen that
|
|
495
|
+
// will answer the same thing forever, and it hides that the answer was final (#430).
|
|
496
|
+
const documentRefusal = refusalOf(document.error);
|
|
429
497
|
const initial = useMemo(() => canvas(defaultGraph()), []);
|
|
430
498
|
const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
|
|
431
499
|
const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
|
|
@@ -450,6 +518,19 @@ function FlowsEditor() {
|
|
|
450
518
|
setDirty(false);
|
|
451
519
|
}, [document.data, selectedFlowId]);
|
|
452
520
|
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
|
521
|
+
// ⚠️ The same query `FlowNeeds` asks, under the same key: TanStack answers both from one cache
|
|
522
|
+
// entry, so the canvas and the panel cannot end up disagreeing about which link is dead. A second
|
|
523
|
+
// key would be a second answer to one question, and the one people would trust is the one they
|
|
524
|
+
// happen to be looking at (#509).
|
|
525
|
+
const needs = useQuery({
|
|
526
|
+
queryKey: ["flow-requirements", selectedFlowId],
|
|
527
|
+
queryFn: () => data.getFlowRequirements(selectedFlowId ?? ""),
|
|
528
|
+
enabled: Boolean(selectedFlowId),
|
|
529
|
+
});
|
|
530
|
+
const invalidLinks = useMemo(
|
|
531
|
+
() => new Set(needs.data?.invalidNodes ?? []),
|
|
532
|
+
[needs.data?.invalidNodes],
|
|
533
|
+
);
|
|
453
534
|
|
|
454
535
|
const save = useMutation({
|
|
455
536
|
mutationFn: () =>
|
|
@@ -496,9 +577,25 @@ function FlowsEditor() {
|
|
|
496
577
|
) : null}
|
|
497
578
|
<UnsavedChangesGuard dirty={dirty} />
|
|
498
579
|
<div className="flex min-h-0 flex-1">
|
|
499
|
-
{selectedFlowId
|
|
580
|
+
{!selectedFlowId ? (
|
|
581
|
+
rootRefusal ? (
|
|
582
|
+
<RefusalNotice refusal={rootRefusal} />
|
|
583
|
+
) : (
|
|
584
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
585
|
+
{i18n.t("flows.select")}
|
|
586
|
+
</div>
|
|
587
|
+
)
|
|
588
|
+
) : /* ⚠️ The refusal comes BEFORE the view switch, and that placement is the whole of #568.
|
|
589
|
+
`?view=graph` and `?view=runs` used to be read first, so a flow the reader may not
|
|
590
|
+
open still rendered its two side views — each with a failure sentence and a "Try
|
|
591
|
+
again" button on an answer that will not change (#430). Whether the reader may see
|
|
592
|
+
this flow at all is answered by `getFlow`, once, for every view of it; asking it
|
|
593
|
+
after the switch means asking it three times and getting two of them wrong. */
|
|
594
|
+
documentRefusal ? (
|
|
595
|
+
<RefusalNotice refusal={documentRefusal} />
|
|
596
|
+
) : selection.view === "runs" ? (
|
|
500
597
|
<FlowRuns flowId={selectedFlowId} />
|
|
501
|
-
) :
|
|
598
|
+
) : selection.view === "graph" ? (
|
|
502
599
|
<GraphPane
|
|
503
600
|
query={relations}
|
|
504
601
|
select={(node) =>
|
|
@@ -508,10 +605,6 @@ function FlowsEditor() {
|
|
|
508
605
|
})
|
|
509
606
|
}
|
|
510
607
|
/>
|
|
511
|
-
) : !selectedFlowId ? (
|
|
512
|
-
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
513
|
-
{i18n.t("flows.select")}
|
|
514
|
-
</div>
|
|
515
608
|
) : document.isError ? (
|
|
516
609
|
<div
|
|
517
610
|
role="alert"
|
|
@@ -566,102 +659,104 @@ function FlowsEditor() {
|
|
|
566
659
|
media query that sets the class, so both turn together and mid-session. What the
|
|
567
660
|
surfaces are actually coloured with is in `styles.css`, through the library's own
|
|
568
661
|
theming variables — for the same reason `.react-sigma` is there. */}
|
|
569
|
-
<
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
(connection
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
662
|
+
<InvalidLinks.Provider value={invalidLinks}>
|
|
663
|
+
<ReactFlow<CanvasNode, CanvasEdge>
|
|
664
|
+
colorMode={theme}
|
|
665
|
+
nodes={nodes}
|
|
666
|
+
edges={edges}
|
|
667
|
+
nodeTypes={nodeTypes}
|
|
668
|
+
fitView
|
|
669
|
+
onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
|
|
670
|
+
if (editsNodes(changes)) setDirty(true);
|
|
671
|
+
setNodes((current) => applyNodeChanges(changes, current));
|
|
672
|
+
}}
|
|
673
|
+
onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
|
|
674
|
+
if (editsEdges(changes)) setDirty(true);
|
|
675
|
+
setEdges((current) => applyEdgeChanges(changes, current));
|
|
676
|
+
}}
|
|
677
|
+
// ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
|
|
678
|
+
// a flow input would be stored as context and drawn as an attachment, while the
|
|
679
|
+
// author meant "and then". Refused at the point where it is still visible.
|
|
680
|
+
isValidConnection={(connection) =>
|
|
681
|
+
(connection.sourceHandle === ContextHandle) ===
|
|
682
|
+
(connection.targetHandle === ContextHandle)
|
|
683
|
+
}
|
|
684
|
+
onConnect={(connection: Connection) => {
|
|
685
|
+
setDirty(true);
|
|
686
|
+
setEdges((current) =>
|
|
687
|
+
addEdge(
|
|
688
|
+
{
|
|
689
|
+
...connection,
|
|
690
|
+
id: crypto.randomUUID(),
|
|
691
|
+
...contextEdgeStyle(connection.sourceHandle === ContextHandle),
|
|
692
|
+
},
|
|
693
|
+
current,
|
|
694
|
+
),
|
|
695
|
+
);
|
|
696
|
+
}}
|
|
697
|
+
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
|
|
698
|
+
onPaneClick={() => {
|
|
699
|
+
setSelectedNodeId(null);
|
|
700
|
+
setPaletteOpen(false);
|
|
701
|
+
}}
|
|
702
|
+
// ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
|
|
703
|
+
// have to feel different while the pointer is still travelling: dropping here makes
|
|
704
|
+
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
705
|
+
onDragOver={(event) => {
|
|
706
|
+
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
event.dataTransfer.dropEffect = "copy";
|
|
709
|
+
}}
|
|
710
|
+
onDrop={(event) => {
|
|
711
|
+
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
712
|
+
if (!payload) return;
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
const dropped = droppedEntry(payload);
|
|
715
|
+
if (!dropped) return;
|
|
716
|
+
const kind = flowKindOfEntry[dropped.kind];
|
|
717
|
+
if (!kind) return;
|
|
718
|
+
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
719
|
+
// the node would land somewhere else at every zoom level.
|
|
720
|
+
const position = flow.screenToFlowPosition({
|
|
721
|
+
x: event.clientX,
|
|
722
|
+
y: event.clientY,
|
|
723
|
+
});
|
|
724
|
+
setDirty(true);
|
|
725
|
+
setNodes((current) => [
|
|
726
|
+
...current,
|
|
594
727
|
{
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
611
|
-
onDragOver={(event) => {
|
|
612
|
-
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
613
|
-
event.preventDefault();
|
|
614
|
-
event.dataTransfer.dropEffect = "copy";
|
|
615
|
-
}}
|
|
616
|
-
onDrop={(event) => {
|
|
617
|
-
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
618
|
-
if (!payload) return;
|
|
619
|
-
event.preventDefault();
|
|
620
|
-
const dropped = droppedEntry(payload);
|
|
621
|
-
if (!dropped) return;
|
|
622
|
-
const kind = flowKindOfEntry[dropped.kind];
|
|
623
|
-
if (!kind) return;
|
|
624
|
-
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
625
|
-
// the node would land somewhere else at every zoom level.
|
|
626
|
-
const position = flow.screenToFlowPosition({
|
|
627
|
-
x: event.clientX,
|
|
628
|
-
y: event.clientY,
|
|
629
|
-
});
|
|
630
|
-
setDirty(true);
|
|
631
|
-
setNodes((current) => [
|
|
632
|
-
...current,
|
|
633
|
-
{
|
|
634
|
-
id: `${kind}-${crypto.randomUUID()}`,
|
|
635
|
-
type: "intel" as const,
|
|
636
|
-
position,
|
|
637
|
-
data: {
|
|
638
|
-
node: {
|
|
639
|
-
id: `${kind}-${crypto.randomUUID()}`,
|
|
640
|
-
kind,
|
|
641
|
-
label: dropped.title,
|
|
642
|
-
position,
|
|
643
|
-
configuration:
|
|
644
|
-
kind === "subflow"
|
|
645
|
-
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
646
|
-
: { resourceId: dropped.id },
|
|
647
|
-
} as FlowNode,
|
|
728
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
729
|
+
type: "intel" as const,
|
|
730
|
+
position,
|
|
731
|
+
data: {
|
|
732
|
+
node: {
|
|
733
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
734
|
+
kind,
|
|
735
|
+
label: dropped.title,
|
|
736
|
+
position,
|
|
737
|
+
configuration:
|
|
738
|
+
kind === "subflow"
|
|
739
|
+
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
740
|
+
: { resourceId: dropped.id },
|
|
741
|
+
} as FlowNode,
|
|
742
|
+
},
|
|
648
743
|
},
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
{/* The overview map has no room for a label, so the start is marked there the only
|
|
744
|
+
]);
|
|
745
|
+
}}
|
|
746
|
+
>
|
|
747
|
+
<Background />
|
|
748
|
+
{/* The overview map has no room for a label, so the start is marked there the only
|
|
655
749
|
way that is left: its own colour, from the tokens rather than a fixed value. */}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
750
|
+
<MiniMap
|
|
751
|
+
pannable
|
|
752
|
+
zoomable
|
|
753
|
+
nodeClassName={(node) =>
|
|
754
|
+
(node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
|
|
755
|
+
}
|
|
756
|
+
/>
|
|
757
|
+
<Controls />
|
|
758
|
+
</ReactFlow>
|
|
759
|
+
</InvalidLinks.Provider>
|
|
665
760
|
</section>
|
|
666
761
|
<aside className="flex w-80 shrink-0 flex-col border-l bg-card">
|
|
667
762
|
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
|
@@ -696,7 +791,7 @@ function FlowsEditor() {
|
|
|
696
791
|
role="alert"
|
|
697
792
|
className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
698
793
|
>
|
|
699
|
-
{
|
|
794
|
+
{saveRefusalText(save.error, i18n)}
|
|
700
795
|
</p>
|
|
701
796
|
)}
|
|
702
797
|
{publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
|
|
@@ -712,7 +807,9 @@ function FlowsEditor() {
|
|
|
712
807
|
// graph that just changed.
|
|
713
808
|
await Promise.all([
|
|
714
809
|
queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
|
|
715
|
-
queryClient.invalidateQueries({
|
|
810
|
+
queryClient.invalidateQueries({
|
|
811
|
+
queryKey: ["flow-publish-preview", selectedFlowId],
|
|
812
|
+
}),
|
|
716
813
|
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
717
814
|
]);
|
|
718
815
|
}}
|
|
@@ -1249,11 +1346,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1249
1346
|
queryKey: ["flow-requirements", flowId],
|
|
1250
1347
|
queryFn: () => data.getFlowRequirements(flowId),
|
|
1251
1348
|
});
|
|
1252
|
-
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden are
|
|
1253
|
-
// answers, so the counted
|
|
1349
|
+
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden or dead are three
|
|
1350
|
+
// different answers, so neither the counted nor the broken ones may fall out of this condition —
|
|
1351
|
+
// otherwise the panel claims the first when it means one of the others.
|
|
1254
1352
|
const empty =
|
|
1255
1353
|
needs.data !== undefined &&
|
|
1256
1354
|
needs.data.nodes.length === 0 &&
|
|
1355
|
+
needs.data.invalidNodes.length === 0 &&
|
|
1257
1356
|
needs.data.hiddenNodes === 0 &&
|
|
1258
1357
|
needs.data.servers.length === 0;
|
|
1259
1358
|
return (
|
|
@@ -1270,7 +1369,9 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1270
1369
|
{empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
|
|
1271
1370
|
{needs.data && !empty && (
|
|
1272
1371
|
<>
|
|
1273
|
-
{(needs.data.nodes.length > 0 ||
|
|
1372
|
+
{(needs.data.nodes.length > 0 ||
|
|
1373
|
+
needs.data.invalidNodes.length > 0 ||
|
|
1374
|
+
needs.data.hiddenNodes > 0) && (
|
|
1274
1375
|
<>
|
|
1275
1376
|
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1276
1377
|
{i18n.t("flows.needsNodes")}
|
|
@@ -1282,6 +1383,16 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1282
1383
|
<span className="min-w-0 truncate">{reference.title}</span>
|
|
1283
1384
|
</li>
|
|
1284
1385
|
))}
|
|
1386
|
+
{/* ⚠️ A dead reference and an unreachable one are two entries, not one (#509).
|
|
1387
|
+
They used to share the counted line, which said "you cannot see it" about a
|
|
1388
|
+
document that is gone — a sentence about a permission, in front of something no
|
|
1389
|
+
permission can fix. This one says what to do instead, because there is exactly
|
|
1390
|
+
one thing to do: replace the step or take it out. */}
|
|
1391
|
+
{needs.data.invalidNodes.length > 0 && (
|
|
1392
|
+
<li className="text-destructive">
|
|
1393
|
+
{i18n.t("flows.needsInvalid", { count: needs.data.invalidNodes.length })}
|
|
1394
|
+
</li>
|
|
1395
|
+
)}
|
|
1285
1396
|
{/* ⚠️ Counted, never named. A title is exactly what someone without access to the
|
|
1286
1397
|
document may not learn from a list about it. */}
|
|
1287
1398
|
{needs.data.hiddenNodes > 0 && (
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { RelationGraph } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { UseQueryResult } from "@tanstack/react-query";
|
|
3
3
|
import { lazy, Suspense } from "react";
|
|
4
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
5
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
4
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
7
|
|
|
6
8
|
const RelationGraphView = lazy(async () => ({
|
|
@@ -20,6 +22,13 @@ export function GraphPane({
|
|
|
20
22
|
const i18n = useI18n();
|
|
21
23
|
const loading = <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
22
24
|
if (query.isPending) return loading;
|
|
25
|
+
// ⚠️ Read off this pane's OWN error, not handed down from the screen around it (#568). The screen
|
|
26
|
+
// above answers whether the flow or folder may be opened at all; this answers whether its
|
|
27
|
+
// relations may be read, and the graph endpoint can refuse on its own — an authorized folder
|
|
28
|
+
// whose graph is not shared. Two questions, so two places, and the one that is refused says so
|
|
29
|
+
// where it happened.
|
|
30
|
+
const refusal = refusalOf(query.error);
|
|
31
|
+
if (refusal) return <RefusalNotice refusal={refusal} />;
|
|
23
32
|
if (query.isError || !query.data) {
|
|
24
33
|
return (
|
|
25
34
|
<div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
package/src/i18n/de.json
CHANGED
|
@@ -259,6 +259,7 @@
|
|
|
259
259
|
"flows.node.condition": "Bedingung",
|
|
260
260
|
"flows.node.subflow": "Flow",
|
|
261
261
|
"flows.node.output": "Ende",
|
|
262
|
+
"flows.nodeInvalid": "Gibt es nicht mehr — ersetze oder entferne diesen Schritt",
|
|
262
263
|
"flows.instruction": "Anweisung für die KI oder die Person",
|
|
263
264
|
"flows.instructionHint": "Bedingungen, Prüfungen und Verzweigungen lassen sich hier in Worten beschreiben. Für jede kleine Entscheidung braucht es keinen eigenen Schritt.",
|
|
264
265
|
"flows.linkEmpty": "Noch nichts gewählt",
|
|
@@ -293,9 +294,13 @@
|
|
|
293
294
|
"flows.tool": "MCP-Server",
|
|
294
295
|
"flows.selectTool": "Server auswählen",
|
|
295
296
|
"flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
|
|
297
|
+
"flows.saveInvalid": "Nicht gespeichert: {detail}",
|
|
298
|
+
"flows.saveConflict": "Nicht gespeichert: Jemand anderes hat diesen Flow zuerst geändert. Lade die neueste Fassung und versuche es erneut.",
|
|
299
|
+
"flows.saveFailed": "Der Flow wurde nicht gespeichert. Versuche es erneut.",
|
|
296
300
|
"flows.needs": "Was dieser Flow braucht",
|
|
297
301
|
"flows.needsNodes": "Dokumente",
|
|
298
302
|
"flows.needsTools": "Werkzeuge",
|
|
303
|
+
"flows.needsInvalid": "{count} weitere, die es nicht mehr gibt — ersetze oder entferne diese Schritte",
|
|
299
304
|
"flows.needsHidden": "{count} weitere, die du nicht sehen kannst",
|
|
300
305
|
"flows.needsEmpty": "Dieser Flow liest keine Dokumente und ruft keine Werkzeuge auf.",
|
|
301
306
|
"flows.needsFailed": "Was dieser Flow braucht, konnte nicht geladen werden.",
|
package/src/i18n/en.json
CHANGED
|
@@ -259,6 +259,7 @@
|
|
|
259
259
|
"flows.node.condition": "Condition",
|
|
260
260
|
"flows.node.subflow": "Flow",
|
|
261
261
|
"flows.node.output": "End",
|
|
262
|
+
"flows.nodeInvalid": "Gone — replace or remove this step",
|
|
262
263
|
"flows.instruction": "Instruction for the AI or person",
|
|
263
264
|
"flows.instructionHint": "Conditions, checks and branches can be described here in words. You do not need a separate node for every small decision.",
|
|
264
265
|
"flows.linkEmpty": "Nothing chosen yet",
|
|
@@ -293,9 +294,13 @@
|
|
|
293
294
|
"flows.tool": "MCP server",
|
|
294
295
|
"flows.selectTool": "Select a server",
|
|
295
296
|
"flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
|
|
297
|
+
"flows.saveInvalid": "Not saved: {detail}",
|
|
298
|
+
"flows.saveConflict": "Not saved: somebody else changed this flow first. Reload the latest version and try again.",
|
|
299
|
+
"flows.saveFailed": "The flow was not saved. Try again.",
|
|
296
300
|
"flows.needs": "What this flow needs",
|
|
297
301
|
"flows.needsNodes": "Documents",
|
|
298
302
|
"flows.needsTools": "Tools",
|
|
303
|
+
"flows.needsInvalid": "{count} more that no longer exist — replace or remove those steps",
|
|
299
304
|
"flows.needsHidden": "{count} more you cannot see",
|
|
300
305
|
"flows.needsEmpty": "This flow reads no documents and calls no tools.",
|
|
301
306
|
"flows.needsFailed": "What this flow needs could not be loaded.",
|
package/src/i18n/es.json
CHANGED
|
@@ -259,6 +259,7 @@
|
|
|
259
259
|
"flows.node.condition": "Condición",
|
|
260
260
|
"flows.node.subflow": "Flujo",
|
|
261
261
|
"flows.node.output": "Fin",
|
|
262
|
+
"flows.nodeInvalid": "Ya no existe — sustituye o quita este paso",
|
|
262
263
|
"flows.instruction": "Instrucción para la IA o la persona",
|
|
263
264
|
"flows.instructionHint": "Las condiciones, comprobaciones y bifurcaciones se pueden describir aquí con palabras. No hace falta un paso propio para cada decisión pequeña.",
|
|
264
265
|
"flows.linkEmpty": "Todavía no se ha elegido nada",
|
|
@@ -293,9 +294,13 @@
|
|
|
293
294
|
"flows.tool": "Servidor MCP",
|
|
294
295
|
"flows.selectTool": "Elegir un servidor",
|
|
295
296
|
"flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
|
|
297
|
+
"flows.saveInvalid": "No se ha guardado: {detail}",
|
|
298
|
+
"flows.saveConflict": "No se ha guardado: otra persona ha cambiado este flujo antes. Carga la versión más reciente e inténtalo de nuevo.",
|
|
299
|
+
"flows.saveFailed": "El flujo no se ha guardado. Inténtalo de nuevo.",
|
|
296
300
|
"flows.needs": "Qué necesita este flujo",
|
|
297
301
|
"flows.needsNodes": "Documentos",
|
|
298
302
|
"flows.needsTools": "Herramientas",
|
|
303
|
+
"flows.needsInvalid": "{count} más que ya no existen — sustituye o quita esos pasos",
|
|
299
304
|
"flows.needsHidden": "{count} más que no puedes ver",
|
|
300
305
|
"flows.needsEmpty": "Este flujo no lee ningún documento ni llama a ninguna herramienta.",
|
|
301
306
|
"flows.needsFailed": "Lo que necesita este flujo no se ha podido cargar.",
|
|
@@ -12,6 +12,7 @@ import { Link2 } from "lucide-react";
|
|
|
12
12
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
14
14
|
import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
|
|
15
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
15
16
|
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
16
17
|
import {
|
|
17
18
|
DocumentLinkProvider,
|
|
@@ -57,6 +58,28 @@ function editorSnapshot(blocks: unknown): string {
|
|
|
57
58
|
return JSON.stringify(blocks);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Which of the two sentences a failed save is told with.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ The refusal's `code`, never the server's prose: `POST /nodes/{id}/versions` words the conflict
|
|
65
|
+
* as `A newer version already exists`, and an English sentence from the API is not a translation
|
|
66
|
+
* source. The code is the contract (`ProblemDetails.code`), and `IntelRequestError` is what carries
|
|
67
|
+
* it this far.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ The two are not a vaguer and a sharper wording of one event. `node.saveError` says "reload and
|
|
70
|
+
* try again", which is right for a network failure and dangerous for a conflict: reloading without
|
|
71
|
+
* knowing that a foreign version now stands loses either the reader's own work or, on the second
|
|
72
|
+
* attempt, the other one. That is why this distinction is worth a branch at all (#451).
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ Local rather than through `resourceErrorKey`: that one answers for the resource menu and the
|
|
75
|
+
* column dialog, and its sentences are the `resource.*` ones. The editor has its own two.
|
|
76
|
+
*/
|
|
77
|
+
function saveErrorKey(error: unknown): string {
|
|
78
|
+
return error instanceof IntelRequestError && error.code === "version_conflict"
|
|
79
|
+
? "node.saveConflict"
|
|
80
|
+
: "node.saveError";
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
export function NodeEditor({
|
|
61
84
|
data,
|
|
62
85
|
document,
|
|
@@ -173,7 +196,7 @@ export function NodeEditor({
|
|
|
173
196
|
<UnsavedChangesGuard dirty={dirty} />
|
|
174
197
|
{save.isError && (
|
|
175
198
|
<p role="alert" className="mx-6 mt-4 text-sm text-destructive">
|
|
176
|
-
{i18n.t(
|
|
199
|
+
{i18n.t(saveErrorKey(save.error))}
|
|
177
200
|
</p>
|
|
178
201
|
)}
|
|
179
202
|
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto py-6">
|
package/src/nodes/nodes.tsx
CHANGED
|
@@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
3
3
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
4
|
import { Download, Paperclip } from "lucide-react";
|
|
5
5
|
import { lazy, Suspense } from "react";
|
|
6
|
+
import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
|
|
6
7
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
8
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
7
9
|
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
8
10
|
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
9
11
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
@@ -41,6 +43,27 @@ export function Nodes() {
|
|
|
41
43
|
// answered "no" (`packages/ui/CLAUDE.md`). While the request is in flight there is no refusal to
|
|
42
44
|
// report, and the loading line below stays.
|
|
43
45
|
const refusal = refusalOf(document.error);
|
|
46
|
+
/**
|
|
47
|
+
* The top level of the tree — the same query the sidebar already holds, by the same key and the
|
|
48
|
+
* same function, so this is a second reader of one cache entry rather than a second request.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ The screen needs a source of its own because the one above cannot answer here: the refusal
|
|
51
|
+
* branch hangs on `["node", selectedId]`, and without a selection that query is not enabled at
|
|
52
|
+
* all. The empty state was therefore reached BEFORE anything had been asked — and it invited the
|
|
53
|
+
* reader to select something, which is exactly what somebody without `intel.nodes.read` cannot
|
|
54
|
+
* do. The sidebar beside it has said so since #430; two surfaces, two answers (#445).
|
|
55
|
+
*/
|
|
56
|
+
const rootLevel = useQuery({
|
|
57
|
+
queryKey: treeLevelKey(null),
|
|
58
|
+
queryFn: () => data.listTreeChildren(null),
|
|
59
|
+
enabled: selectedId === null,
|
|
60
|
+
});
|
|
61
|
+
// ⚠️ Off the ERROR, and here that is half the ticket rather than a detail. A sentence about a
|
|
62
|
+
// missing permission, shown while the answer is still travelling, claims a refusal nobody has
|
|
63
|
+
// spoken yet — the mistake from #350, and `packages/ui/CLAUDE.md` has the rule: a missing answer
|
|
64
|
+
// is a reason to say less, never to refuse more. So the negative sentence hangs off the refusal;
|
|
65
|
+
// until one arrives, what stands is the invitation, which claims nothing about permission.
|
|
66
|
+
const rootRefusal = refusalOf(rootLevel.error);
|
|
44
67
|
// A folder shows its contents as a graph, and so does the root of the tree (#19). A document has
|
|
45
68
|
// nothing to draw — which is why the switch is not offered on one rather than offered and empty.
|
|
46
69
|
const graphable = selectedId === null || selected?.kind === "folder";
|
|
@@ -80,26 +103,17 @@ export function Nodes() {
|
|
|
80
103
|
}
|
|
81
104
|
/>
|
|
82
105
|
) : !selectedId ? (
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
|
|
106
|
+
rootRefusal ? (
|
|
107
|
+
<RefusalNotice refusal={rootRefusal} />
|
|
108
|
+
) : (
|
|
109
|
+
<div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
|
|
110
|
+
{i18n.t("node.select")}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
86
113
|
) : document.isPending ? (
|
|
87
114
|
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
88
115
|
) : refusal ? (
|
|
89
|
-
|
|
90
|
-
share was revoked was left in front of "Loading…" — the worst answer of all, because it
|
|
91
|
-
says the program is still working. They get a sentence and no retry button: the answer
|
|
92
|
-
was final, and a button that changes nothing is an invitation to keep waiting.
|
|
93
|
-
|
|
94
|
-
⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same
|
|
95
|
-
status for "no such node" and "not for you" on purpose — `requireVisible` in
|
|
96
|
-
`packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that
|
|
97
|
-
from the other side. */
|
|
98
|
-
<div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
99
|
-
<p className="max-w-sm text-muted-foreground">
|
|
100
|
-
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
101
|
-
</p>
|
|
102
|
-
</div>
|
|
116
|
+
<RefusalNotice refusal={refusal} />
|
|
103
117
|
) : document.isError || !selected ? (
|
|
104
118
|
<div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
105
119
|
<div className="space-y-3">
|
|
@@ -411,19 +411,30 @@ export function ResourceMenu({
|
|
|
411
411
|
(`flows.problem.<code>` is one string) and repeating it under itself says nothing
|
|
412
412
|
the reader did not just read. What differs is the detail, and every detail is
|
|
413
413
|
still here — which is the promise the comment above makes: somebody with two
|
|
414
|
-
missing tools should not have to ask twice.
|
|
414
|
+
missing tools should not have to ask twice.
|
|
415
|
+
|
|
416
|
+
⚠️ What identifies a detail is its POSITION in `problems`, not its text (#461).
|
|
417
|
+
Two details of one code are routinely the same sentence: `collectRunProblems`
|
|
418
|
+
files one problem per sub-flow NODE and words it with that node's label
|
|
419
|
+
(`packages/api/src/flows/flows.ts`), and calling the same flow twice is the normal
|
|
420
|
+
case. Under `key={detail}` those two collided on one key, and React does not
|
|
421
|
+
answer that with a console warning alone — it drops one of the siblings on the
|
|
422
|
+
next render, so the reader sees ONE entry where there are two causes. */}
|
|
415
423
|
{[
|
|
416
424
|
...validation.problems
|
|
417
|
-
.reduce((byCode, problem) => {
|
|
418
|
-
byCode.set(problem.code, [
|
|
425
|
+
.reduce((byCode, problem, at) => {
|
|
426
|
+
byCode.set(problem.code, [
|
|
427
|
+
...(byCode.get(problem.code) ?? []),
|
|
428
|
+
{ at, detail: problem.detail },
|
|
429
|
+
]);
|
|
419
430
|
return byCode;
|
|
420
|
-
}, new Map<string, string[]>())
|
|
431
|
+
}, new Map<string, { at: number; detail: string }[]>())
|
|
421
432
|
.entries(),
|
|
422
433
|
].map(([code, details]) => (
|
|
423
434
|
<li key={code} className="rounded-md border p-3 text-sm">
|
|
424
435
|
<span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
|
|
425
|
-
{details.map((detail) => (
|
|
426
|
-
<span key={
|
|
436
|
+
{details.map(({ at, detail }) => (
|
|
437
|
+
<span key={at} className="mt-1 block text-xs text-muted-foreground">
|
|
427
438
|
{detail}
|
|
428
439
|
</span>
|
|
429
440
|
))}
|