@anchrd/intel-ui 0.25.0 → 0.28.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 +1 -1
- package/src/app/app-tree/app-tree.tsx +64 -35
- package/src/app/tree-move/tree-move.tsx +92 -21
- package/src/app/view-toggle/view-toggle.tsx +1 -1
- package/src/archive/archive.tsx +4 -4
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +2 -0
- package/src/flow-runs/flow-runs.tsx +5 -5
- package/src/flows/flows.tsx +19 -22
- package/src/folder-contents/folder-contents.tsx +6 -6
- package/src/i18n/de.json +9 -2
- package/src/i18n/en.json +9 -2
- package/src/i18n/es.json +10 -3
- package/src/main.tsx +6 -3
- package/src/node-editor/node-editor.tsx +4 -11
- package/src/node-table/node-table.tsx +3 -2
- package/src/nodes/nodes.tsx +5 -78
- package/src/resource-menu/resource-menu.tsx +85 -31
- package/src/save-button/save-button.tsx +12 -31
- package/src/time/relative-time.tsx +41 -0
- package/src/time/time-context.tsx +7 -1
- package/src/time/time.ts +25 -0
- package/src/title-row/title-row.tsx +124 -4
package/package.json
CHANGED
|
@@ -85,6 +85,16 @@ function levelKey(level: Level): readonly unknown[] {
|
|
|
85
85
|
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// What a drag carries: the row, and the LEVEL it was picked up from.
|
|
89
|
+
//
|
|
90
|
+
// ⚠️ The two are not the same, and that is the whole of #446. Since #429 the root level also shows
|
|
91
|
+
// rows whose own record names a folder this reader may never see — a shared document reaches the
|
|
92
|
+
// root only through the share. Asking `parentOf` again during the move answers with that invisible
|
|
93
|
+
// folder, and then the root offers "move to the top" to a row already at the top, while the level
|
|
94
|
+
// it actually left is never cleaned up. The level is known where the row is rendered; carrying it
|
|
95
|
+
// is cheaper than deriving it wrongly.
|
|
96
|
+
type Carried = { entry: TreeEntry; level: string | null };
|
|
97
|
+
|
|
88
98
|
// What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
|
|
89
99
|
// `text/plain`, so a drop target that means "move this row" and one that means "make a node for
|
|
90
100
|
// this" cannot be confused by the same payload.
|
|
@@ -99,8 +109,8 @@ export function AppTree() {
|
|
|
99
109
|
const [creating, setCreating] = useState<Creating | null>(null);
|
|
100
110
|
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
101
111
|
const uploadInput = useRef<HTMLInputElement>(null);
|
|
102
|
-
const [dragged, setDragged] = useState<
|
|
103
|
-
const draggedRef = useRef<
|
|
112
|
+
const [dragged, setDragged] = useState<Carried | null>(null);
|
|
113
|
+
const draggedRef = useRef<Carried | null>(null);
|
|
104
114
|
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
105
115
|
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
106
116
|
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
@@ -273,14 +283,15 @@ export function AppTree() {
|
|
|
273
283
|
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
274
284
|
// is not a target at all.
|
|
275
285
|
function verdictFor(
|
|
276
|
-
carried:
|
|
286
|
+
carried: Carried | null,
|
|
277
287
|
targetId: string | null,
|
|
278
288
|
targetAncestors: ReadonlySet<string>,
|
|
279
289
|
) {
|
|
280
290
|
if (!carried) return undefined;
|
|
281
291
|
return moveVerdict({
|
|
282
|
-
draggedId: carried.id,
|
|
283
|
-
draggedParentId: parentOf(carried),
|
|
292
|
+
draggedId: carried.entry.id,
|
|
293
|
+
draggedParentId: parentOf(carried.entry),
|
|
294
|
+
draggedLevel: carried.level,
|
|
284
295
|
targetId,
|
|
285
296
|
targetAncestors,
|
|
286
297
|
});
|
|
@@ -313,7 +324,7 @@ export function AppTree() {
|
|
|
313
324
|
setDragged(null);
|
|
314
325
|
draggedRef.current = null;
|
|
315
326
|
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
316
|
-
move.start(carried, target);
|
|
327
|
+
move.start(carried.entry, carried.level, target);
|
|
317
328
|
}
|
|
318
329
|
},
|
|
319
330
|
},
|
|
@@ -436,7 +447,7 @@ export function AppTree() {
|
|
|
436
447
|
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
437
448
|
const drop =
|
|
438
449
|
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
439
|
-
const isDragged = dragged?.id === entry.id;
|
|
450
|
+
const isDragged = dragged?.entry.id === entry.id;
|
|
440
451
|
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
441
452
|
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
442
453
|
// turns a drag into a guess that only the drop answers.
|
|
@@ -465,10 +476,41 @@ export function AppTree() {
|
|
|
465
476
|
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
466
477
|
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
467
478
|
row shows one surface instead of two overlapping ones. */}
|
|
479
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: Native drag is the pointer shortcut;
|
|
480
|
+
the nested button and move dialog remain the keyboard path. */}
|
|
468
481
|
<div
|
|
482
|
+
draggable={!derived}
|
|
483
|
+
onDragStart={(event) => {
|
|
484
|
+
// ⚠️ `draggable={!derived}` is not enough on its own: `dragstart` bubbles, so a drag
|
|
485
|
+
// begun on a descendant of a derived row would still land here — and carry a level
|
|
486
|
+
// (`["tree", <flow-id>]`) that nothing ever wrote. That is the very class of key #446
|
|
487
|
+
// is about, so the handler refuses it rather than trusting the attribute.
|
|
488
|
+
if (derived) return;
|
|
489
|
+
// Native drag initiation on form controls is not interoperable: Safari can leave a
|
|
490
|
+
// draggable button looking grabbable without ever starting the drag. The row owns the
|
|
491
|
+
// gesture while its button remains the keyboard-reachable navigation control (#483).
|
|
492
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
493
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
494
|
+
event.dataTransfer.setData(
|
|
495
|
+
TreeEntryMediaType,
|
|
496
|
+
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
497
|
+
);
|
|
498
|
+
// The level travels with the row, read from the level this row was rendered into —
|
|
499
|
+
// never from its own record, which for a shared row names a folder the reader cannot
|
|
500
|
+
// see (#446).
|
|
501
|
+
const carried: Carried = { entry, level: parent.id };
|
|
502
|
+
draggedRef.current = carried;
|
|
503
|
+
setDragged(carried);
|
|
504
|
+
}}
|
|
505
|
+
onDragEnd={() => {
|
|
506
|
+
draggedRef.current = null;
|
|
507
|
+
setDragged(null);
|
|
508
|
+
setOver(undefined);
|
|
509
|
+
}}
|
|
510
|
+
{...(drop?.props ?? {})}
|
|
469
511
|
className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
470
512
|
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
471
|
-
}`}
|
|
513
|
+
} ${derived ? "" : "cursor-grab active:cursor-grabbing"}`}
|
|
472
514
|
>
|
|
473
515
|
{expandable ? (
|
|
474
516
|
<button
|
|
@@ -508,37 +550,14 @@ export function AppTree() {
|
|
|
508
550
|
// 2px left of one with an arrow, and nothing said why.
|
|
509
551
|
<span aria-hidden="true" className={TREE_GUTTER} />
|
|
510
552
|
)}
|
|
511
|
-
{/*
|
|
512
|
-
|
|
513
|
-
div that assistive technology would have to be told about twice. */}
|
|
553
|
+
{/* Navigation stays a real button. The enclosing row carries the native drag gesture;
|
|
554
|
+
putting `draggable` on this form control is precisely the Safari failure from #483. */}
|
|
514
555
|
<SidebarMenuButton
|
|
515
556
|
isActive={isActive}
|
|
516
|
-
draggable={!derived}
|
|
517
|
-
onDragStart={(event) => {
|
|
518
|
-
// ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
|
|
519
|
-
// that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
|
|
520
|
-
// well, to know which node to make, and resolving an id there would mean a second
|
|
521
|
-
// read of something the drag already knows. `effectAllowed` says both are allowed;
|
|
522
|
-
// each drop target picks the one it means (#75).
|
|
523
|
-
event.dataTransfer.effectAllowed = "copyMove";
|
|
524
|
-
event.dataTransfer.setData("text/plain", entry.id);
|
|
525
|
-
event.dataTransfer.setData(
|
|
526
|
-
TreeEntryMediaType,
|
|
527
|
-
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
528
|
-
);
|
|
529
|
-
draggedRef.current = entry;
|
|
530
|
-
setDragged(entry);
|
|
531
|
-
}}
|
|
532
|
-
onDragEnd={() => {
|
|
533
|
-
draggedRef.current = null;
|
|
534
|
-
setDragged(null);
|
|
535
|
-
setOver(undefined);
|
|
536
|
-
}}
|
|
537
|
-
{...(drop?.props ?? {})}
|
|
538
557
|
data-drop={
|
|
539
558
|
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
540
559
|
}
|
|
541
|
-
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${
|
|
560
|
+
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${isDragged ? "opacity-50" : ""} ${highlight}`}
|
|
542
561
|
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
543
562
|
>
|
|
544
563
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
@@ -588,7 +607,17 @@ export function AppTree() {
|
|
|
588
607
|
<button
|
|
589
608
|
type="button"
|
|
590
609
|
{...rootDrop.props}
|
|
591
|
-
onClick={() =>
|
|
610
|
+
onClick={() => {
|
|
611
|
+
// The strip is drawn for every verdict — greyed out when it would refuse — so the click
|
|
612
|
+
// has to read it too. Without this, a click on the grey strip performs exactly the write
|
|
613
|
+
// the drop path refuses, and `initial` being set skips the picker where the verdict is
|
|
614
|
+
// otherwise enforced.
|
|
615
|
+
if (rootDrop.verdict !== "ok") return;
|
|
616
|
+
move.start(dragged.entry, dragged.level, {
|
|
617
|
+
id: null,
|
|
618
|
+
title: i18n.t("tree.move.root"),
|
|
619
|
+
});
|
|
620
|
+
}}
|
|
592
621
|
data-drop={rootDrop.verdict}
|
|
593
622
|
className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
|
|
594
623
|
rootDrop.verdict === "ok"
|
|
@@ -15,6 +15,12 @@ export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
|
|
|
15
15
|
|
|
16
16
|
// The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
|
|
17
17
|
// answer is read from whichever one this row is, never from a merged shape.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ This is the parent the RECORD names, which since #429 is not always the level the row is shown
|
|
20
|
+
// in: a shared document is filed inside the sharer's folder and reaches the root only through the
|
|
21
|
+
// share. Everything about a move — which level loses the row, which one keeps it, and whether the
|
|
22
|
+
// root is "same place" — is a question about the level on screen, so the level travels with the
|
|
23
|
+
// move instead of being read back out of the record here (#446).
|
|
18
24
|
export function parentOf(entry: TreeEntry): string | null {
|
|
19
25
|
return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
|
|
20
26
|
}
|
|
@@ -25,6 +31,30 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
|
25
31
|
return ["tree", parentId];
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Which cached levels a move has to take the row out of.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Normally one, and the caller names it. When it does not — the title line has no tree level to
|
|
38
|
+
* name — the answer is not a guess but the full set of places the row can be standing in, and that
|
|
39
|
+
* set has exactly two members.
|
|
40
|
+
*
|
|
41
|
+
* It is two rather than "all of them" because of how a level is read (`db.ts`): every level except
|
|
42
|
+
* the root is strictly `parent_id IS ?`, so there the level and the record always agree. Only the
|
|
43
|
+
* root also collects the top of every readable region — a shared row whose own record names the
|
|
44
|
+
* sharer's folder (#429). So a row is either in the level its record names, or in the root.
|
|
45
|
+
*
|
|
46
|
+
* Cleaning up one level too many costs a re-read of a list the reader is looking at anyway.
|
|
47
|
+
* Cleaning up one too few is #446: the row stays where it no longer is.
|
|
48
|
+
*/
|
|
49
|
+
export function levelsToClear(
|
|
50
|
+
entry: TreeEntry,
|
|
51
|
+
level: string | null | undefined,
|
|
52
|
+
): (string | null)[] {
|
|
53
|
+
if (level !== undefined) return [level];
|
|
54
|
+
const recorded = parentOf(entry);
|
|
55
|
+
return recorded === null ? [null] : [recorded, null];
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
29
59
|
// plus on the moved row would still file into the folder it just left.
|
|
30
60
|
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
@@ -45,13 +75,32 @@ function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
|
45
75
|
*/
|
|
46
76
|
export function moveVerdict(input: {
|
|
47
77
|
draggedId: string;
|
|
78
|
+
// ⚠️ Two answers to "where is this row", and since #429 they can differ — which is why both are
|
|
79
|
+
// asked (#446). `draggedParentId` is what the record says and what a move would overwrite;
|
|
80
|
+
// `draggedLevel` is the level the row is shown in.
|
|
81
|
+
//
|
|
82
|
+
// "Same place" has to be true for either, and for different reasons:
|
|
83
|
+
//
|
|
84
|
+
// - The record: dropping a row on the folder its own record already names writes the value that
|
|
85
|
+
// is already there. Nothing happens, and offering it says something will.
|
|
86
|
+
// - The level: a shared row reaches the root only through the share, and its record names the
|
|
87
|
+
// sharer's folder. Dropping it on the root looks like a no-op to the reader — but it would
|
|
88
|
+
// write `parentId = null` and lift the document OUT of the folder it was shared from. That is
|
|
89
|
+
// the opposite of nothing, done by somebody who was told nothing would happen.
|
|
90
|
+
//
|
|
91
|
+
// `draggedLevel` is optional because a surface without a tree has no level to name — the resource
|
|
92
|
+
// menu opens the same dialog from a title line. There the record is the only answer there is.
|
|
48
93
|
draggedParentId: string | null;
|
|
94
|
+
draggedLevel?: string | null | undefined;
|
|
49
95
|
targetId: string | null;
|
|
50
96
|
targetAncestors: ReadonlySet<string>;
|
|
51
97
|
}): MoveVerdict {
|
|
52
98
|
if (input.targetId === input.draggedId) return "self";
|
|
53
99
|
if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
|
|
54
100
|
if (input.targetId === input.draggedParentId) return "same-place";
|
|
101
|
+
if (input.draggedLevel !== undefined && input.targetId === input.draggedLevel) {
|
|
102
|
+
return "same-place";
|
|
103
|
+
}
|
|
55
104
|
return "ok";
|
|
56
105
|
}
|
|
57
106
|
|
|
@@ -98,11 +147,14 @@ export function moveErrorKey(error: unknown): string {
|
|
|
98
147
|
*/
|
|
99
148
|
export function MoveDialog({
|
|
100
149
|
entry,
|
|
150
|
+
level,
|
|
101
151
|
initial,
|
|
102
152
|
close,
|
|
103
153
|
submit,
|
|
104
154
|
}: {
|
|
105
155
|
entry: TreeEntry;
|
|
156
|
+
// `undefined` where no tree level was rendered — the verdict then rests on the record alone.
|
|
157
|
+
level: string | null | undefined;
|
|
106
158
|
initial: MoveDestination | null;
|
|
107
159
|
close(): void;
|
|
108
160
|
submit(destination: MoveDestination): void;
|
|
@@ -114,17 +166,20 @@ export function MoveDialog({
|
|
|
114
166
|
// being moved is never offered, so its subtree can never be entered in the first place.
|
|
115
167
|
const [path, setPath] = useState<MoveDestination[]>([]);
|
|
116
168
|
const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
|
|
117
|
-
const
|
|
169
|
+
const children = useQuery({
|
|
118
170
|
queryKey: ["tree", here.id],
|
|
119
171
|
queryFn: async () => await data.listTreeChildren(here.id),
|
|
120
172
|
enabled: destination === null,
|
|
121
173
|
});
|
|
122
|
-
const folders = (
|
|
174
|
+
const folders = (children.data ?? []).filter(
|
|
123
175
|
(child) => child.kind === "folder" && child.id !== entry.id,
|
|
124
176
|
);
|
|
125
177
|
const verdict = moveVerdict({
|
|
126
178
|
draggedId: entry.id,
|
|
127
179
|
draggedParentId: parentOf(entry),
|
|
180
|
+
// Handed down by the caller, because the dialog is opened from two places and only one of them
|
|
181
|
+
// has a tree level to name (#446).
|
|
182
|
+
draggedLevel: level,
|
|
128
183
|
targetId: here.id,
|
|
129
184
|
targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
|
|
130
185
|
});
|
|
@@ -147,7 +202,7 @@ export function MoveDialog({
|
|
|
147
202
|
<p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
|
|
148
203
|
</div>
|
|
149
204
|
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
|
150
|
-
{
|
|
205
|
+
{children.isPending ? (
|
|
151
206
|
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
152
207
|
{i18n.t("common.loading")}
|
|
153
208
|
</li>
|
|
@@ -236,7 +291,17 @@ export function useTreeMove({
|
|
|
236
291
|
// folder so the row is where the eye follows it. Nobody else has a tree to open.
|
|
237
292
|
onMoved?: ((destination: MoveDestination) => void) | undefined;
|
|
238
293
|
} = {}): {
|
|
239
|
-
|
|
294
|
+
// `level` is the one the row is standing in, and the caller says it or says `undefined` — the tree
|
|
295
|
+
// knows it from the level it rendered into, the title line has no tree at all. Guessing it here
|
|
296
|
+
// with `parentOf` is what filed a moved row into a cache level that does not exist (#446).
|
|
297
|
+
//
|
|
298
|
+
// ⚠️ `undefined` is not "root". It means the level is unknown, and it is handled by cleaning up
|
|
299
|
+
// BOTH levels such a row can stand in — see `levelsToClear`.
|
|
300
|
+
start(
|
|
301
|
+
entry: TreeEntry,
|
|
302
|
+
level: string | null | undefined,
|
|
303
|
+
destination: MoveDestination | null,
|
|
304
|
+
): void;
|
|
240
305
|
error: unknown;
|
|
241
306
|
dialog: React.ReactNode;
|
|
242
307
|
} {
|
|
@@ -244,6 +309,7 @@ export function useTreeMove({
|
|
|
244
309
|
const queryClient = useQueryClient();
|
|
245
310
|
const [moving, setMoving] = useState<{
|
|
246
311
|
entry: TreeEntry;
|
|
312
|
+
level: string | null | undefined;
|
|
247
313
|
initial: MoveDestination | null;
|
|
248
314
|
} | null>(null);
|
|
249
315
|
|
|
@@ -253,6 +319,7 @@ export function useTreeMove({
|
|
|
253
319
|
destination,
|
|
254
320
|
}: {
|
|
255
321
|
entry: TreeEntry;
|
|
322
|
+
level: string | null | undefined;
|
|
256
323
|
destination: MoveDestination;
|
|
257
324
|
}) => {
|
|
258
325
|
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
@@ -273,20 +340,21 @@ export function useTreeMove({
|
|
|
273
340
|
});
|
|
274
341
|
}
|
|
275
342
|
},
|
|
276
|
-
onMutate: async ({ entry, destination }) => {
|
|
277
|
-
const
|
|
343
|
+
onMutate: async ({ entry, level, destination }) => {
|
|
344
|
+
const fromKeys = levelsToClear(entry, level).map(treeLevelKey);
|
|
278
345
|
const toKey = treeLevelKey(destination.id);
|
|
279
|
-
await Promise.all(
|
|
280
|
-
queryClient.cancelQueries({ queryKey
|
|
281
|
-
queryClient.cancelQueries({ queryKey: toKey }),
|
|
282
|
-
]);
|
|
283
|
-
const snapshot = [
|
|
284
|
-
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
285
|
-
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
286
|
-
] as const;
|
|
287
|
-
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
288
|
-
current?.filter((row) => row.id !== entry.id),
|
|
346
|
+
await Promise.all(
|
|
347
|
+
[...fromKeys, toKey].map(async (queryKey) => await queryClient.cancelQueries({ queryKey })),
|
|
289
348
|
);
|
|
349
|
+
const snapshot = [
|
|
350
|
+
...fromKeys.map((key) => [key, queryClient.getQueryData<TreeEntry[]>(key)] as const),
|
|
351
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)] as const,
|
|
352
|
+
];
|
|
353
|
+
for (const key of fromKeys) {
|
|
354
|
+
queryClient.setQueryData<TreeEntry[]>(key, (current) =>
|
|
355
|
+
current?.filter((row) => row.id !== entry.id),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
290
358
|
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
291
359
|
// that were never read.
|
|
292
360
|
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
@@ -300,9 +368,11 @@ export function useTreeMove({
|
|
|
300
368
|
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
301
369
|
},
|
|
302
370
|
onSuccess: (_result, { destination }) => onMoved?.(destination),
|
|
303
|
-
onSettled: async (_result, _error, { entry, destination }) => {
|
|
371
|
+
onSettled: async (_result, _error, { entry, level, destination }) => {
|
|
304
372
|
await Promise.all([
|
|
305
|
-
|
|
373
|
+
...levelsToClear(entry, level).map(
|
|
374
|
+
async (from) => await queryClient.invalidateQueries({ queryKey: treeLevelKey(from) }),
|
|
375
|
+
),
|
|
306
376
|
queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
|
|
307
377
|
queryClient.invalidateQueries({
|
|
308
378
|
queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
|
|
@@ -313,19 +383,20 @@ export function useTreeMove({
|
|
|
313
383
|
});
|
|
314
384
|
|
|
315
385
|
return {
|
|
316
|
-
start(entry, destination) {
|
|
386
|
+
start(entry, level, destination) {
|
|
317
387
|
move.reset();
|
|
318
|
-
setMoving({ entry, initial: destination });
|
|
388
|
+
setMoving({ entry, level, initial: destination });
|
|
319
389
|
},
|
|
320
390
|
error: move.isError ? move.error : null,
|
|
321
391
|
dialog: moving ? (
|
|
322
392
|
<MoveDialog
|
|
323
393
|
entry={moving.entry}
|
|
394
|
+
level={moving.level}
|
|
324
395
|
initial={moving.initial}
|
|
325
396
|
close={() => setMoving(null)}
|
|
326
397
|
submit={(destination) => {
|
|
327
398
|
setMoving(null);
|
|
328
|
-
move.mutate({ entry: moving.entry, destination });
|
|
399
|
+
move.mutate({ entry: moving.entry, level: moving.level, destination });
|
|
329
400
|
}}
|
|
330
401
|
/>
|
|
331
402
|
) : null,
|
|
@@ -64,7 +64,7 @@ export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly I
|
|
|
64
64
|
search: view === "editor" ? rest : { ...rest, view },
|
|
65
65
|
});
|
|
66
66
|
}}
|
|
67
|
-
className="inline-flex size-8 items-center justify-center rounded-md
|
|
67
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
68
68
|
>
|
|
69
69
|
<Icon aria-hidden="true" className="size-4" />
|
|
70
70
|
</TooltipTrigger>
|
package/src/archive/archive.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
|
8
8
|
import { kindIcons } from "@/kind-icon.ts";
|
|
9
9
|
import { Modal } from "@/modal/modal.tsx";
|
|
10
10
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
12
12
|
|
|
13
13
|
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
14
14
|
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
@@ -29,7 +29,6 @@ interface ArchivedEntry {
|
|
|
29
29
|
export function Archive() {
|
|
30
30
|
const { data } = useIntelRouterContext();
|
|
31
31
|
const i18n = useI18n();
|
|
32
|
-
const dateTime = useDateTime();
|
|
33
32
|
const queryClient = useQueryClient();
|
|
34
33
|
|
|
35
34
|
const archived = useQuery({
|
|
@@ -180,8 +179,9 @@ export function Archive() {
|
|
|
180
179
|
<span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
|
|
181
180
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
182
181
|
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
183
|
-
<span className="truncate text-xs text-muted-foreground">
|
|
184
|
-
{i18n.t("archive.
|
|
182
|
+
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
183
|
+
{i18n.t("archive.archived")}
|
|
184
|
+
<RelativeTime value={entry.archivedAt} />
|
|
185
185
|
</span>
|
|
186
186
|
</span>
|
|
187
187
|
{/* Both actions stay permanently visible: the archive is the one place where getting
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
FlowPublishPreview,
|
|
10
10
|
FlowRequirements,
|
|
11
11
|
FlowValidation,
|
|
12
|
+
FlowVersionList,
|
|
12
13
|
ListFlowsInput,
|
|
13
14
|
PreviewFlowPublishInput,
|
|
14
15
|
PublishFlowInput,
|
|
@@ -441,6 +442,9 @@ export function createIntelDataProvider(
|
|
|
441
442
|
body: JSON.stringify(parsed),
|
|
442
443
|
});
|
|
443
444
|
},
|
|
445
|
+
async listFlowVersions(flowId) {
|
|
446
|
+
return await request(`/flows/${encodeURIComponent(flowId)}/versions`, FlowVersionList);
|
|
447
|
+
},
|
|
444
448
|
async previewFlowPublish(input) {
|
|
445
449
|
const parsed = PreviewFlowPublishInput.parse(input);
|
|
446
450
|
return await request(
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
FlowPublishPreview,
|
|
10
10
|
FlowRequirements,
|
|
11
11
|
FlowValidation,
|
|
12
|
+
FlowVersionList,
|
|
12
13
|
ListFlowsInput,
|
|
13
14
|
PreviewFlowPublishInput,
|
|
14
15
|
PublishFlowInput,
|
|
@@ -166,6 +167,7 @@ export interface IntelDataProvider {
|
|
|
166
167
|
// stops resolving as another flow's callee; its versions stay untouched.
|
|
167
168
|
archiveFlow(input: ArchiveFlowInput): Promise<Flow>;
|
|
168
169
|
saveFlow(input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
170
|
+
listFlowVersions(flowId: string): Promise<FlowVersionList>;
|
|
169
171
|
// Which version each sub-flow call will take once published, and which of them publishing
|
|
170
172
|
// freezes. Read before publishing, so the author agrees to the pins rather than discovering them.
|
|
171
173
|
previewFlowPublish(input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
@@ -5,7 +5,8 @@ import { useState } from "react";
|
|
|
5
5
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
7
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
9
|
+
import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
|
|
9
10
|
|
|
10
11
|
// One page is what a person reads before deciding, not what a database can return. The server caps
|
|
11
12
|
// it at fifty; twenty is what fits on a screen without scrolling past the answer.
|
|
@@ -63,7 +64,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
63
64
|
{i18n.t("runs.onlyFailed")}
|
|
64
65
|
</label>
|
|
65
66
|
</div>
|
|
66
|
-
<
|
|
67
|
+
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
67
68
|
{runs.isPending && (
|
|
68
69
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
69
70
|
)}
|
|
@@ -109,14 +110,13 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
109
110
|
{runs.isFetchingNextPage ? i18n.t("common.loading") : i18n.t("runs.more")}
|
|
110
111
|
</button>
|
|
111
112
|
)}
|
|
112
|
-
</
|
|
113
|
+
</TitleRowScrollArea>
|
|
113
114
|
</section>
|
|
114
115
|
);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
|
|
118
119
|
const i18n = useI18n();
|
|
119
|
-
const dateTime = useDateTime();
|
|
120
120
|
const failed = run.status === "failed";
|
|
121
121
|
const Chevron = open ? ChevronDown : ChevronRight;
|
|
122
122
|
return (
|
|
@@ -135,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
|
|
|
135
135
|
>
|
|
136
136
|
{i18n.t(`runs.status.${run.status}`)}
|
|
137
137
|
</span>
|
|
138
|
-
<
|
|
138
|
+
<RelativeTime value={run.startedAt} />
|
|
139
139
|
<span className="text-muted-foreground">
|
|
140
140
|
{run.durationMs === null
|
|
141
141
|
? i18n.t("runs.stillRunning")
|
package/src/flows/flows.tsx
CHANGED
|
@@ -38,7 +38,7 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
38
38
|
import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
|
|
39
39
|
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
40
40
|
import { useResolvedTheme } from "@/theme/theme-context.tsx";
|
|
41
|
-
import { TitleRow } from "@/title-row/title-row.tsx";
|
|
41
|
+
import { TitleRow, TitleRowFrame, TitleRowScrollArea } from "@/title-row/title-row.tsx";
|
|
42
42
|
|
|
43
43
|
type CanvasNode = ReactFlowNode<{ node: FlowNode }, "intel">;
|
|
44
44
|
type CanvasEdge = Edge;
|
|
@@ -455,7 +455,7 @@ function FlowsEditor() {
|
|
|
455
455
|
}
|
|
456
456
|
|
|
457
457
|
return (
|
|
458
|
-
<
|
|
458
|
+
<TitleRowFrame className="flex h-full min-h-0 flex-col">
|
|
459
459
|
{selectedFlowId && document.data ? (
|
|
460
460
|
<FlowTitle
|
|
461
461
|
flow={document.data.flow}
|
|
@@ -634,14 +634,18 @@ function FlowsEditor() {
|
|
|
634
634
|
<Controls />
|
|
635
635
|
</ReactFlow>
|
|
636
636
|
</section>
|
|
637
|
-
<aside className="flex w-80 shrink-0 flex-col
|
|
638
|
-
<
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
637
|
+
<aside className="flex w-80 shrink-0 flex-col border-l bg-card">
|
|
638
|
+
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
|
639
|
+
<NodeInspector
|
|
640
|
+
node={selectedNode}
|
|
641
|
+
update={updateNode}
|
|
642
|
+
tools={tools.data?.items ?? []}
|
|
643
|
+
flows={(callable.data?.items ?? []).filter(
|
|
644
|
+
(entry) => entry.id !== selectedFlowId,
|
|
645
|
+
)}
|
|
646
|
+
/>
|
|
647
|
+
<FlowNeeds flowId={selectedFlowId} />
|
|
648
|
+
</TitleRowScrollArea>
|
|
645
649
|
</aside>
|
|
646
650
|
</>
|
|
647
651
|
)}
|
|
@@ -673,7 +677,7 @@ function FlowsEditor() {
|
|
|
673
677
|
}}
|
|
674
678
|
/>
|
|
675
679
|
) : null}
|
|
676
|
-
</
|
|
680
|
+
</TitleRowFrame>
|
|
677
681
|
);
|
|
678
682
|
}
|
|
679
683
|
|
|
@@ -729,7 +733,7 @@ function FlowTitle({
|
|
|
729
733
|
applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
|
|
730
734
|
that names this flow. A flow is the only level with runs, and therefore the only one with
|
|
731
735
|
three views (#35). */}
|
|
732
|
-
<
|
|
736
|
+
<SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
|
|
733
737
|
<TooltipProvider delayDuration={300}>
|
|
734
738
|
<Tooltip>
|
|
735
739
|
<TooltipTrigger asChild>
|
|
@@ -742,8 +746,8 @@ function FlowTitle({
|
|
|
742
746
|
onClick={onPublish}
|
|
743
747
|
disabled={!publishable}
|
|
744
748
|
aria-label={publishLabel}
|
|
745
|
-
className={`inline-flex size-8 items-center justify-center rounded-md
|
|
746
|
-
publishable && unpublished ? "
|
|
749
|
+
className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
|
|
750
|
+
publishable && unpublished ? "bg-primary/10 text-primary" : ""
|
|
747
751
|
}`}
|
|
748
752
|
>
|
|
749
753
|
<Send aria-hidden="true" className="size-4" />
|
|
@@ -753,14 +757,7 @@ function FlowTitle({
|
|
|
753
757
|
<TooltipContent>{publishLabel}</TooltipContent>
|
|
754
758
|
</Tooltip>
|
|
755
759
|
</TooltipProvider>
|
|
756
|
-
{
|
|
757
|
-
one (#432) — the same distinction the publish button next to it already draws. */}
|
|
758
|
-
<SaveButton
|
|
759
|
-
dirty={dirty && canMutate}
|
|
760
|
-
saving={saving}
|
|
761
|
-
stored={flow.currentVersionId !== null}
|
|
762
|
-
onSave={onSave}
|
|
763
|
-
/>
|
|
760
|
+
<ViewToggle views={["editor", "graph", "runs"]} />
|
|
764
761
|
</TitleRow>
|
|
765
762
|
);
|
|
766
763
|
}
|