@anchrd/intel-ui 0.16.1 → 0.18.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.
Files changed (38) hide show
  1. package/package.json +10 -2
  2. package/src/agent/agent-calendar/agent-calendar.tsx +27 -2
  3. package/src/agent/agent-profile/agent-profile.tsx +20 -2
  4. package/src/agent/agent.tsx +27 -2
  5. package/src/board/board-calendar/board-calendar.tsx +89 -0
  6. package/src/board/board-card/board-card.tsx +106 -0
  7. package/src/board/board-data/board-data.ts +241 -0
  8. package/src/board/board-data/board-data.types.ts +63 -0
  9. package/src/board/board-detail/board-detail.tsx +629 -0
  10. package/src/board/board-gantt/board-gantt.ts +545 -0
  11. package/src/board/board-gantt/board-gantt.tsx +286 -0
  12. package/src/board/board-graph/board-graph.ts +174 -0
  13. package/src/board/board-graph/board-graph.tsx +168 -0
  14. package/src/board/board-items/board-items.ts +183 -0
  15. package/src/board/board-kanban/board-kanban.ts +137 -0
  16. package/src/board/board-kanban/board-kanban.tsx +257 -0
  17. package/src/board/board-status/board-status.ts +59 -0
  18. package/src/board/board-statuses/board-statuses.ts +63 -0
  19. package/src/board/board-statuses/board-statuses.tsx +228 -0
  20. package/src/board/board-table/board-table.ts +33 -0
  21. package/src/board/board-table/board-table.tsx +413 -0
  22. package/src/board/board-views/board-views.tsx +68 -0
  23. package/src/board/board-views/board-views.types.ts +29 -0
  24. package/src/board/board.tsx +251 -0
  25. package/src/components/ui/dropdown-menu.tsx +25 -0
  26. package/src/components/ui/item-calendar.tsx +181 -0
  27. package/src/components/ui/item-gantt.tsx +463 -0
  28. package/src/components/ui/kanban.tsx +282 -0
  29. package/src/components/ui/switch.tsx +25 -0
  30. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  31. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  32. package/src/i18n/de.json +90 -1
  33. package/src/i18n/en.json +90 -1
  34. package/src/i18n/es.json +90 -1
  35. package/src/node-table/node-table.tsx +3 -2
  36. package/src/nodes/nodes.tsx +16 -0
  37. package/src/resource-menu/resource-menu.tsx +26 -6
  38. package/src/styles.css +33 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -16,6 +16,10 @@
16
16
  "scripts",
17
17
  "!src/**/*.unit.ts",
18
18
  "!src/**/*.unit.tsx",
19
+ "!src/**/*.int.ts",
20
+ "!src/**/*.int.tsx",
21
+ "!src/**/*.e2e.ts",
22
+ "!src/**/*.e2e.tsx",
19
23
  "index.html",
20
24
  "vite.config.ts",
21
25
  "tsconfig.json",
@@ -29,16 +33,20 @@
29
33
  "typecheck": "tsc --noEmit"
30
34
  },
