@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.25.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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<TreeEntry | null>(null);
103
- const draggedRef = useRef<TreeEntry | null>(null);
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: TreeEntry | null,
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
- {/* The row's own control is what is grabbed and what is dropped on: a real button, so the
512
- gesture rides on something that is already focusable and named rather than on a bare
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 ${derived ? "" : "cursor-grab active:cursor-grabbing"} ${isDragged ? "opacity-50" : ""} ${highlight}`}
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={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
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 level = useQuery({
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 = (level.data ?? []).filter(
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
- {level.isPending ? (
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
- start(entry: TreeEntry, destination: MoveDestination | null): void;
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 fromKey = treeLevelKey(parentOf(entry));
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: fromKey }),
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
- queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
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 border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
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>
@@ -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 { useDateTime } from "@/time/time-context.tsx";
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.archivedAt", { when: dateTime.at(entry.archivedAt) })}
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 { useDateTime } from "@/time/time-context.tsx";
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
- <div className="min-h-0 flex-1 overflow-y-auto p-5">
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
- </div>
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
- <span>{dateTime.at(run.startedAt)}</span>
138
+ <RelativeTime value={run.startedAt} />
139
139
  <span className="text-muted-foreground">
140
140
  {run.durationMs === null
141
141
  ? i18n.t("runs.stillRunning")
@@ -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
- <div className="flex h-full min-h-0 flex-col">
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 overflow-y-auto border-l bg-card">
638
- <NodeInspector
639
- node={selectedNode}
640
- update={updateNode}
641
- tools={tools.data?.items ?? []}
642
- flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
643
- />
644
- <FlowNeeds flowId={selectedFlowId} />
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
- </div>
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
- <ViewToggle views={["editor", "graph", "runs"]} />
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 border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
746
- publishable && unpublished ? "border-primary text-primary" : ""
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
- {/* A draft that was never saved has no version, and the button says so rather than claiming
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
  }