@anchrd/intel-ui 0.25.0 → 0.29.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.
@@ -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;
@@ -285,6 +285,7 @@ function newNode(
285
285
  index: number,
286
286
  firstNodeId?: string,
287
287
  firstFlowId?: string,
288
+ firstServer?: string,
288
289
  ): FlowNode {
289
290
  const common = {
290
291
  id: `${kind}-${crypto.randomUUID()}`,
@@ -315,10 +316,13 @@ function newNode(
315
316
  return {
316
317
  ...common,
317
318
  kind,
319
+ // ⚠️ A server, not an empty string. `ToolServerHandle` has a minimum length, so a draft
320
+ // with `""` cannot be saved at all — the author would build a step and lose it at the next
321
+ // save with a raw field-path error. The tree link one line up takes the same way out.
318
322
  configuration: {
319
- toolName: "select-a-tool",
323
+ server: firstServer ?? "",
324
+ allow: null,
320
325
  fingerprint: null,
321
- arguments: {},
322
326
  },
323
327
  };
324
328
  case "condition":
@@ -361,9 +365,11 @@ function FlowsEditor() {
361
365
  const theme = useResolvedTheme();
362
366
  const queryClient = useQueryClient();
363
367
  const navigate = useNavigate();
364
- const tools = useQuery({
365
- queryKey: ["tools"],
366
- queryFn: () => data.listTools(),
368
+ // The servers a tool step can name (#489). The flat function list the picker used to show is
369
+ // what that ticket set out to remove; which function runs is decided while the flow runs.
370
+ const toolServers = useQuery({
371
+ queryKey: ["tool-servers"],
372
+ queryFn: () => data.listToolServers(),
367
373
  });
368
374
  // A tree link points at a node, so the editor needs candidates. The graph is the authorized
369
375
  // flat list of them; walking the folder tree for a picker would be the N+1 all over again.
@@ -455,7 +461,7 @@ function FlowsEditor() {
455
461
  }
456
462
 
457
463
  return (
458
- <div className="flex h-full min-h-0 flex-col">
464
+ <TitleRowFrame className="flex h-full min-h-0 flex-col">
459
465
  {selectedFlowId && document.data ? (
460
466
  <FlowTitle
461
467
  flow={document.data.flow}
@@ -519,6 +525,7 @@ function FlowsEditor() {
519
525
  nodes.length,
520
526
  nodeGraph.data?.nodes[0]?.id,
521
527
  callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
528
+ toolServers.data?.items[0]?.handle,
522
529
  );
523
530
  setNodes((current) => [
524
531
  ...current,
@@ -634,14 +641,18 @@ function FlowsEditor() {
634
641
  <Controls />
635
642
  </ReactFlow>
636
643
  </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} />
644
+ <aside className="flex w-80 shrink-0 flex-col border-l bg-card">
645
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
646
+ <NodeInspector
647
+ node={selectedNode}
648
+ update={updateNode}
649
+ toolServers={toolServers.data?.items ?? []}
650
+ flows={(callable.data?.items ?? []).filter(
651
+ (entry) => entry.id !== selectedFlowId,
652
+ )}
653
+ />
654
+ <FlowNeeds flowId={selectedFlowId} />
655
+ </TitleRowScrollArea>
645
656
  </aside>
646
657
  </>
647
658
  )}
@@ -673,7 +684,7 @@ function FlowsEditor() {
673
684
  }}
674
685
  />
675
686
  ) : null}
676
- </div>
687
+ </TitleRowFrame>
677
688
  );
678
689
  }
679
690
 
@@ -729,7 +740,7 @@ function FlowTitle({
729
740
  applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
730
741
  that names this flow. A flow is the only level with runs, and therefore the only one with
731
742
  three views (#35). */}
732
- <ViewToggle views={["editor", "graph", "runs"]} />
743
+ <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
733
744
  <TooltipProvider delayDuration={300}>
734
745
  <Tooltip>
735
746
  <TooltipTrigger asChild>
@@ -742,8 +753,8 @@ function FlowTitle({
742
753
  onClick={onPublish}
743
754
  disabled={!publishable}
744
755
  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" : ""
756
+ 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 ${
757
+ publishable && unpublished ? "bg-primary/10 text-primary" : ""
747
758
  }`}
748
759
  >
749
760
  <Send aria-hidden="true" className="size-4" />
@@ -753,14 +764,7 @@ function FlowTitle({
753
764
  <TooltipContent>{publishLabel}</TooltipContent>
754
765
  </Tooltip>
755
766
  </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
- />
767
+ <ViewToggle views={["editor", "graph", "runs"]} />
764
768
  </TitleRow>
765
769
  );
766
770
  }
@@ -855,12 +859,12 @@ function PublishPreview({
855
859
  function NodeInspector({
856
860
  node,
857
861
  update,
858
- tools,
862
+ toolServers,
859
863
  flows,
860
864
  }: {
861
865
  node: CanvasNode | null;
862
866
  update(fn: (node: FlowNode) => FlowNode): void;
863
- tools: Array<{ name: string; title: string | null; fingerprint: string }>;
867
+ toolServers: Array<{ handle: string; name: string; toolCount: number }>;
864
868
  flows: Flow[];
865
869
  }) {
866
870
  const i18n = useI18n();
@@ -952,50 +956,36 @@ function NodeInspector({
952
956
  <div className="mt-4">
953
957
  <label className="block text-sm font-medium">
954
958
  {i18n.t("flows.tool")}
959
+ {/* ⚠️ The step names a SERVER since #489. This picker is the smallest thing that keeps
960
+ the field usable on the new model; the real one — server names from the portal plus
961
+ an optional narrowing to single functions — is #489's second package and is
962
+ deliberately not smuggled in here. */}
955
963
  <select
956
- value={contract.configuration.toolName}
964
+ value={contract.configuration.server}
957
965
  onChange={(event) => {
958
- const toolName = event.target.value;
966
+ const server = event.target.value;
959
967
  update((value) =>
960
968
  value.kind === "tool"
961
969
  ? {
962
970
  ...value,
963
- configuration: {
964
- ...value.configuration,
965
- toolName,
966
- fingerprint:
967
- tools.find((entry) => entry.name === toolName)?.fingerprint ?? null,
968
- },
971
+ // The fingerprint belongs to a surface that publishing freezes, and it is
972
+ // the server's to compute — a value guessed in the browser would be a
973
+ // second answer to a question the API already owns.
974
+ configuration: { ...value.configuration, server, fingerprint: null },
969
975
  }
970
976
  : value,
971
977
  );
972
978
  }}
973
979
  className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
974
980
  >
975
- <option value="select-a-tool">{i18n.t("flows.selectTool")}</option>
976
- {tools.map((entry) => (
977
- <option key={entry.name} value={entry.name}>
978
- {entry.title ?? entry.name}
981
+ <option value="">{i18n.t("flows.selectTool")}</option>
982
+ {toolServers.map((entry) => (
983
+ <option key={entry.handle} value={entry.handle}>
984
+ {entry.name}
979
985
  </option>
980
986
  ))}
981
987
  </select>
982
988
  </label>
983
- <ToolArgumentsEditor
984
- key={contract.id}
985
- label={i18n.t("tools.arguments")}
986
- invalidLabel={i18n.t("tools.invalidJson")}
987
- initialValue={contract.configuration.arguments}
988
- onValid={(argumentsValue) => {
989
- update((value) =>
990
- value.kind === "tool"
991
- ? {
992
- ...value,
993
- configuration: { ...value.configuration, arguments: argumentsValue },
994
- }
995
- : value,
996
- );
997
- }}
998
- />
999
989
  </div>
1000
990
  )}
1001
991
  </div>
@@ -1018,7 +1008,7 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1018
1008
  needs.data !== undefined &&
1019
1009
  needs.data.nodes.length === 0 &&
1020
1010
  needs.data.hiddenNodes === 0 &&
1021
- needs.data.tools.length === 0;
1011
+ needs.data.servers.length === 0;
1022
1012
  return (
1023
1013
  <section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
1024
1014
  <h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
@@ -1055,13 +1045,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1055
1045
  </ul>
1056
1046
  </>
1057
1047
  )}
1058
- {needs.data.tools.length > 0 && (
1048
+ {needs.data.servers.length > 0 && (
1059
1049
  <>
1060
1050
  <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1061
1051
  {i18n.t("flows.needsTools")}
1062
1052
  </h3>
1063
1053
  <ul className="mt-2 space-y-1 text-sm">
1064
- {needs.data.tools.map((name) => (
1054
+ {needs.data.servers.map((name) => (
1065
1055
  <li key={name} className="flex items-center gap-2">
1066
1056
  <Wrench aria-hidden="true" className="size-3.5 shrink-0" />
1067
1057
  <span className="min-w-0 truncate">{name}</span>
@@ -1070,7 +1060,6 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1070
1060
  </ul>
1071
1061
  </>
1072
1062
  )}
1073
- <p className="mt-4 text-xs text-muted-foreground">{i18n.t("flows.needsHint")}</p>
1074
1063
  </>
1075
1064
  )}
1076
1065
  </section>
@@ -1121,47 +1110,6 @@ function SubflowVersionField({
1121
1110
  );
1122
1111
  }
1123
1112
 
1124
- function ToolArgumentsEditor({
1125
- label,
1126
- invalidLabel,
1127
- initialValue,
1128
- onValid,
1129
- }: {
1130
- label: string;
1131
- invalidLabel: string;
1132
- initialValue: Record<string, unknown>;
1133
- onValid(value: Record<string, unknown>): void;
1134
- }) {
1135
- const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2));
1136
- const [invalid, setInvalid] = useState(false);
1137
- return (
1138
- <label className="mt-4 block text-sm font-medium">
1139
- {label}
1140
- <textarea
1141
- rows={6}
1142
- value={text}
1143
- onChange={(event) => {
1144
- const next = event.target.value;
1145
- setText(next);
1146
- try {
1147
- const parsed: unknown = JSON.parse(next);
1148
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1149
- setInvalid(true);
1150
- return;
1151
- }
1152
- setInvalid(false);
1153
- onValid(parsed as Record<string, unknown>);
1154
- } catch {
1155
- setInvalid(true);
1156
- }
1157
- }}
1158
- className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
1159
- />
1160
- {invalid && <span className="mt-1 block text-xs text-destructive">{invalidLabel}</span>}
1161
- </label>
1162
- );
1163
- }
1164
-
1165
1113
  function Field({
1166
1114
  label,
1167
1115
  value,
@@ -15,7 +15,8 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
15
15
  import { kindIcons } from "@/kind-icon.ts";
16
16
  import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
17
17
  import { useIntelRouterContext } from "@/router/router-context.ts";
18
- import { useDateTime } from "@/time/time-context.tsx";
18
+ import { RelativeTime } from "@/time/relative-time.tsx";
19
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
19
20
 
20
21
  // The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
21
22
  // second source of truth — a rename works on the whole record, not on a summary of it.
@@ -33,8 +34,8 @@ function targetOf(entry: TreeEntry): ResourceTarget {
33
34
  * fetch separately show it differently the moment one of them is stale, and that is the kind of
34
35
  * difference a customer finds before anybody here does. The other half of the bargain is free —
35
36
  * every invalidation the tree already does after a create, a move or an archive lands here too, and
36
- * that now includes the ones this screen's own menu makes: `ResourceMenu` invalidates
37
- * `["tree", parentId]`, which is this level.
37
+ * that now includes the ones this screen's own menu makes: since #519 they all refresh every tree
38
+ * level (`allTreeLevelsKey`), and this level is one of them.
38
39
  *
39
40
  * ⚠️ There is no "Kind" column since #101. The icon says what a row is, in the same picture the
40
41
  * sidebar draws two centimetres to its left, and `sr-only` says it in words for everyone who does
@@ -43,7 +44,6 @@ function targetOf(entry: TreeEntry): ResourceTarget {
43
44
  export function FolderContents({ folderId }: { folderId: string }) {
44
45
  const { data } = useIntelRouterContext();
45
46
  const i18n = useI18n();
46
- const dateTime = useDateTime();
47
47
  const navigate = useNavigate();
48
48
  const level = useQuery({
49
49
  queryKey: treeLevelKey(folderId),
@@ -74,7 +74,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
74
74
  }
75
75
 
76
76
  return (
77
- <div className="min-h-0 flex-1 overflow-y-auto p-6">
77
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto p-6">
78
78
  <div className="overflow-hidden rounded-lg border bg-card">
79
79
  <Table>
80
80
  <TableHeader className="[&_tr]:bg-muted/40">
@@ -119,7 +119,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
119
119
  column, instead of five loose glyphs at five different widths. */}
120
120
  <span
121
121
  aria-hidden="true"
122
- className="grid size-8 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
122
+ className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/50 text-muted-foreground transition-colors group-hover/row:bg-accent group-hover/row:text-accent-foreground"
123
123
  >
124
124
  <Icon className="size-4" />
125
125
  </span>
@@ -132,7 +132,7 @@ export function FolderContents({ folderId }: { folderId: string }) {
132
132
  </button>
133
133
  </TableCell>
134
134
  <TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
135
- {dateTime.on(changed)}
135
+ <RelativeTime value={changed} />
136
136
  </TableCell>
137
137
  <TableCell className="px-4">
138
138
  <ResourceAccessSummary
@@ -154,6 +154,6 @@ export function FolderContents({ folderId }: { folderId: string }) {
154
154
  </TableBody>
155
155
  </Table>
156
156
  </div>
157
- </div>
157
+ </TitleRowScrollArea>
158
158
  );
159
159
  }
package/src/i18n/de.json CHANGED
@@ -71,10 +71,15 @@
71
71
  "tree.move.failed.other": "Es konnte nicht verschoben werden. Prüfe deinen Zugriff und versuche es erneut.",
72
72
  "resource.menu": "Weitere Aktionen für {title}",
73
73
  "resource.rename": "Umbenennen",
74
+ "resource.move": "Verschieben",
74
75
  "resource.renameTitle": "{title} umbenennen",
75
76
  "resource.archive": "Archivieren",
76
77
  "resource.export": "Export",
77
78
  "resource.import": "Import",
79
+ "resource.downloadCsv": "CSV herunterladen",
80
+ "resource.validate": "Prüfen",
81
+ "resource.links": "Verweise",
82
+ "resource.share": "Freigeben",
78
83
  "tree.new.importZip": "ZIP-Bündel importieren",
79
84
  "tree.new.importFolder": "Ordner importieren",
80
85
  "tree.importZipInto": "ZIP-Bündel in {where} importieren",
@@ -103,7 +108,7 @@
103
108
  "archive.purge.confirm": "Endgültig löschen",
104
109
  "archive.purge.cancel": "Abbrechen",
105
110
  "archive.purgeFailed": "Es konnte nicht vollständig gelöscht werden. Lade neu und versuche es erneut.",
106
- "archive.archivedAt": "Archiviert {when}",
111
+ "archive.archived": "Archiviert",
107
112
  "resource.conflict": "Nicht geändert: Jemand anderes hat diesen Eintrag zuerst geändert. Lade ihn neu und versuche es erneut.",
108
113
  "resource.forbidden": "Nicht geändert: Du darfst diesen Eintrag nicht ändern.",
109
114
  "resource.failed": "Die Änderung wurde nicht gespeichert. Lade die neueste Fassung und versuche es erneut.",
@@ -210,8 +215,6 @@
210
215
  "tools.toolCount": "{count} Werkzeuge",
211
216
  "tools.toolCountOne": "1 Werkzeug",
212
217
  "tools.liveNote": "Diese Liste ist eine Live-Abfrage mit deinem eigenen Portal-Zugriff. Intel speichert weder die Werkzeuge noch, wer sie nutzen darf; beides entscheidet das Portal.",
213
- "tools.arguments": "Argumente",
214
- "tools.invalidJson": "Die Argumente müssen ein JSON-Objekt in geschweiften Klammern sein; eine Liste oder ein einzelner Wert funktioniert nicht.",
215
218
  "flows.select": "Wähle einen Flow oder lege einen an, um den visuellen Editor zu öffnen.",
216
219
  "flows.inspect": "Wähle einen Schritt, um seine Konfiguration zu bearbeiten.",
217
220
  "flows.publish": "Veröffentlichen",
@@ -276,7 +279,6 @@
276
279
  "flows.selectTool": "Wähle ein gefundenes Werkzeug",
277
280
  "flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
278
281
  "flows.needs": "Was dieser Flow braucht",
279
- "flows.needsHint": "Aus dem Graphen gelesen. Ob eine bestimmte Person davon etwas erreicht, entscheidet sich, wenn sie ihn ausführt.",
280
282
  "flows.needsNodes": "Dokumente",
281
283
  "flows.needsTools": "Werkzeuge",
282
284
  "flows.needsHidden": "{count} weitere, die du nicht sehen kannst",
@@ -347,5 +349,7 @@
347
349
  "common.close": "Schließen",
348
350
  "common.unavailable": "Intel ist derzeit nicht verfügbar.",
349
351
  "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
350
- "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen."
352
+ "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
353
+ "title.descriptionMore": "Mehr",
354
+ "title.descriptionLess": "Weniger"
351
355
  }
package/src/i18n/en.json CHANGED
@@ -71,10 +71,15 @@
71
71
  "tree.move.failed.other": "It could not be moved. Check your access and try again.",
72
72
  "resource.menu": "More actions for {title}",
73
73
  "resource.rename": "Rename",
74
+ "resource.move": "Move",
74
75
  "resource.renameTitle": "Rename {title}",
75
76
  "resource.archive": "Archive",
76
77
  "resource.export": "Export",
77
78
  "resource.import": "Import",
79
+ "resource.downloadCsv": "Download CSV",
80
+ "resource.validate": "Check",
81
+ "resource.links": "Links",
82
+ "resource.share": "Share",
78
83
  "tree.new.importZip": "Import zip bundle",
79
84
  "tree.new.importFolder": "Import folder",
80
85
  "tree.importZipInto": "Import a zip bundle into {where}",
@@ -103,7 +108,7 @@
103
108
  "archive.purge.confirm": "Delete for good",
104
109
  "archive.purge.cancel": "Cancel",
105
110
  "archive.purgeFailed": "It could not be deleted completely. Reload and try again.",
106
- "archive.archivedAt": "Archived {when}",
111
+ "archive.archived": "Archived",
107
112
  "resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
108
113
  "resource.forbidden": "It was not changed: you may not change this entry.",
109
114
  "resource.failed": "The change was not saved. Reload the latest version and try again.",
@@ -210,8 +215,6 @@
210
215
  "tools.toolCount": "{count} tools",
211
216
  "tools.toolCountOne": "1 tool",
212
217
  "tools.liveNote": "This list is a live query with your own portal access. Intel stores neither the tools nor who may use them; the portal decides both.",
213
- "tools.arguments": "Arguments",
214
- "tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
215
218
  "flows.select": "Select a flow or create one to open the visual editor.",
216
219
  "flows.inspect": "Select a node to edit its configuration.",
217
220
  "flows.publish": "Publish",
@@ -276,7 +279,6 @@
276
279
  "flows.selectTool": "Select a discovered tool",
277
280
  "flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
278
281
  "flows.needs": "What this flow needs",
279
- "flows.needsHint": "Read out of the graph. Whether a given person may reach any of it is decided when they run it.",
280
282
  "flows.needsNodes": "Documents",
281
283
  "flows.needsTools": "Tools",
282
284
  "flows.needsHidden": "{count} more you cannot see",
@@ -347,5 +349,7 @@
347
349
  "common.close": "Close",
348
350
  "common.unavailable": "Intel is currently unavailable.",
349
351
  "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
350
- "common.noPermission": "You do not have permission to see this."
352
+ "common.noPermission": "You do not have permission to see this.",
353
+ "title.descriptionMore": "More",
354
+ "title.descriptionLess": "Less"
351
355
  }
package/src/i18n/es.json CHANGED
@@ -70,11 +70,16 @@
70
70
  "tree.move.failed.gone": "No se ha movido: esa carpeta ya no existe.",
71
71
  "tree.move.failed.other": "No se ha podido mover. Comprueba tu acceso e inténtalo de nuevo.",
72
72
  "resource.menu": "Más acciones para {title}",
73
- "resource.rename": "Cambiar el nombre",
73
+ "resource.rename": "Renombrar",
74
+ "resource.move": "Mover",
74
75
  "resource.renameTitle": "Cambiar el nombre de {title}",
75
76
  "resource.archive": "Archivar",
76
77
  "resource.export": "Exportar",
77
78
  "resource.import": "Importar",
79
+ "resource.downloadCsv": "Descargar CSV",
80
+ "resource.validate": "Comprobar",
81
+ "resource.links": "Enlaces",
82
+ "resource.share": "Compartir",
78
83
  "tree.new.importZip": "Importar un paquete zip",
79
84
  "tree.new.importFolder": "Importar una carpeta",
80
85
  "tree.importZipInto": "Importar un paquete zip en {where}",
@@ -103,7 +108,7 @@
103
108
  "archive.purge.confirm": "Eliminar definitivamente",
104
109
  "archive.purge.cancel": "Cancelar",
105
110
  "archive.purgeFailed": "No se pudo eliminar por completo. Recarga e inténtalo de nuevo.",
106
- "archive.archivedAt": "Archivado {when}",
111
+ "archive.archived": "Archivado",
107
112
  "resource.conflict": "No se ha cambiado: otra persona ha cambiado esta entrada antes. Recárgala e inténtalo de nuevo.",
108
113
  "resource.forbidden": "No se ha cambiado: no puedes cambiar esta entrada.",
109
114
  "resource.failed": "El cambio no se ha guardado. Carga la versión más reciente e inténtalo de nuevo.",
@@ -210,8 +215,6 @@
210
215
  "tools.toolCount": "{count} herramientas",
211
216
  "tools.toolCountOne": "1 herramienta",
212
217
  "tools.liveNote": "Esta lista es una consulta en vivo con tu propio acceso al portal. Intel no guarda ni las herramientas ni quién puede usarlas; ambas cosas las decide el portal.",
213
- "tools.arguments": "Argumentos",
214
- "tools.invalidJson": "Los argumentos tienen que ser un objeto JSON entre llaves; una lista o un valor suelto no sirven.",
215
218
  "flows.select": "Elige un flujo o crea uno para abrir el editor visual.",
216
219
  "flows.inspect": "Elige un paso para editar su configuración.",
217
220
  "flows.publish": "Publicar",
@@ -276,7 +279,6 @@
276
279
  "flows.selectTool": "Elige una herramienta descubierta",
277
280
  "flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
278
281
  "flows.needs": "Qué necesita este flujo",
279
- "flows.needsHint": "Leído del grafo. Si una persona concreta llega a algo de esto se decide cuando lo ejecuta.",
280
282
  "flows.needsNodes": "Documentos",
281
283
  "flows.needsTools": "Herramientas",
282
284
  "flows.needsHidden": "{count} más que no puedes ver",
@@ -347,5 +349,7 @@
347
349
  "common.close": "Cerrar",
348
350
  "common.unavailable": "Intel no está disponible en este momento.",
349
351
  "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
350
- "common.noPermission": "No tienes permiso para ver esto."
352
+ "common.noPermission": "No tienes permiso para ver esto.",
353
+ "title.descriptionMore": "Más",
354
+ "title.descriptionLess": "Menos"
351
355
  }
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
2
  import { zipSync } from "fflate";
3
3
  import { useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
- import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
5
+ import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
6
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
8
 
@@ -93,7 +93,10 @@ export function useBundleImport(options: {
93
93
  onSuccess: async (imported) => {
94
94
  await options.onImported?.(imported.parentId);
95
95
  await Promise.all([
96
- queryClient.invalidateQueries({ queryKey: treeLevelKey(imported.parentId) }),
96
+ // ⚠️ Every level, not the one imported into: the target folder's own row carries whether it
97
+ // can be opened, and it stands one level higher (#519). An import into an empty folder was
98
+ // otherwise unreachable in the tree until the page was reloaded.
99
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
97
100
  queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
98
101
  // The graph view of that same level draws exactly this list (#19).
99
102
  queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
package/src/main.tsx CHANGED
@@ -13,6 +13,7 @@ import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
13
13
  import { createIntelRouter } from "@/router/router.tsx";
14
14
  import { applyTheme, readThemeChoice, SystemThemeQuery, themeFor } from "@/theme/theme.ts";
15
15
  import { ThemeProvider } from "@/theme/theme-context.tsx";
16
+ import { RelativeClockProvider } from "@/time/relative-time.tsx";
16
17
  import { TimeZoneProvider } from "@/time/time-context.tsx";
17
18
  import "./styles.css";
18
19
  import "./theme/custom.css";
@@ -55,9 +56,11 @@ mounted.render(
55
56
  {/* ⚠️ INSIDE `I18nProvider`: the formatter needs both — the language says HOW a time is
56
57
  written, the zone WHICH time it is (#467). */}
57
58
  <TimeZoneProvider store={window.localStorage}>
58
- <QueryClientProvider client={queryClient}>
59
- <AppRoot data={data} router={router} refusal={refusal} />
60
- </QueryClientProvider>
59
+ <RelativeClockProvider>
60
+ <QueryClientProvider client={queryClient}>
61
+ <AppRoot data={data} router={router} refusal={refusal} />
62
+ </QueryClientProvider>
63
+ </RelativeClockProvider>
61
64
  </TimeZoneProvider>
62
65
  </ThemeProvider>
63
66
  </I18nProvider>
@@ -23,6 +23,7 @@ import {
23
23
  import type { I18n } from "@/i18n/i18n.types.ts";
24
24
  import { Modal } from "@/modal/modal.tsx";
25
25
  import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
26
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
26
27
 
27
28
  function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undefined {
28
29
  if (!content) return undefined;
@@ -167,15 +168,7 @@ export function NodeEditor({
167
168
  because this is where `dirty` lives — moving the state up to the screen would mean the
168
169
  screen tracking every keystroke in an editor it does not own. */}
169
170
  <ActionSlot name="title-actions">
170
- {/* ⚠️ `stored` comes from the document the screen holds, not from a flag this component
171
- sets on success: it turns true when the answer that carried a version has reached the
172
- query cache, and never a moment earlier (#432). */}
173
- <SaveButton
174
- dirty={dirty}
175
- saving={save.isPending}
176
- stored={document.version !== null}
177
- onSave={() => save.mutate()}
178
- />
171
+ <SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
179
172
  </ActionSlot>
180
173
  <UnsavedChangesGuard dirty={dirty} />
181
174
  {save.isError && (
@@ -183,7 +176,7 @@ export function NodeEditor({
183
176
  {i18n.t("node.saveError")}
184
177
  </p>
185
178
  )}
186
- <div className="min-h-0 flex-1 overflow-y-auto py-6">
179
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto py-6">
187
180
  <DocumentLinkProvider
188
181
  value={{
189
182
  titles: new Map((resolved.data?.items ?? []).map((item) => [item.nodeId, item.title])),
@@ -219,7 +212,7 @@ export function NodeEditor({
219
212
  />
220
213
  </BlockNoteView>
221
214
  </DocumentLinkProvider>
222
- </div>
215
+ </TitleRowScrollArea>
223
216
  {picking ? (
224
217
  <DocumentPicker
225
218
  data={data}
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
3
3
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
4
4
  import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
+ import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
6
7
 
7
8
  /**
8
9
  * A table as a grid (#40).
@@ -59,7 +60,7 @@ export function NodeTablePanel({ node }: { node: Node }) {
59
60
  {i18n.t("node.table.undefined")}
60
61
  </p>
61
62
  ) : (
62
- <div className="min-h-0 flex-1 overflow-auto">
63
+ <TitleRowScrollArea className="min-h-0 flex-1 overflow-auto">
63
64
  <table className="w-full border-collapse text-sm">
64
65
  <caption className="sr-only">{node.title}</caption>
65
66
  <thead className="sticky top-0 bg-card">
@@ -97,7 +98,7 @@ export function NodeTablePanel({ node }: { node: Node }) {
97
98
  {table.data.rows.length === 0 ? (
98
99
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("node.table.empty")}</p>
99
100
  ) : null}
100
- </div>
101
+ </TitleRowScrollArea>
101
102
  )}
102
103
  </div>
103
104
  );