@anchrd/intel-ui 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n/es.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Mover",
75
75
  "resource.renameTitle": "Cambiar el nombre de {title}",
76
76
  "resource.archive": "Archivar",
77
+ "resource.columns": "Columnas",
77
78
  "resource.export": "Exportar",
78
79
  "resource.import": "Importar",
79
80
  "resource.validate": "Comprobar",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} filas, {columns} columnas",
118
119
  "node.table.empty": "Todavía no hay filas. Las filas las añaden los flujos.",
119
120
  "node.table.undefined": "Esta tabla todavía no tiene columnas.",
121
+ "node.table.columnsTitle": "Columnas de {title}",
122
+ "node.table.columnName": "Nombre de la columna «{column}»",
123
+ "node.table.columnNewName": "Nombre de la nueva columna",
124
+ "node.table.columnAdd": "Añadir columna",
125
+ "node.table.columnDrop": "Eliminar la columna «{column}»",
126
+ "node.table.columnDropNew": "Eliminar la nueva columna",
127
+ "node.table.columnsDistinct": "Dos columnas no pueden llevar el mismo nombre.",
128
+ "node.table.columnsNamed": "Cada columna necesita un nombre.",
129
+ "node.table.columnsAtLeastOne": "Una tabla necesita al menos una columna.",
130
+ "node.table.columnsTooLong": "Un nombre de columna puede tener como máximo 120 caracteres.",
131
+ "node.table.columnsTooMany": "Una tabla puede tener como máximo 64 columnas.",
132
+ "node.table.columnsDropWarning.one": "Esta columna se elimina con todas sus celdas: {columns}",
133
+ "node.table.columnsDropWarning.many": "Estas columnas se eliminan con todas sus celdas: {columns}",
134
+ "node.table.columnsDropRows.none": "Ninguna fila tiene contenido ahí, así que no se pierde nada con ellas.",
135
+ "node.table.columnsDropRows.one": "Una fila tiene contenido ahí. Seguirá siendo legible en el historial de versiones y en ningún otro sitio.",
136
+ "node.table.columnsDropRows.many": "{count} filas tienen contenido ahí. Seguirá siendo legible en el historial de versiones y en ningún otro sitio.",
137
+ "node.table.columnsDropConfirm": "Eliminar y guardar",
120
138
  "node.select": "Elige un documento o una carpeta para trabajar con ello.",
121
139
  "node.folderEmpty": "En esta carpeta todavía no hay nada. Crea algo con el más de la barra lateral.",
122
140
  "node.folderFailed": "Esta carpeta no se ha podido leer.",
@@ -241,6 +259,7 @@
241
259
  "flows.node.condition": "Condición",
242
260
  "flows.node.subflow": "Flujo",
243
261
  "flows.node.output": "Fin",
262
+ "flows.nodeInvalid": "Ya no existe — sustituye o quita este paso",
244
263
  "flows.instruction": "Instrucción para la IA o la persona",
245
264
  "flows.instructionHint": "Las condiciones, comprobaciones y bifurcaciones se pueden describir aquí con palabras. No hace falta un paso propio para cada decisión pequeña.",
246
265
  "flows.linkEmpty": "Todavía no se ha elegido nada",
@@ -275,9 +294,13 @@
275
294
  "flows.tool": "Servidor MCP",
276
295
  "flows.selectTool": "Elegir un servidor",
277
296
  "flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
297
+ "flows.saveInvalid": "No se ha guardado: {detail}",
298
+ "flows.saveConflict": "No se ha guardado: otra persona ha cambiado este flujo antes. Carga la versión más reciente e inténtalo de nuevo.",
299
+ "flows.saveFailed": "El flujo no se ha guardado. Inténtalo de nuevo.",
278
300
  "flows.needs": "Qué necesita este flujo",
279
301
  "flows.needsNodes": "Documentos",
280
302
  "flows.needsTools": "Herramientas",
303
+ "flows.needsInvalid": "{count} más que ya no existen — sustituye o quita esos pasos",
281
304
  "flows.needsHidden": "{count} más que no puedes ver",
282
305
  "flows.needsEmpty": "Este flujo no lee ningún documento ni llama a ninguna herramienta.",
283
306
  "flows.needsFailed": "Lo que necesita este flujo no se ha podido cargar.",
@@ -344,6 +367,7 @@
344
367
  "common.loading": "Cargando…",
345
368
  "common.retry": "Intentarlo de nuevo",
346
369
  "common.close": "Cerrar",
