@anchrd/intel-ui 0.33.0 → 0.35.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.
@@ -18,7 +18,7 @@ import {
18
18
  ShieldCheck,
19
19
  } from "lucide-react";
20
20
  import type * as React from "react";
21
- import { useState } from "react";
21
+ import { useId, useState } from "react";
22
22
  import { AccessSummary } from "@/access-summary/access-summary.tsx";
23
23
  import { allTreeLevelsKey, moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
24
24
  import {
@@ -154,6 +154,11 @@ export function ResourceMenu({
154
154
  // nowhere else: a person had strictly less reach on their own table than a model did.
155
155
  const isTable = node !== null && node.kind === "table";
156
156
  const bundleImport = useBundleImport({ where: title });
157
+ // ⚠️ Per instance, not a constant: the folder table renders one menu PER ROW, so a fixed id would
158
+ // put the same `id` in the document twenty times and every entry's name would resolve to the
159
+ // first one's.
160
+ const exportLabelId = useId();
161
+ const exportHintId = useId();
157
162
 
158
163
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
159
164
  // reads the record, and the flat collections behind the search, the link picker and the relation
@@ -316,10 +321,40 @@ export function ResourceMenu({
316
321
  ⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
317
322
  refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
318
323
  (#534). That is the export's gap on every kind it can hit, not something this entry
319
- was covering — which is why it is fixed there and not by keeping a second entry. */}
320
- <DropdownMenuItem onSelect={() => exportBundle.mutate()}>
321
- <FolderDown aria-hidden="true" />
322
- {i18n.t("resource.export")}
324
+ was covering — which is why it is fixed there and not by keeping a second entry.
325
+
326
+ ⚠️ The second line is not decoration and it belongs HERE rather than after the click
327
+ (#433, ADR-0006). A bundle carries one version per node; whoever moves an installation
328
+ this way loses every earlier one, and until now the only place that said so was
329
+ `excluded` in a `manifest.json` inside the zip — read, if ever, long after the source
330
+ installation is gone. A loss that cannot be undone has to be readable BEFORE the
331
+ gesture, and a menu item is where this gesture is chosen.
332
+
333
+ ⚠️ Its third clause is the flow half (#585), and it is a DIFFERENT loss from the first
334
+ two rather than an example of them: a published flow travels as its published graph,
335
+ so edits drafted since do not travel at all. Those edits are not an earlier version —
336
+ they are the newest state there is, and somebody reading only "earlier versions stay
337
+ behind" would expect the opposite of what happens. `Excluded` in `bundle.ts` names it
338
+ with a word of its own for the same reason.
339
+
340
+ ⚠️ It is the entry's DESCRIPTION, not part of its name: `aria-labelledby` keeps the
341
+ name at the one word every other entry here uses, and `aria-describedby` is what a
342
+ screen reader reads after it. Folding a whole sentence into the name would make this
343
+ the only entry in the menu somebody has to listen through to know what it does — and
344
+ the sentence is a condition of the action, which is what a description is for. */}
345
+ <DropdownMenuItem
346
+ className="items-start"
347
+ aria-labelledby={exportLabelId}
348
+ aria-describedby={exportHintId}
349
+ onSelect={() => exportBundle.mutate()}
350
+ >
351
+ <FolderDown aria-hidden="true" className="mt-0.5" />
352
+ <span className="flex flex-col gap-0.5">
353
+ <span id={exportLabelId}>{i18n.t("resource.export")}</span>
354
+ <span id={exportHintId} className="text-xs text-muted-foreground">
355
+ {i18n.t("resource.exportExcludes")}
356
+ </span>
357
+ </span>
323
358
  </DropdownMenuItem>
324
359
  {/* The other half of the same round trip, next to it rather than in the tree's plus
325
360
  (#346): one word in the menu, both sources under it. */}
@@ -411,19 +446,30 @@ export function ResourceMenu({
411
446
  (`flows.problem.<code>` is one string) and repeating it under itself says nothing
412
447
  the reader did not just read. What differs is the detail, and every detail is
413
448
  still here — which is the promise the comment above makes: somebody with two
414
- missing tools should not have to ask twice. */}
449
+ missing tools should not have to ask twice.
450
+
451
+ ⚠️ What identifies a detail is its POSITION in `problems`, not its text (#461).
452
+ Two details of one code are routinely the same sentence: `collectRunProblems`
453
+ files one problem per sub-flow NODE and words it with that node's label
454
+ (`packages/api/src/flows/flows.ts`), and calling the same flow twice is the normal
455
+ case. Under `key={detail}` those two collided on one key, and React does not
456
+ answer that with a console warning alone — it drops one of the siblings on the
457
+ next render, so the reader sees ONE entry where there are two causes. */}
415
458
  {[
416
459
  ...validation.problems
417
- .reduce((byCode, problem) => {
418
- byCode.set(problem.code, [...(byCode.get(problem.code) ?? []), problem.detail]);
460
+ .reduce((byCode, problem, at) => {
461
+ byCode.set(problem.code, [
462
+ ...(byCode.get(problem.code) ?? []),
463
+ { at, detail: problem.detail },
464
+ ]);
419
465
  return byCode;
420
- }, new Map<string, string[]>())
466
+ }, new Map<string, { at: number; detail: string }[]>())
421
467
  .entries(),
422
468
  ].map(([code, details]) => (
423
469
  <li key={code} className="rounded-md border p-3 text-sm">
424
470
  <span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
425
- {details.map((detail) => (
426
- <span key={detail} className="mt-1 block text-xs text-muted-foreground">
471
+ {details.map(({ at, detail }) => (
472
+ <span key={at} className="mt-1 block text-xs text-muted-foreground">
427
473
  {detail}
428
474
  </span>
429
475
  ))}
@@ -776,27 +822,41 @@ function SharePanel({ target, close }: { target: ResourceTarget; close(): void }
776
822
  *
777
823
  * ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
778
824
  * folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
779
- * `revokeGrant`) and the refusal's `detail` is the only place the CALLERS are named. Rendering
780
- * it as the generic "access was not changed" threw exactly that away and left the reader with a
781
- * dialog that refuses and will not say why, for the one grant that is hardest to undo.
825
+ * `revokeGrant`), and the CALLERS are what makes that refusal actionable. Rendering it as the
826
+ * generic "access was not changed" threw exactly that away and left the reader with a dialog that
827
+ * refuses and will not say why, for the one grant that is hardest to undo.
782
828
  *
783
- * ⚠️ The server's own words, on purpose. `callersDetail` names the flows this actor may see and
784
- * only COUNTS the rest, so it is actionable without becoming a directory of the tree — a
785
- * translated stand-in would either lose the names or need them separately, and the wire carries
786
- * prose rather than a list (#448). The lead-in sentence above it is translated; this line is the
787
- * evidence under it.
829
+ * ⚠️ Since #448 they arrive as data the flows this actor may see by name, the rest as a count —
830
+ * so the sentence is written here, in the reader's language, exactly like the `shareUnreadable`
831
+ * warning below it. The server still decides WHICH of them may be named; that is an authorization
832
+ * answer and nothing on this side recomputes it.
788
833
  */
789
- const revokeInUse =
790
- revoke.error instanceof IntelRequestError && revoke.error.code === "folder_execute_in_use"
791
- ? revoke.error.message
792
- : null;
834
+ const revokeError = revoke.error instanceof IntelRequestError ? revoke.error : null;
835
+ const revokeInUse = revokeError?.code === "folder_execute_in_use" ? revokeError : null;
793
836
 
794
837
  return (
795
838
  <Modal title={i18n.t("node.share")} close={close}>
796
839
  {revokeInUse ? (
797
840
  <div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
798
841
  <p>{i18n.t("node.revokeInUse")}</p>
799
- <p className="text-xs">{revokeInUse}</p>
842
+ {/* An API that predates #448 sends no `callers`, and its sentence is then the only place
843
+ the names exist at all. Showing it beats showing nothing — losing the titles is the
844
+ failure this ticket is about. */}
845
+ {revokeInUse.callers === null ? (
846
+ <p className="text-xs">{revokeInUse.message}</p>
847
+ ) : (
848
+ <p className="text-xs">
849
+ {revokeInUse.callers.titles.length > 0
850
+ ? i18n.t("node.revokeInUseCallers", {
851
+ titles: revokeInUse.callers.titles.join(", "),
852
+ })
853
+ : i18n.t("node.revokeInUseCallersHidden", { count: revokeInUse.callers.hidden })}
854
+ {revokeInUse.callers.titles.length > 0 && revokeInUse.callers.hidden > 0
855
+ ? ` ${i18n.t("node.revokeInUseCallersMore", { count: revokeInUse.callers.hidden })}`
856
+ : ""}
857
+ {` ${i18n.t("node.revokeInUseHint")}`}
858
+ </p>
859
+ )}
800
860
  </div>
801
861
  ) : (
802
862
  (share.isError || revoke.isError || grants.isError) && (
@@ -415,10 +415,11 @@ function ServerGroup({
415
415
  // with it — otherwise Enter on a hit lands on a screen where nothing happened.
416
416
  const holdsRequested = group.items.some((item) => item.name === requested);
417
417
  const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
418
- const count =
419
- group.items.length === 1
420
- ? i18n.t("tools.toolCountOne")
421
- : i18n.t("tools.toolCount", { count: group.items.length });
418
+ // ⚠️ One key, not a `…One` beside a `…Many` (#605): the catalog carries a wording that fits every
419
+ // number, so nothing here branches on the count. The branch that used to stand here is what the
420
+ // rule in `packages/ui/CLAUDE.md` forbids — it puts the inflection into the component and into
421
+ // every future language.
422
+ const count = i18n.t("tools.toolCount", { count: group.items.length });
422
423
  // The badges beside the name read as one run-on word when a screen reader concatenates them
423
424
  // ("wiki1 tool"), so the control says its own name instead of being read off its contents.
424
425
  const spoken = [`${i18n.t("tools.origin")}: ${group.label}`, count];