31
35
  "dependencies": {
32
- "@anchrd/intel-contract": "^0.10.0",
36
+ "@anchrd/intel-contract": "^0.12.0",
33
37
  "@assistant-ui/react": "^0.15.4",
34
38
  "@assistant-ui/react-ai-sdk": "^1.4.4",
35
39
  "@blocknote/core": "^0.52.1",
36
40
  "@blocknote/react": "^0.52.1",
37
41
  "@blocknote/shadcn": "^0.52.1",
42
+ "@dnd-kit/core": "^6.3.1",
43
+ "@dnd-kit/sortable": "^10.0.0",
44
+ "@dnd-kit/utilities": "^3.2.2",
38
45
  "@react-sigma/core": "^5.0.6",
39
46
  "@tailwindcss/vite": "^4.3.3",
40
47
  "@tanstack/react-query": "^5.101.4",
41
48
  "@tanstack/react-router": "^1.170.18",
49
+ "@tanstack/react-table": "^9.1.0",
42
50
  "@vitejs/plugin-react": "^6.0.4",
43
51
  "@xyflow/react": "^12.11.2",
44
52
  "class-variance-authority": "^0.7.1",
@@ -30,24 +30,40 @@ const PerSchedule = 5;
30
30
  * ⚠️ And only for a zone somebody SET. `useChosenTimezone` answers `null` where nothing was chosen,
31
31
  * and then this screen is exactly what it was — no browser guess dressed up as "your time", and no
32
32
  * second time on every row for a reader who never asked for one.
33
+ *
34
+ * ⚠️ A paused agent gets a sentence over the list, because this is the tab somebody opens to find
35
+ * out WHETHER something runs (#203). Without it two places on one page said opposite things: the
36
+ * header read `Paused · no schedule fires`, and this list went on naming the next five times as if
37
+ * they would happen. The list is kept rather than emptied — what the agent WOULD do is still the
38
+ * answer to "what did I switch off" — but it may not read as a plan.
33
39
  */
34
40
  export function AgentCalendar({
35
41
  definition,
36
42
  now,
43
+ paused,
37
44
  }: {
38
45
  definition: AgentDefinition | null;
39
46
  // Handed in rather than read from the clock, so the view is a function of its input and a test
40
47
  // does not have to travel in time to assert on it.
41
48
  now: Date;
49
+ // ⚠️ Handed in for the same reason, and not read from `useAgentState` here: that hook needs the
50
+ // router context and a query client, and reaching for it would turn every test of this view into
51
+ // an integration test of two providers. The caller already holds the state for the header.
52
+ paused: boolean;
42
53
  }) {
43
54
  const i18n = useI18n();
44
55
  const mine = useChosenTimezone();
45
56
  const schedules = definition?.schedules ?? [];
46
57
  const upcoming = schedules
47
- .flatMap((schedule) =>
58
+ // ⚠️ The schedule's own position rides along, and it is the only thing that separates two
59
+ // LITERALLY identical entries (#323). Two schedules with the same cron, zone and target are a
60
+ // definition somebody can save, and every other part of the key would be equal for them —
61
+ // including the fire time, which is computed from exactly those three.
62
+ .flatMap((schedule, position) =>
48
63
  nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
49
64
  at,
50
65
  schedule,
66
+ position,
51
67
  })),
52
68
  )
53
69
  .sort((left, right) => left.at.getTime() - right.at.getTime());
@@ -94,6 +110,15 @@ export function AgentCalendar({
94
110
  {/* ⚠️ An agent with no schedule shows an empty calendar and nothing else (#254). The sentence
95
111
  that used to stand here described the emptiness the reader is already looking at; the two
96
112
  other states below are different, because each of them is a fact the list cannot show. */}
113
+ {/* ⚠️ Only where there is a list to qualify. A paused agent WITHOUT schedules shows the same
114
+ empty calendar it always did — the two states have to stay distinguishable, and a sentence
115
+ about times that do not happen, over no times at all, describes nothing. Same reason it
116
+ does not appear over `calendarUnreadable`: that one already says nothing is planned. */}
117
+ {paused && upcoming.length > 0 ? (
118
+ <p role="status" className="mt-4 rounded-lg border bg-muted px-4 py-3 text-sm">
119
+ {i18n.t("agent.calendarPaused")}
120
+ </p>
121
+ ) : null}
97
122
  {schedules.length === 0 ? null : upcoming.length === 0 ? (
98
123
  // Schedules exist and none of them fires: every expression is unusable. A different answer
99
124
  // from "no schedules", because it is a different problem and needs a different fix.
@@ -104,7 +129,7 @@ export function AgentCalendar({
104
129
  <ol className="mt-4 space-y-2">
105
130
  {upcoming.map((entry) => (
106
131
  <li
107
- key={`${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
132
+ key={`${entry.position}:${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
108
133
  className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
109
134
  >
110
135
  <CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
@@ -584,6 +584,20 @@ function ToolsSection({
584
584
  <ul className="space-y-2">
585
585
  {selected.map((handle) => {
586
586
  const known = servers.data?.items.find((server) => server.handle === handle);
587
+ /**
588
+ * ⚠️ Silence about the question is not a negative answer to it (#350). While the list is
589
+ * loading — and after a failed read — `servers.data` is `undefined`, and `known` is
590
+ * then falsy for exactly the same reason as "the portal answered and this handle was
591
+ * not in it". Drawn as one, a row said `You no longer reach this server` for half a
592
+ * second on every visit: a statement about a REVOKED permission, and the sentence that
593
+ * makes somebody re-delegate, sign in to the portal again, or open a ticket.
594
+ *
595
+ * It is the same rule `adapters/tools` keeps for `portalConnected` and `reached` on the
596
+ * server side, where `packages/api/CLAUDE.md` spells it out — a missing field is a
597
+ * reason to say less, never to refuse more. This is the one place that said it the
598
+ * other way round.
599
+ */
600
+ const answered = servers.isSuccess;
587
601
  return (
588
602
  <li
589
603
  key={handle}
@@ -597,11 +611,15 @@ function ToolsSection({
597
611
  </span>
598
612
  {/* A server the signed-in person no longer reaches still stands in the definition,
599
613
  and saying so beats drawing it as if it worked. The agent gets nothing from it
600
- either — the catalog is cut against what the delegator reaches. */}
614
+ either — the catalog is cut against what the delegator reaches. But only once
615
+ the portal has actually answered: until then the row carries the handle and
616
+ nothing else, which is everything that is known about it. */}
601
617
  <span className="text-xs text-muted-foreground">
602
618
  {known
603
619
  ? i18n.t("tools.toolCount", { count: String(known.toolCount) })
604
- : i18n.t("agent.toolServerUnavailable")}
620
+ : answered
621
+ ? i18n.t("agent.toolServerUnavailable")
622
+ : null}
605
623
  </span>
606
624
  <button
607
625
  type="button"
@@ -104,7 +104,14 @@ export function AgentPanel({ node }: { node: Node }) {
104
104
  </TabsContent>
105
105
  <TabsContent value="calendar" className="flex min-h-0 flex-col">
106
106
  {tab === "calendar" ? (
107
- <AgentCalendar definition={agent.definition} now={new Date()} />
107
+ <AgentCalendar
108
+ definition={agent.definition}
109
+ now={new Date()}
110
+ // ⚠️ The same `state` the header reads, so the two cannot disagree about one agent —
111
+ // and the pause button writes its answer straight into this query key, so the note
112
+ // goes the moment somebody resumes, without this tab refetching (#203).
113
+ paused={state.data?.paused ?? false}
114
+ />
108
115
  ) : null}
109
116
  </TabsContent>
110
117
  <TabsContent value="log" className="flex min-h-0 flex-col">
@@ -314,7 +321,25 @@ export function AgentActions({
314
321
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-runs", agentId] }),
315
322
  });
316
323
 
317
- const targets = definition?.schedules.map((schedule) => schedule.target) ?? [];
324
+ /**
325
+ * ⚠️ One entry per TARGET, not per schedule (#323). Two schedules on the same flow — 08:00 and
326
+ * 17:00 — are the ordinary configuration this list exists for, and they produced two menu items
327
+ * nobody could choose between: both start the same run, and the time that tells them apart is
328
+ * not on either. They also shared a React key.
329
+ *
330
+ * Deduplicating is the honest reading of what this button does. "Run now" fires the target
331
+ * immediately; the schedule is the thing it is deliberately NOT waiting for, so which of the two
332
+ * entries somebody picked could never have meant anything. The alternative — two entries labelled
333
+ * with their cron — offers a choice that has no consequence.
334
+ */
335
+ const targets = [
336
+ ...new Map(
337
+ (definition?.schedules ?? []).map((schedule) => [
338
+ `${schedule.target.kind}:${schedule.target.id}`,
339
+ schedule.target,
340
+ ]),
341
+ ).values(),
342
+ ];
318
343
  const reason = denied
319
344
  ? i18n.t("agent.notPermitted")
320
345
  : state.isError
@@ -0,0 +1,89 @@
1
+ import { useMemo, useState } from "react";
2
+ import { shownTasks } from "@/board/board-data/board-data.ts";
3
+ import { type BoardItem, boardSchedule } from "@/board/board-items/board-items.ts";
4
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
5
+ import { ItemCalendar } from "@/components/ui/item-calendar.tsx";
6
+ import { useI18n } from "@/i18n/i18n-context.tsx";
7
+
8
+ // Which day a week starts on, in the reader's own language rather than fixed at Sunday. Intl knows
9
+ // it; a hard-coded 0 would draw a German month the way an American reads one.
10
+ function weekStart(locale: string): number {
11
+ const info = new Intl.Locale(locale);
12
+ // Not every runtime answers, and the ones that do disagree on where it lives. Monday is the ISO
13
+ // default and the safer of the two guesses for a European installation.
14
+ const week = (info as unknown as { getWeekInfo?(): { firstDay: number } }).getWeekInfo?.();
15
+ const firstDay = week?.firstDay ?? 1;
16
+ // Intl counts Monday as 1 and Sunday as 7; `Date.getDay()` counts Sunday as 0.
17
+ return firstDay % 7;
18
+ }
19
+
20
+ /**
21
+ * The board as a month (anchrd/intel#286).
22
+ *
23
+ * ⚠️ It draws `boardSchedule` and nothing else. That adapter is the ONE reading of a task as a
24
+ * dated thing, and Gantt (anchrd/intel#293) is a second renderer over the same list — a calendar
25
+ * that read `dueDate` itself would be the second reading, and the two would disagree about the case
26
+ * that is actually subtle (a task with a start date and no due date).
27
+ *
28
+ * ⚠️ A task without a due date is NOT in the grid and is counted underneath it. Nothing vanishes
29
+ * quietly: a month showing eleven of twenty tasks with no word about the other nine is a month
30
+ * that answers "what is due" with a number that is wrong.
31
+ */
32
+ export function BoardCalendar({ board, select, showArchived }: BoardViewProps) {
33
+ const i18n = useI18n();
34
+ const [month, setMonth] = useState(() => {
35
+ const now = new Date();
36
+ return new Date(now.getFullYear(), now.getMonth(), 1);
37
+ });
38
+
39
+ const schedule = useMemo(
40
+ () => boardSchedule(shownTasks(board.tasks, showArchived), board.statuses, board.blockedBy),
41
+ [board.tasks, board.statuses, board.blockedBy, showArchived],
42
+ );
43
+
44
+ return (
45
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
46
+ <ItemCalendar<BoardItem>
47
+ items={schedule.items}
48
+ month={month}
49
+ onMonthChange={setMonth}
50
+ locale={i18n.locale}
51
+ weekStartsOn={weekStart(i18n.locale)}
52
+ labels={{
53
+ previous: i18n.t("board.calendar.previous"),
54
+ next: i18n.t("board.calendar.next"),
55
+ today: i18n.t("board.calendar.today"),
56
+ more: (count) => i18n.t("board.calendar.more", { count }),
57
+ }}
58
+ renderItem={(item) => (
59
+ <button
60
+ type="button"
61
+ onClick={() => select(item.id)}
62
+ // ⚠️ The same click as a card in a lane and a node in the graph: it opens the one
63
+ // detail panel. Three views with three different ideas of what selecting means would
64
+ // be three products.
65
+ // The status is on the left edge as well as in the dot: at this size a two-millimetre
66
+ // dot is the whole of the colour coding, and a bar down the side survives a glance
67
+ // across a whole month.
68
+ className={`flex w-full min-w-0 items-center gap-1.5 rounded border-l-2 bg-muted/60 px-1.5 py-0.5 text-left text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring ${item.status.tone.chip}`}
69
+ >
70
+ <span
71
+ aria-hidden="true"
72
+ className={`size-1.5 shrink-0 rounded-full ${item.status.tone.dot}`}
73
+ />
74
+ <span className="min-w-0 truncate">{item.name}</span>
75
+ {/* The status in words as well as in colour — the grid is the one place a reader has
76
+ no column heading to fall back on. */}
77
+ <span className="sr-only">{item.status.label}</span>
78
+ {item.blocked ? <span className="sr-only">{i18n.t("board.blocked")}</span> : null}
79
+ </button>
80
+ )}
81
+ />
82
+ {schedule.undated.length > 0 ? (
83
+ <p className="text-sm text-muted-foreground">
84
+ {i18n.t("board.calendar.undated", { count: schedule.undated.length })}
85
+ </p>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,106 @@
1
+ import type { BoardAssignee, BoardTask } from "@anchrd/intel-contract";
2
+ import { Ban, Bot, CalendarDays, ListTree, User } from "lucide-react";
3
+ import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
+ import { useUserName } from "@/user-name/user-name.ts";
6
+
7
+ /**
8
+ * Who a task is on.
9
+ *
10
+ * ⚠️ An id is never shown, and never falls back to being shown. Intel has no user directory
11
+ * (`useUserName`), so most person ids cannot be resolved here — but an unassigned card and a card
12
+ * assigned to somebody this browser cannot name are two different facts, and drawing them the same
13
+ * would be the lie the DoD's "assignee on the card" exists to prevent. So the marker stays and only
14
+ * the name goes: "assigned", with nobody named.
15
+ */
16
+ export function BoardAssigneeLabel({ assignee }: { assignee: BoardAssignee }) {
17
+ const i18n = useI18n();
18
+ const person = useUserName(assignee.type === "user" ? assignee.id : null);
19
+ const agent = useEntryTitle(assignee.type === "agent" ? assignee.nodeId : "");
20
+ const Icon = assignee.type === "agent" ? Bot : User;
21
+ const name =
22
+ assignee.type === "agent" ? agent.title : (person ?? i18n.t("board.assignee.unnamed"));
23
+ return (
24
+ <span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
25
+ <Icon aria-hidden="true" className="size-3.5 shrink-0" />
26
+ <span className="truncate">{name}</span>
27
+ </span>
28
+ );
29
+ }
30
+
31
+ /**
32
+ * The card, wherever it is drawn: in a lane, and in the overlay that follows the pointer.
33
+ *
34
+ * ⚠️ It never renders the description. A card is a card — what needs more than a line is opened,
35
+ * and the panel is where the description lives. The board arrives as ONE document (#285), so no
36
+ * card causes a request of its own either; there is nothing left for a card to load.
37
+ */
38
+ export function BoardCardBody({
39
+ task,
40
+ statusLabel,
41
+ toneDot,
42
+ blocked,
43
+ subtasks,
44
+ }: {
45
+ task: BoardTask;
46
+ statusLabel: string;
47
+ toneDot: string;
48
+ blocked: boolean;
49
+ subtasks: { done: number; total: number };
50
+ }) {
51
+ const i18n = useI18n();
52
+ return (
53
+ <div className="flex min-w-0 flex-col gap-2">
54
+ <div className="flex min-w-0 items-start gap-2">
55
+ <span
56
+ aria-hidden="true"
57
+ className={`mt-1 size-2 shrink-0 rounded-full ${toneDot}`}
58
+ data-slot="board-card-status"
59
+ />
60
+ <span className="min-w-0 text-sm font-medium break-words">{task.title}</span>
61
+ </div>
62
+ {/* The colour above says the status to whoever sees colour; this says it to everybody else,
63
+ and it is what makes the card readable in a screen reader's list of cards. */}
64
+ <span className="sr-only">{statusLabel}</span>
65
+ {blocked ? (
66
+ <span className="inline-flex w-fit items-center gap-1 rounded-md border border-destructive px-1.5 py-0.5 text-xs text-destructive">
67
+ <Ban aria-hidden="true" className="size-3" />
68
+ {i18n.t("board.blocked")}
69
+ </span>
70
+ ) : null}
71
+ {task.labels.length > 0 ? (
72
+ <ul className="flex flex-wrap gap-1">
73
+ {/* Once per word, which is what makes `key={label}` unique by construction rather than by
74
+ decoration (anchrd/intel#318) — the same reading the detail panel has. */}
75
+ {[...new Set(task.labels)].map((label) => (
76
+ <li
77
+ key={label}
78
+ className="rounded-md border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
79
+ >
80
+ {label}
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ ) : null}
85
+ <div className="flex flex-wrap items-center gap-3">
86
+ {task.assignee ? <BoardAssigneeLabel assignee={task.assignee} /> : null}
87
+ {task.dueDate ? (
88
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
89
+ <CalendarDays aria-hidden="true" className="size-3.5" />
90
+ <time dateTime={task.dueDate}>
91
+ {new Date(`${task.dueDate}T00:00:00`).toLocaleDateString(i18n.locale)}
92
+ </time>
93
+ </span>
94
+ ) : null}
95
+ {/* ⚠️ A counter and not the children themselves. Drawing a subtask as a card of its own
96
+ would put the same work on the board twice — once under its parent and once beside it. */}
97
+ {subtasks.total > 0 ? (
98
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
99
+ <ListTree aria-hidden="true" className="size-3.5" />
100
+ {i18n.t("board.subtaskCount", { done: subtasks.done, total: subtasks.total })}
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,241 @@
1
+ import {
2
+ ArchivedBoardStatusId,
3
+ type BoardDocument,
4
+ type BoardStatus,
5
+ type BoardTask,
6
+ type NodeBoard,
7
+ } from "@anchrd/intel-contract";
8
+ import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useMemo } from "react";
10
+ import { useIntelRouterContext } from "@/router/router-context.ts";
11
+ import type { BoardHandle } from "./board-data.types.ts";
12
+
13
+ export const boardKey = (nodeId: string) => ["board", nodeId] as const;
14
+
15
+ // The columns somebody works in. `archived` is a shelf and not a stage, so it is out of every list
16
+ // that offers a status to move a task to and out of the lanes drawn by default.
17
+ export function visibleStatuses(statuses: BoardStatus[]): BoardStatus[] {
18
+ return statuses.filter((status) => status.id !== ArchivedBoardStatusId);
19
+ }
20
+
21
+ /**
22
+ * The board in the order it is drawn: by column, then by the server's fractional key.
23
+ *
24
+ * ⚠️ `order` is only defined WITHIN a column — the server mints a key between the neighbours a
25
+ * move named, and those neighbours are the ones in the column it lands in. Sorting the whole board
26
+ * by `order` alone would therefore interleave columns by an accident of key history. An unknown
27
+ * status sorts last rather than being dropped: a task whose column was configured away is still a
28
+ * task, and a view that silently loses it is worse than one that shows it at the end.
29
+ *
30
+ * ⚠️ And one entry per task id, however often the document names it (anchrd/intel#321). Here rather
31
+ * than in each renderer, for the same reason `shownTasks` is here: the kanban keys its cards by
32
+ * `task.id`, the table its rows, Gantt its bars and the graph its nodes, and React's answer to two
33
+ * children under one key is "duplicated and/or omitted — the behavior is unsupported". Four views
34
+ * would otherwise need four copies of one sentence, and the first bug is the day two of them
35
+ * disagree about which of the pair is on the board.
36
+ *
37
+ * ⚠️ Folded AFTER the sort, so the entry that survives is the one that would have been drawn first,
38
+ * and `byId` therefore answers with the card the reader is looking at.
39
+ *
40
+ * ⚠️ This is a decision about a drawing and not the one `upgradeStoredBoard` deliberately does not
41
+ * make (`repeatedBoardId`, packages/api). Two tasks under one id are two whole tasks; folding them
42
+ * on the SERVER's read would be written back by the next save and the second one would be gone for
43
+ * good. Nothing here writes anything — the document still holds both, and the day the pair is
44
+ * repaired the board draws both again.
45
+ */
46
+ export function orderedTasks(document: BoardDocument): BoardTask[] {
47
+ const rank = new Map(document.statuses.map((status, index) => [status.id, index]));
48
+ const last = document.statuses.length;
49
+ const sorted = [...document.tasks].sort(
50
+ (left, right) =>
51
+ (rank.get(left.status) ?? last) - (rank.get(right.status) ?? last) ||
52
+ (left.order < right.order ? -1 : left.order > right.order ? 1 : 0) ||
53
+ left.id.localeCompare(right.id),
54
+ );
55
+ const seen = new Set<string>();
56
+ return sorted.filter((task) => {
57
+ if (seen.has(task.id)) return false;
58
+ seen.add(task.id);
59
+ return true;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Which columns mean the work is finished (anchrd/intel#311).
65
+ *
66
+ * ⚠️ Read off the status list's own `terminal` flag, never off a position. This used to be "the last
67
+ * column before `archived`", reasoned by symmetry with the server's rule for a new task — and a
68
+ * board configured to `… done → blocked → archived` then counted "blocked" as finished, with a wrong
69
+ * blocked marker as the only symptom. A status says it for itself now, and this is the ONE place the
70
+ * UI asks.
71
+ *
72
+ * ⚠️ A status a task still stands in that the board no longer lists is NOT finished: nothing says it
73
+ * is, and treating an unknown column as done would let deleting a column clear blockers.
74
+ */
75
+ export function settledIn(statuses: BoardStatus[]): ReadonlySet<string> {
76
+ return new Set(statuses.filter((status) => status.terminal).map((status) => status.id));
77
+ }
78
+
79
+ /**
80
+ * What a view draws once the shelf switch has had its say.
81
+ *
82
+ * ⚠️ One rule for every view, here rather than in each of them. The kanban could have done it by
83
+ * leaving out a lane, the table by filtering a column, the graph by dropping nodes and Gantt by
84
+ * leaving a bar undrawn — four implementations of one sentence, and the first bug is the day two of
85
+ * them disagree about whether an archived subtask under a live parent counts as hidden.
86
+ */
87
+ export function shownTasks(tasks: BoardTask[], showArchived: boolean): BoardTask[] {
88
+ return showArchived ? tasks : tasks.filter((task) => task.status !== ArchivedBoardStatusId);
89
+ }
90
+
91
+ /**
92
+ * The board document with one task's write folded in.
93
+ *
94
+ * ⚠️ Patched, never refetched. Every task operation answers with the ONE task it wrote (#285), and
95
+ * a board of five thousand cards would otherwise re-read all of them after each drag. The status
96
+ * list travels along because `configureBoard` writes that instead of a task.
97
+ */
98
+ export function withTask(document: BoardDocument, task: BoardTask): BoardDocument {
99
+ const index = document.tasks.findIndex((existing) => existing.id === task.id);
100
+ const tasks =
101
+ index === -1
102
+ ? [...document.tasks, task]
103
+ : document.tasks.map((existing) => (existing.id === task.id ? task : existing));
104
+ return { ...document, tasks };
105
+ }
106
+
107
+ /**
108
+ * The board with a task and everything under it gone.
109
+ *
110
+ * ⚠️ Two things go, not one. The server cascades to every descendant AND clears the `dependsOn`
111
+ * entries that pointed into the hole, so a cache that only dropped the named task would keep
112
+ * showing subtasks whose parent no longer exists and blocked markers waiting on nothing. The
113
+ * server's answer carries a count and no ids, so the same walk is done here — from the same
114
+ * `parentId` field, which is the whole hierarchy.
115
+ */
116
+ export function withoutTask(document: BoardDocument, taskId: string): BoardDocument {
117
+ const gone = new Set([taskId]);
118
+ // A board is capped at five thousand tasks and five levels, so repeating the sweep until it
119
+ // stops growing is bounded and needs no ordering of its own.
120
+ for (let grew = true; grew; ) {
121
+ grew = false;
122
+ for (const task of document.tasks) {
123
+ if (task.parentId !== null && gone.has(task.parentId) && !gone.has(task.id)) {
124
+ gone.add(task.id);
125
+ grew = true;
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ ...document,
131
+ tasks: document.tasks
132
+ .filter((task) => !gone.has(task.id))
133
+ .map((task) =>
134
+ task.dependsOn.some((id) => gone.has(id))
135
+ ? { ...task, dependsOn: task.dependsOn.filter((id) => !gone.has(id)) }
136
+ : task,
137
+ ),
138
+ };
139
+ }
140
+
141
+ // One place writes the cache, so a mutation that forgets the version id cannot exist: the next
142
+ // read has to see the version the write produced, or the board would offer to write against one
143
+ // that is already superseded.
144
+ function patch(
145
+ client: QueryClient,
146
+ nodeId: string,
147
+ change: (document: BoardDocument) => BoardDocument,
148
+ versionId: string,
149
+ ): void {
150
+ client.setQueryData<NodeBoard>(boardKey(nodeId), (current) =>
151
+ current ? { ...current, board: change(current.board), versionId } : current,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * One board, and the only way any of its views reads or writes it (anchrd/intel#286).
157
+ *
158
+ * ⚠️ Every mutation mints its own `idempotencyKey`. A retried drag must not move a card twice, and
159
+ * a key minted by the view would be one the view has to remember across a re-render it does not
160
+ * control.
161
+ *
162
+ * ⚠️ A failed write invalidates rather than patching. The five operations are the only writers,
163
+ * but they are not the only ones: an agent holds the same board over MCP, and a refusal is the one
164
+ * moment this screen knows its copy may be behind.
165
+ */
166
+ export function useBoard(nodeId: string): BoardHandle {
167
+ const { data } = useIntelRouterContext();
168
+ const client = useQueryClient();
169
+ const query = useQuery({ queryKey: boardKey(nodeId), queryFn: () => data.getBoard(nodeId) });
170
+ const document = query.data?.board ?? null;
171
+
172
+ const derived = useMemo(() => {
173
+ const statuses = document?.statuses ?? [];
174
+ const tasks = document ? orderedTasks(document) : [];
175
+ const byId = new Map(tasks.map((task) => [task.id, task]));
176
+ const childrenOf = new Map<string, BoardTask[]>();
177
+ for (const task of tasks) {
178
+ if (task.parentId === null) continue;
179
+ const siblings = childrenOf.get(task.parentId);
180
+ if (siblings) siblings.push(task);
181
+ else childrenOf.set(task.parentId, [task]);
182
+ }
183
+ const settledStatuses = settledIn(statuses);
184
+ return {
185
+ statuses,
186
+ tasks,
187
+ byId,
188
+ childrenOf,
189
+ settled: (task: BoardTask) => settledStatuses.has(task.status),
190
+ // A dependency on a task that is not on this board cannot exist (#285 refuses it on the
191
+ // write path), so an id that resolves to nothing here is one the reader may not see — and it
192
+ // is left out rather than counted as blocking, because a marker nobody can explain is worse
193
+ // than none.
194
+ blockedBy: (task: BoardTask) =>
195
+ task.dependsOn.flatMap((id) => {
196
+ const other = byId.get(id);
197
+ return other && !settledStatuses.has(other.status) ? [other] : [];
198
+ }),
199
+ };
200
+ }, [document]);
201
+
202
+ const failed = () => client.invalidateQueries({ queryKey: boardKey(nodeId) });
203
+
204
+ const add = useMutation({
205
+ mutationFn: (input: Parameters<BoardHandle["add"]["mutate"]>[0]) =>
206
+ data.addBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
207
+ onSuccess: (result) =>
208
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
209
+ onError: failed,
210
+ });
211
+ const update = useMutation({
212
+ mutationFn: (input: Parameters<BoardHandle["update"]["mutate"]>[0]) =>
213
+ data.updateBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
214
+ onSuccess: (result) =>
215
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
216
+ onError: failed,
217
+ });
218
+ const move = useMutation({
219
+ mutationFn: (input: Parameters<BoardHandle["move"]["mutate"]>[0]) =>
220
+ data.moveBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
221
+ onSuccess: (result) =>
222
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
223
+ onError: failed,
224
+ });
225
+ const remove = useMutation({
226
+ mutationFn: (input: { taskId: string }) =>
227
+ data.deleteBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
228
+ onSuccess: (result, input) =>
229
+ patch(client, nodeId, (d) => withoutTask(d, input.taskId), result.version.id),
230
+ onError: failed,
231
+ });
232
+ const configure = useMutation({
233
+ mutationFn: (statuses: Parameters<BoardHandle["configure"]["mutate"]>[0]) =>
234
+ data.configureBoard({ nodeId, statuses, idempotencyKey: crypto.randomUUID() }),
235
+ onSuccess: (result) =>
236
+ patch(client, nodeId, (d) => ({ ...d, statuses: result.statuses }), result.version.id),
237
+ onError: failed,
238
+ });
239
+
240
+ return { nodeId, query, ...derived, add, update, move, remove, configure };
241
+ }