370
+ "common.cancel": "Cancelar",
347
371
  "common.unavailable": "Intel no está disponible en este momento.",
348
372
  "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
349
373
  "common.noPermission": "No tienes permiso para ver esto.",
@@ -12,6 +12,7 @@ import { Link2 } from "lucide-react";
12
12
  import { useEffect, useMemo, useRef, useState } from "react";
13
13
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
14
14
  import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
15
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
15
16
  import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
16
17
  import {
17
18
  DocumentLinkProvider,
@@ -57,6 +58,28 @@ function editorSnapshot(blocks: unknown): string {
57
58
  return JSON.stringify(blocks);
58
59
  }
59
60
 
61
+ /**
62
+ * Which of the two sentences a failed save is told with.
63
+ *
64
+ * ⚠️ The refusal's `code`, never the server's prose: `POST /nodes/{id}/versions` words the conflict
65
+ * as `A newer version already exists`, and an English sentence from the API is not a translation
66
+ * source. The code is the contract (`ProblemDetails.code`), and `IntelRequestError` is what carries
67
+ * it this far.
68
+ *
69
+ * ⚠️ The two are not a vaguer and a sharper wording of one event. `node.saveError` says "reload and
70
+ * try again", which is right for a network failure and dangerous for a conflict: reloading without
71
+ * knowing that a foreign version now stands loses either the reader's own work or, on the second
72
+ * attempt, the other one. That is why this distinction is worth a branch at all (#451).
73
+ *
74
+ * ⚠️ Local rather than through `resourceErrorKey`: that one answers for the resource menu and the
75
+ * column dialog, and its sentences are the `resource.*` ones. The editor has its own two.
76
+ */
77
+ function saveErrorKey(error: unknown): string {
78
+ return error instanceof IntelRequestError && error.code === "version_conflict"
79
+ ? "node.saveConflict"
80
+ : "node.saveError";
81
+ }
82
+
60
83
  export function NodeEditor({
61
84
  data,
62
85
  document,
@@ -173,7 +196,7 @@ export function NodeEditor({
173
196
  <UnsavedChangesGuard dirty={dirty} />
174
197
  {save.isError && (
175
198
  <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
176
- {i18n.t("node.saveError")}
199
+ {i18n.t(saveErrorKey(save.error))}
177
200
  </p>
178
201
  )}
179
202
  <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto py-6">
@@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
4
  import { Download, Paperclip } from "lucide-react";
5
5
  import { lazy, Suspense } from "react";
6
+ import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
6
7
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
+ import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
7
9
  import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
8
10
  import { FolderContents } from "@/folder-contents/folder-contents.tsx";
9
11
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
@@ -41,6 +43,27 @@ export function Nodes() {
41
43
  // answered "no" (`packages/ui/CLAUDE.md`). While the request is in flight there is no refusal to
42
44
  // report, and the loading line below stays.
43
45
  const refusal = refusalOf(document.error);
46
+ /**
47
+ * The top level of the tree — the same query the sidebar already holds, by the same key and the
48
+ * same function, so this is a second reader of one cache entry rather than a second request.
49
+ *
50
+ * ⚠️ The screen needs a source of its own because the one above cannot answer here: the refusal
51
+ * branch hangs on `["node", selectedId]`, and without a selection that query is not enabled at
52
+ * all. The empty state was therefore reached BEFORE anything had been asked — and it invited the
53
+ * reader to select something, which is exactly what somebody without `intel.nodes.read` cannot
54
+ * do. The sidebar beside it has said so since #430; two surfaces, two answers (#445).
55
+ */
56
+ const rootLevel = useQuery({
57
+ queryKey: treeLevelKey(null),
58
+ queryFn: () => data.listTreeChildren(null),
59
+ enabled: selectedId === null,
60
+ });
61
+ // ⚠️ Off the ERROR, and here that is half the ticket rather than a detail. A sentence about a
62
+ // missing permission, shown while the answer is still travelling, claims a refusal nobody has
63
+ // spoken yet — the mistake from #350, and `packages/ui/CLAUDE.md` has the rule: a missing answer
64
+ // is a reason to say less, never to refuse more. So the negative sentence hangs off the refusal;
65
+ // until one arrives, what stands is the invitation, which claims nothing about permission.
66
+ const rootRefusal = refusalOf(rootLevel.error);
44
67
  // A folder shows its contents as a graph, and so does the root of the tree (#19). A document has
45
68
  // nothing to draw — which is why the switch is not offered on one rather than offered and empty.
46
69
  const graphable = selectedId === null || selected?.kind === "folder";
@@ -80,26 +103,17 @@ export function Nodes() {
80
103
  }
81
104
  />
82
105
  ) : !selectedId ? (
83
- <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
84
- {i18n.t("node.select")}
85
- </div>
106
+ rootRefusal ? (
107
+ <RefusalNotice refusal={rootRefusal} />
108
+ ) : (
109
+ <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
110
+ {i18n.t("node.select")}
111
+ </div>
112
+ )
86
113
  ) : document.isPending ? (
87
114
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
88
115
  ) : refusal ? (
89
- /* ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose
90
- share was revoked was left in front of "Loading…" — the worst answer of all, because it
91
- says the program is still working. They get a sentence and no retry button: the answer
92
- was final, and a button that changes nothing is an invitation to keep waiting.
93
-
94
- ⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same
95
- status for "no such node" and "not for you" on purpose — `requireVisible` in
96
- `packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that
97
- from the other side. */
98
- <div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
99
- <p className="max-w-sm text-muted-foreground">
100
- {i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
101
- </p>
102
- </div>
116
+ <RefusalNotice refusal={refusal} />
103
117
  ) : document.isError || !selected ? (
104
118
  <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
105
119
  <div className="space-y-3">
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which sentence a refused change to a resource is told with.
3
+ *
4
+ * ⚠️ A conflict is the one that must not be swallowed: every write in the product names the state it
5
+ * was made against — `baseUpdatedAt` for a record, `baseVersionId` for a table — and turns a parallel
6
+ * edit into a refusal. A refusal nobody words is indistinguishable from a change that went through.
7
+ *
8
+ * ⚠️ Flat and on its own since #531, where the table's column dialog became the second reader. It
9
+ * used to live in `resource-menu.tsx`; importing it from there would have made the menu and the
10
+ * dialog import each other, and a cycle is a thing that works until the bundler splits differently.
11
+ */
12
+ export function resourceErrorKey(error: unknown): string {
13
+ const code =
14
+ typeof error === "object" && error !== null && "code" in error
15
+ ? String((error as { code: unknown }).code)
16
+ : null;
17
+ switch (code) {
18
+ // ⚠️ `version_conflict` is the table's word for the same event (`nodes.ts`, `tableStateFor`):
19
+ // the positions or the mapping were read from a version that is no longer the newest. It is the
20
+ // conflict sentence and not the general one — "reload it and try again" is exactly the way out,
21
+ // and letting it fall through to `resource.failed` would tell somebody to reload in the same
22
+ // breath as claiming nobody knows what happened.
23
+ case "version_conflict":
24
+ case "update_conflict":
25
+ case "flow_update_conflict":
26
+ return "resource.conflict";
27
+ case "node_forbidden":
28
+ case "flow_edit_forbidden":
29
+ return "resource.forbidden";
30
+ default:
31
+ return "resource.failed";
32
+ }
33
+ }
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
7
7
  Archive,
8
+ Columns3,
8
9
  CornerLeftUp,
9
10
  Ellipsis,
10
11
  FileArchive,
@@ -34,8 +35,10 @@ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.t
34
35
  import { useI18n } from "@/i18n/i18n-context.tsx";
35
36
  import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
36
37
  import { Modal } from "@/modal/modal.tsx";
38
+ import { resourceErrorKey } from "@/resource-error.ts";
37
39
  import { useIntelRouterContext } from "@/router/router-context.ts";
38
40
  import { selectedFrom } from "@/router/selection-search.ts";
41
+ import { TableColumnsDialog } from "@/table-columns/table-columns.tsx";
39
42
  import { RelativeTime } from "@/time/relative-time.tsx";
40
43
  import { useDateTime } from "@/time/time-context.tsx";
41
44
 
@@ -92,26 +95,6 @@ export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"]
92
95
  return `${subject}.verbHint.${verb}`;
93
96
  }
94
97
 
95
- // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
96
- // the one that must not be swallowed: `baseUpdatedAt` turns a parallel edit into a refusal, and a
97
- // refusal nobody words is indistinguishable from a change that went through.
98
- export function resourceErrorKey(error: unknown): string {
99
- const code =
100
- typeof error === "object" && error !== null && "code" in error
101
- ? String((error as { code: unknown }).code)
102
- : null;
103
- switch (code) {
104
- case "update_conflict":
105
- case "flow_update_conflict":
106
- return "resource.conflict";
107
- case "node_forbidden":
108
- case "flow_edit_forbidden":
109
- return "resource.forbidden";
110
- default:
111
- return "resource.failed";
112
- }
113
- }
114
-
115
98
  /**
116
99
  * The three-dot menu.
117
100
  *
@@ -140,6 +123,7 @@ export function ResourceMenu({
140
123
  const [sharing, setSharing] = useState(false);
141
124
  const [linksOpen, setLinksOpen] = useState(false);
142
125
  const [versionsOpen, setVersionsOpen] = useState(false);
126
+ const [columnsOpen, setColumnsOpen] = useState(false);
143
127
  const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
144
128
  const pathname = useRouterState({ select: (state) => state.location.pathname });
145
129
  // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
@@ -165,6 +149,10 @@ export function ResourceMenu({
165
149
  // ⚠️ Absent, not disabled — the rule at the top of this file. An import needs a folder to file
166
150
  // into; a document has nowhere to put a subtree, and a greyed-out entry would promise it does.
167
151
  const importable = node !== null && node.kind === "folder";
152
+ // ⚠️ Only a table has a header to change, so only a table carries the entry — absent, not
153
+ // disabled, the rule at the top of this file. Until #531 the header could be changed over MCP and
154
+ // nowhere else: a person had strictly less reach on their own table than a model did.
155
+ const isTable = node !== null && node.kind === "table";
168
156
  const bundleImport = useBundleImport({ where: title });
169
157
 
170
158
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
@@ -296,6 +284,15 @@ export function ResourceMenu({
296
284
  <Pencil aria-hidden="true" />
297
285
  {i18n.t("resource.rename")}
298
286
  </DropdownMenuItem>
287
+ {/* Directly under the rename, because they are the same gesture at two levels: one names
288
+ the table, the other names its columns. A reader looking for "change what this is
289
+ called" finds both without reading the rest of the menu (#531). */}
290
+ {isTable ? (
291
+ <DropdownMenuItem onSelect={() => setColumnsOpen(true)}>
292
+ <Columns3 aria-hidden="true" />
293
+ {i18n.t("resource.columns")}
294
+ </DropdownMenuItem>
295
+ ) : null}
299
296
  {/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
300
297
  shortcut; it does not need a second label beside this action. */}
301
298
  {/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
@@ -414,19 +411,30 @@ export function ResourceMenu({
414
411
  (`flows.problem.<code>` is one string) and repeating it under itself says nothing
415
412
  the reader did not just read. What differs is the detail, and every detail is
416
413
  still here — which is the promise the comment above makes: somebody with two
417
- missing tools should not have to ask twice. */}
414
+ missing tools should not have to ask twice.
415
+
416
+ ⚠️ What identifies a detail is its POSITION in `problems`, not its text (#461).
417
+ Two details of one code are routinely the same sentence: `collectRunProblems`
418
+ files one problem per sub-flow NODE and words it with that node's label
419
+ (`packages/api/src/flows/flows.ts`), and calling the same flow twice is the normal
420
+ case. Under `key={detail}` those two collided on one key, and React does not
421
+ answer that with a console warning alone — it drops one of the siblings on the
422
+ next render, so the reader sees ONE entry where there are two causes. */}
418
423
  {[
419
424
  ...validation.problems
420
- .reduce((byCode, problem) => {
421
- byCode.set(problem.code, [...(byCode.get(problem.code) ?? []), problem.detail]);
425
+ .reduce((byCode, problem, at) => {
426
+ byCode.set(problem.code, [
427
+ ...(byCode.get(problem.code) ?? []),
428
+ { at, detail: problem.detail },
429
+ ]);
422
430
  return byCode;
423
- }, new Map<string, string[]>())
431
+ }, new Map<string, { at: number; detail: string }[]>())
424
432
  .entries(),
425
433
  ].map(([code, details]) => (
426
434
  <li key={code} className="rounded-md border p-3 text-sm">
427
435
  <span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
428
- {details.map((detail) => (
429
- <span key={detail} className="mt-1 block text-xs text-muted-foreground">
436
+ {details.map(({ at, detail }) => (
437
+ <span key={at} className="mt-1 block text-xs text-muted-foreground">
430
438
  {detail}
431
439
  </span>
432
440
  ))}
@@ -457,6 +465,9 @@ export function ResourceMenu({
457
465
  ) : null}
458
466
  {sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
459
467
  {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
468
+ {columnsOpen && node ? (
469
+ <TableColumnsDialog node={node} close={() => setColumnsOpen(false)} />
470
+ ) : null}
460
471
  {versionsOpen ? (
461
472
  <VersionHistory target={target} close={() => setVersionsOpen(false)} />
462
473
  ) : null}