@anchrd/intel-ui 0.16.0 → 0.17.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 (35) hide show
  1. package/package.json +10 -2
  2. package/src/agent/agent-profile/agent-profile.tsx +2 -0
  3. package/src/board/board-calendar/board-calendar.tsx +89 -0
  4. package/src/board/board-card/board-card.tsx +106 -0
  5. package/src/board/board-data/board-data.ts +241 -0
  6. package/src/board/board-data/board-data.types.ts +63 -0
  7. package/src/board/board-detail/board-detail.tsx +629 -0
  8. package/src/board/board-gantt/board-gantt.ts +545 -0
  9. package/src/board/board-gantt/board-gantt.tsx +286 -0
  10. package/src/board/board-graph/board-graph.ts +174 -0
  11. package/src/board/board-graph/board-graph.tsx +168 -0
  12. package/src/board/board-items/board-items.ts +183 -0
  13. package/src/board/board-kanban/board-kanban.ts +97 -0
  14. package/src/board/board-kanban/board-kanban.tsx +211 -0
  15. package/src/board/board-status/board-status.ts +59 -0
  16. package/src/board/board-statuses/board-statuses.ts +63 -0
  17. package/src/board/board-statuses/board-statuses.tsx +228 -0
  18. package/src/board/board-table/board-table.ts +33 -0
  19. package/src/board/board-table/board-table.tsx +413 -0
  20. package/src/board/board-views/board-views.tsx +68 -0
  21. package/src/board/board-views/board-views.types.ts +29 -0
  22. package/src/board/board.tsx +251 -0
  23. package/src/components/ui/dropdown-menu.tsx +25 -0
  24. package/src/components/ui/item-calendar.tsx +181 -0
  25. package/src/components/ui/item-gantt.tsx +463 -0
  26. package/src/components/ui/kanban.tsx +245 -0
  27. package/src/components/ui/switch.tsx +25 -0
  28. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  29. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  30. package/src/i18n/de.json +88 -1
  31. package/src/i18n/en.json +88 -1
  32. package/src/i18n/es.json +88 -1
  33. package/src/kind-icon.ts +12 -1
  34. package/src/nodes/nodes.tsx +16 -0
  35. package/src/styles.css +33 -0
@@ -0,0 +1,629 @@
1
+ import { ArchivedBoardStatusId, type BoardTask } from "@anchrd/intel-contract";
2
+ import { useNavigate } from "@tanstack/react-router";
3
+ import { ExternalLink, Plus, Trash2, X } from "lucide-react";
4
+ import { useId, useState } from "react";
5
+ import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
6
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
7
+ import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from "@/components/ui/dialog";
16
+ import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
17
+ import { useI18n } from "@/i18n/i18n-context.tsx";
18
+ import { useSessionUser } from "@/user-name/user-name.ts";
19
+
20
+ // The task and everything under it — the number a delete has to name BEFORE it is confirmed.
21
+ // ⚠️ The same rule the server applies, read off the same field: deleting cascades down `parentId`
22
+ // and answers with a count (#285). That count arrives only WITH the answer, which is too late to
23
+ // ask with — so the question is asked with this, and the server's own number is reported afterwards
24
+ // by the screen (`board.tsx`), which is still there when it arrives.
25
+ export function subtreeSize(taskId: string, childrenOf: Map<string, BoardTask[]>): number {
26
+ let total = 1;
27
+ for (const child of childrenOf.get(taskId) ?? []) total += subtreeSize(child.id, childrenOf);
28
+ return total;
29
+ }
30
+
31
+ /**
32
+ * The one detail panel (anchrd/intel#286).
33
+ *
34
+ * ⚠️ One panel for every view. A card in a lane, a row in the table, an entry in the month and
35
+ * a node in the graph open THIS — not four panels that agree today. Every field is a control
36
+ * rather than a form with a save button, because a board is edited one field at a time and each of
37
+ * these is one operation on the server (#285).
38
+ *
39
+ * ⚠️ Where a task SITS is a move and everything else is an update, and that split is the server's,
40
+ * not a style choice here: a move mints an order key and re-checks two cycle rules, and folding it
41
+ * into the field editor would make every typed character pay for them.
42
+ */
43
+ export function BoardDetail({
44
+ board,
45
+ task,
46
+ close,
47
+ select,
48
+ }: {
49
+ board: BoardHandle;
50
+ task: BoardTask;
51
+ close(): void;
52
+ select(taskId: string): void;
53
+ }) {
54
+ const i18n = useI18n();
55
+ const navigate = useNavigate();
56
+ const me = useSessionUser();
57
+ const headingId = useId();
58
+ // ⚠️ Four drafts, seeded ONCE. The panel is keyed on the task id (`board.tsx`), so pointing it at
59
+ // another card remounts it and re-seeds them — an effect on the server's values would do nothing
60
+ // for that and one thing besides: reset every draft whenever ANY of them round-trips, throwing
61
+ // away the description somebody was halfway through because the title above it saved.
62
+ const [title, setTitle] = useState(task.title);
63
+ const [description, setDescription] = useState(task.description);
64
+ const [startDate, setStartDate] = useState(task.startDate ?? "");
65
+ const [dueDate, setDueDate] = useState(task.dueDate ?? "");
66
+ const [newLabel, setNewLabel] = useState("");
67
+ const [newSubtask, setNewSubtask] = useState("");
68
+ /**
69
+ * Which drafts the reader has actually typed in.
70
+ *
71
+ * ⚠️ Without this, leaving a field is enough to write it back. The blur guard compares the draft
72
+ * against the CURRENT server value, and that value moves underneath an open panel — TanStack
73
+ * refetches on window focus, and a refused write invalidates. So: an agent renames the task over
74
+ * MCP, the reader tabs through the title field without touching it, and the blur sees "draft ≠
75
+ * server" and puts the old title back. The draft has to say whether it is a change at all.
76
+ */
77
+ const [edited, setEdited] = useState<Record<string, boolean>>({});
78
+ const edit = (field: string) => setEdited((current) => ({ ...current, [field]: true }));
79
+ const settle = (field: string) => setEdited((current) => ({ ...current, [field]: false }));
80
+ const [addingReference, setAddingReference] = useState(false);
81
+ const [confirmDelete, setConfirmDelete] = useState(false);
82
+
83
+ const children = board.childrenOf.get(task.id) ?? [];
84
+ /**
85
+ * ⚠️ Only a refusal about THIS task.
86
+ *
87
+ * The mutations live in `useBoard` and are shared by the whole screen — the top bar's create form
88
+ * is `board.add`, and a drag in a lane is `board.move`. Read as a plain `isError`, a failed
89
+ * "new task" up there would pin a red `role="alert"` inside every detail panel opened afterwards,
90
+ * about something that never concerned the task being looked at. `variables` is what the last
91
+ * call was made with, so the refusal can be attributed instead of assumed.
92
+ */
93
+ const failed =
94
+ [board.update, board.move, board.remove].some(
95
+ (mutation) => mutation.isError && mutation.variables?.taskId === task.id,
96
+ ) ||
97
+ // ⚠️ `add` belongs here too, and it is the one that needed reporting most. The subtask form
98
+ // below is the only place in the product that can hit `BoardMaxTaskDepth` — a fifth level is
99
+ // refused — and the form used to clear its field either way, so the refusal looked like a
100
+ // subtask that had been created and then vanished. Attributed by `parentId`, which is what
101
+ // separates this form's writes from the "new task" box in the screen's own bar.
102
+ (board.add.isError && board.add.variables?.parentId === task.id);
103
+
104
+ return (
105
+ <aside
106
+ aria-labelledby={headingId}
107
+ className="absolute top-0 right-0 bottom-0 z-20 flex w-96 flex-col overflow-y-auto border-l bg-card shadow-xl"
108
+ >
109
+ <div className="flex items-start justify-between gap-3 border-b p-4">
110
+ <h3 id={headingId} className="text-sm font-semibold">
111
+ {i18n.t("board.detail")}
112
+ </h3>
113
+ <button
114
+ type="button"
115
+ onClick={close}
116
+ aria-label={i18n.t("common.close")}
117
+ className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
118
+ >
119
+ <X aria-hidden="true" className="size-4" />
120
+ </button>
121
+ </div>
122
+
123
+ {failed ? (
124
+ <p role="alert" className="mx-4 mt-4 text-sm text-destructive">
125
+ {i18n.t("node.operationFailed")}
126
+ </p>
127
+ ) : null}
128
+
129
+ <div className="flex flex-col gap-5 p-4">
130
+ <Field label={i18n.t("common.title")}>
131
+ <input
132
+ value={title}
133
+ aria-label={i18n.t("common.title")}
134
+ onChange={(event) => {
135
+ setTitle(event.currentTarget.value);
136
+ edit("title");
137
+ }}
138
+ onBlur={() => {
139
+ if (!edited.title) return;
140
+ settle("title");
141
+ const next = title.trim();
142
+ // Empty is not a title the contract accepts, so the field goes back to what it was
143
+ // instead of sending a write that is refused.
144
+ if (next === "") return setTitle(task.title);
145
+ if (next !== task.title) board.update.mutate({ taskId: task.id, title: next });
146
+ }}
147
+ className="w-full rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
148
+ />
149
+ </Field>
150
+
151
+ <Field label={i18n.t("board.column.status")}>
152
+ <div className="flex items-center gap-2">
153
+ <span
154
+ aria-hidden="true"
155
+ className={`size-2.5 shrink-0 rounded-full ${toneOf(task.status, board.statuses).dot}`}
156
+ />
157
+ <select
158
+ value={task.status}
159
+ aria-label={i18n.t("board.column.status")}
160
+ // ⚠️ A move, not an update. Archiving is this same control with `archived` chosen —
161
+ // there is no separate verb on the server and there is none here either, because it
162
+ // is not a separate act (#285).
163
+ // Both neighbours null on purpose: changing the column from here puts the task at the
164
+ // end of the one it lands in. A position is something a drag names; a select does not
165
+ // have one to name.
166
+ onChange={(event) =>
167
+ board.move.mutate({
168
+ taskId: task.id,
169
+ status: event.currentTarget.value,
170
+ afterTaskId: null,
171
+ beforeTaskId: null,
172
+ })
173
+ }
174
+ className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
175
+ >
176
+ {board.statuses.map((status) => (
177
+ <option key={status.id} value={status.id}>
178
+ {status.label}
179
+ </option>
180
+ ))}
181
+ </select>
182
+ </div>
183
+ </Field>
184
+
185
+ <Field label={i18n.t("board.column.assignee")}>
186
+ {/* ⚠️ Three choices and no name search, because Intel has no user directory: `/session`
187
+ answers about the signed-in person and nothing else (`useUserName`). Offering a search
188
+ box that can only ever find one person would be a worse lie than offering the one
189
+ person by name. An agent is a node, so it is picked the way every node is. */}
190
+ <select
191
+ aria-label={i18n.t("board.column.assignee")}
192
+ value={
193
+ task.assignee === null
194
+ ? ""
195
+ : task.assignee.type === "agent"
196
+ ? `agent:${task.assignee.nodeId}`
197
+ : `user:${task.assignee.id}`
198
+ }
199
+ onChange={(event) => {
200
+ const value = event.currentTarget.value;
201
+ board.update.mutate({
202
+ taskId: task.id,
203
+ assignee:
204
+ value === ""
205
+ ? null
206
+ : value.startsWith("agent:")
207
+ ? { type: "agent", nodeId: value.slice("agent:".length) }
208
+ : { type: "user", id: value.slice("user:".length) },
209
+ });
210
+ }}
211
+ className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
212
+ >
213
+ <option value="">{i18n.t("board.unassigned")}</option>
214
+ {me ? <option value={`user:${me.id}`}>{i18n.t("board.assignee.me")}</option> : null}
215
+ {/* An assignee this browser cannot offer again — somebody else's id, or an agent that
216
+ is gone — stays selectable so that opening the panel does not silently reassign it. */}
217
+ {task.assignee !== null &&
218
+ task.assignee.type === "user" &&
219
+ task.assignee.id !== me?.id ? (
220
+ <option value={`user:${task.assignee.id}`}>{i18n.t("board.assignee.unnamed")}</option>
221
+ ) : null}
222
+ {task.assignee !== null && task.assignee.type === "agent" ? (
223
+ <AgentOption nodeId={task.assignee.nodeId} />
224
+ ) : null}
225
+ </select>
226
+ </Field>
227
+
228
+ <div className="grid grid-cols-2 gap-3">
229
+ <Field label={i18n.t("board.column.startDate")}>
230
+ {/* ⚠️ On blur, not on change, and this is not a preference. A native date input fires
231
+ `change` on EVERY keystroke once its three segments are filled, so typing the year
232
+ of an existing date walks through 0002, 0020, 0202, 2026 — four writes, four
233
+ idempotency keys, four versions and four audit entries for one gesture, every one of
234
+ them a valid `z.iso.date()` the server has no reason to refuse. */}
235
+ <input
236
+ type="date"
237
+ aria-label={i18n.t("board.column.startDate")}
238
+ value={startDate}
239
+ onChange={(event) => {
240
+ setStartDate(event.currentTarget.value);
241
+ edit("startDate");
242
+ }}
243
+ onBlur={() => {
244
+ if (!edited.startDate) return;
245
+ settle("startDate");
246
+ if (startDate === (task.startDate ?? "")) return;
247
+ board.update.mutate({ taskId: task.id, startDate: startDate || null });
248
+ }}
249
+ className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
250
+ />
251
+ </Field>
252
+ <Field label={i18n.t("board.column.dueDate")}>
253
+ <input
254
+ type="date"
255
+ aria-label={i18n.t("board.column.dueDate")}
256
+ value={dueDate}
257
+ onChange={(event) => {
258
+ setDueDate(event.currentTarget.value);
259
+ edit("dueDate");
260
+ }}
261
+ onBlur={() => {
262
+ if (!edited.dueDate) return;
263
+ settle("dueDate");
264
+ if (dueDate === (task.dueDate ?? "")) return;
265
+ board.update.mutate({ taskId: task.id, dueDate: dueDate || null });
266
+ }}
267
+ className="h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
268
+ />
269
+ </Field>
270
+ </div>
271
+
272
+ <Field label={i18n.t("board.column.labels")}>
273
+ <ul className="flex flex-wrap gap-1">
274
+ {[...new Set(task.labels)].map((label) => (
275
+ <li
276
+ key={label}
277
+ className="inline-flex items-center gap-1 rounded-md border bg-muted px-1.5 py-0.5 text-xs"
278
+ >
279
+ {label}
280
+ <button
281
+ type="button"
282
+ aria-label={i18n.t("board.removeLabel", { label })}
283
+ onClick={() =>
284
+ board.update.mutate({
285
+ taskId: task.id,
286
+ labels: task.labels.filter((existing) => existing !== label),
287
+ })
288
+ }
289
+ className="rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
290
+ >
291
+ <X aria-hidden="true" className="size-3" />
292
+ </button>
293
+ </li>
294
+ ))}
295
+ </ul>
296
+ <form
297
+ className="mt-2 flex gap-2"
298
+ onSubmit={(event) => {
299
+ event.preventDefault();
300
+ const value = newLabel.trim();
301
+ // A label already on the task is not added twice. The contract refuses one now
302
+ // (anchrd/intel#318); refusing it here as well keeps the answer to a repeated word a
303
+ // no-op rather than an error message about something nobody meant to do.
304
+ if (value === "" || task.labels.includes(value)) return;
305
+ board.update.mutate({ taskId: task.id, labels: [...task.labels, value] });
306
+ setNewLabel("");
307
+ }}
308
+ >
309
+ <input
310
+ value={newLabel}
311
+ onChange={(event) => setNewLabel(event.currentTarget.value)}
312
+ placeholder={i18n.t("board.addLabel")}
313
+ aria-label={i18n.t("board.addLabel")}
314
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
315
+ />
316
+ <SmallButton type="submit" label={i18n.t("common.create")} />
317
+ </form>
318
+ </Field>
319
+
320
+ <Field label={i18n.t("board.description")}>
321
+ <textarea
322
+ value={description}
323
+ aria-label={i18n.t("board.description")}
324
+ rows={5}
325
+ onChange={(event) => {
326
+ setDescription(event.currentTarget.value);
327
+ edit("description");
328
+ }}
329
+ onBlur={() => {
330
+ if (!edited.description) return;
331
+ settle("description");
332
+ if (description !== task.description) {
333
+ board.update.mutate({ taskId: task.id, description });
334
+ }
335
+ }}
336
+ className="w-full rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
337
+ />
338
+ </Field>
339
+
340
+ <Field label={i18n.t("board.subtasks")}>
341
+ <ul className="flex flex-col gap-1">
342
+ {children.map((child) => (
343
+ <li key={child.id}>
344
+ <button
345
+ type="button"
346
+ onClick={() => select(child.id)}
347
+ className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
348
+ >
349
+ <span
350
+ aria-hidden="true"
351
+ className={`size-2 shrink-0 rounded-full ${toneOf(child.status, board.statuses).dot}`}
352
+ />
353
+ <span className="min-w-0 truncate">{child.title}</span>
354
+ <span className="ml-auto shrink-0 text-xs text-muted-foreground">
355
+ {labelOf(child.status, board.statuses)}
356
+ </span>
357
+ </button>
358
+ </li>
359
+ ))}
360
+ </ul>
361
+ <form
362
+ className="mt-2 flex gap-2"
363
+ onSubmit={(event) => {
364
+ event.preventDefault();
365
+ const value = newSubtask.trim();
366
+ if (value === "") return;
367
+ // A new subtask starts in its parent's column: it is part of the same piece of work,
368
+ // and a board where every added subtask lands in the backlog reads as a backlog that
369
+ // grows whenever somebody breaks a task down.
370
+ // ⚠️ Cleared on SUCCESS only. `BoardMaxTaskDepth` makes a fifth level a refusal, and
371
+ // a field emptied before the answer arrives turns that refusal into a subtask that
372
+ // appeared to be created and then was not there — with the typed title gone too.
373
+ board.add.mutate(
374
+ {
375
+ title: value,
376
+ parentId: task.id,
377
+ status: task.status,
378
+ assignee: null,
379
+ labels: [],
380
+ startDate: null,
381
+ dueDate: null,
382
+ dependsOn: [],
383
+ description: "",
384
+ references: [],
385
+ afterTaskId: null,
386
+ beforeTaskId: null,
387
+ },
388
+ { onSuccess: () => setNewSubtask("") },
389
+ );
390
+ }}
391
+ >
392
+ <input
393
+ value={newSubtask}
394
+ onChange={(event) => setNewSubtask(event.currentTarget.value)}
395
+ placeholder={i18n.t("board.addSubtask")}
396
+ aria-label={i18n.t("board.addSubtask")}
397
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
398
+ />
399
+ <SmallButton type="submit" label={i18n.t("common.create")} />
400
+ </form>
401
+ </Field>
402
+
403
+ <Field label={i18n.t("board.dependencies")}>
404
+ <ul className="flex flex-col gap-1">
405
+ {/* ⚠️ Once per task waited for, which is what makes `key={id}` unique BY CONSTRUCTION
406
+ rather than by decoration (anchrd/intel#318). Two rows under one key is a React
407
+ warning nobody reads plus two identical lines whose two remove buttons each take
408
+ both away — and waiting twice for a task is waiting for it once. The write path
409
+ refuses a repeat now and a stored one is folded on read, so nothing arriving through
410
+ Intel carries one; a view still may not be what mangles odd data. */}
411
+ {[...new Set(task.dependsOn)].map((id) => {
412
+ const other = board.byId.get(id);
413
+ return (
414
+ <li key={id} className="flex items-center gap-2 text-sm">
415
+ <button
416
+ type="button"
417
+ onClick={() => select(id)}
418
+ disabled={other === undefined}
419
+ className="min-w-0 flex-1 truncate rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground"
420
+ >
421
+ {other?.title ?? i18n.t("board.unknownTask")}
422
+ </button>
423
+ {other && !board.settled(other) ? (
424
+ <span className="shrink-0 rounded border border-destructive px-1 text-xs text-destructive">
425
+ {i18n.t("board.open")}
426
+ </span>
427
+ ) : null}
428
+ <button
429
+ type="button"
430
+ aria-label={i18n.t("board.removeDependency")}
431
+ onClick={() =>
432
+ board.update.mutate({
433
+ taskId: task.id,
434
+ dependsOn: task.dependsOn.filter((existing) => existing !== id),
435
+ })
436
+ }
437
+ className="shrink-0 rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
438
+ >
439
+ <X aria-hidden="true" className="size-3.5" />
440
+ </button>
441
+ </li>
442
+ );
443
+ })}
444
+ </ul>
445
+ {/* ⚠️ Only tasks of THIS board are offered, because only they can be one: a dependency on
446
+ a task in another board is refused on the write path, and across boards the link is a
447
+ reference to the board node instead (#285). A picker that offered them would be a
448
+ picker whose choices are rejected. */}
449
+ <select
450
+ value=""
451
+ aria-label={i18n.t("board.addDependency")}
452
+ onChange={(event) => {
453
+ const id = event.currentTarget.value;
454
+ if (id === "") return;
455
+ board.update.mutate({ taskId: task.id, dependsOn: [...task.dependsOn, id] });
456
+ }}
457
+ className="mt-2 h-8 w-full rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
458
+ >
459
+ <option value="">{i18n.t("board.addDependency")}</option>
460
+ {board.tasks
461
+ .filter((other) => other.id !== task.id && !task.dependsOn.includes(other.id))
462
+ .map((other) => (
463
+ <option key={other.id} value={other.id}>
464
+ {other.title}
465
+ </option>
466
+ ))}
467
+ </select>
468
+ </Field>
469
+
470
+ <Field label={i18n.t("board.references")}>
471
+ <ul className="flex flex-col gap-1">
472
+ {[...new Set(task.references)].map((id) => (
473
+ <li key={id} className="flex items-center gap-2 text-sm">
474
+ <ReferenceLink
475
+ nodeId={id}
476
+ open={() => void navigate({ to: "/nodes", search: { select: id } })}
477
+ />
478
+ <button
479
+ type="button"
480
+ aria-label={i18n.t("board.removeReference")}
481
+ onClick={() =>
482
+ board.update.mutate({
483
+ taskId: task.id,
484
+ references: task.references.filter((existing) => existing !== id),
485
+ })
486
+ }
487
+ className="shrink-0 rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
488
+ >
489
+ <X aria-hidden="true" className="size-3.5" />
490
+ </button>
491
+ </li>
492
+ ))}
493
+ </ul>
494
+ {addingReference ? (
495
+ <div className="mt-2">
496
+ <EntryPicker
497
+ kind={null}
498
+ value=""
499
+ label={i18n.t("board.addReference")}
500
+ onSelect={(entryId) => {
501
+ setAddingReference(false);
502
+ if (task.references.includes(entryId)) return;
503
+ board.update.mutate({
504
+ taskId: task.id,
505
+ references: [...task.references, entryId],
506
+ });
507
+ }}
508
+ />
509
+ </div>
510
+ ) : (
511
+ <button
512
+ type="button"
513
+ onClick={() => setAddingReference(true)}
514
+ className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
515
+ >
516
+ <Plus aria-hidden="true" className="size-4" />
517
+ {i18n.t("board.addReference")}
518
+ </button>
519
+ )}
520
+ </Field>
521
+
522
+ <button
523
+ type="button"
524
+ onClick={() => setConfirmDelete(true)}
525
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-destructive px-3 text-sm text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring"
526
+ >
527
+ <Trash2 aria-hidden="true" className="size-4" />
528
+ {i18n.t("board.deleteTask")}
529
+ </button>
530
+ </div>
531
+
532
+ <Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
533
+ <DialogContent>
534
+ <DialogHeader>
535
+ <DialogTitle>{i18n.t("board.deleteTask")}</DialogTitle>
536
+ {/* ⚠️ The COUNT, always, and not only when there are descendants. Deleting cascades
537
+ (#285), and "delete this task?" in front of a delete that takes four is a question
538
+ that was answered about something else. */}
539
+ <DialogDescription>
540
+ {i18n.t("board.deleteConfirm", {
541
+ task: task.title,
542
+ count: subtreeSize(task.id, board.childrenOf),
543
+ })}
544
+ </DialogDescription>
545
+ </DialogHeader>
546
+ <DialogFooter>
547
+ <button
548
+ type="button"
549
+ onClick={() => setConfirmDelete(false)}
550
+ className="h-9 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
551
+ >
552
+ {i18n.t("common.close")}
553
+ </button>
554
+ <button
555
+ type="button"
556
+ onClick={() => {
557
+ board.remove.mutate({ taskId: task.id }, { onSuccess: () => close() });
558
+ setConfirmDelete(false);
559
+ }}
560
+ className="h-9 rounded-md bg-destructive px-3 text-sm text-destructive-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
561
+ >
562
+ {i18n.t("board.deleteTask")}
563
+ </button>
564
+ </DialogFooter>
565
+ </DialogContent>
566
+ </Dialog>
567
+ {/* ⚠️ The server's own count is NOT reported here, and cannot be: a successful delete closes
568
+ this panel in the same commit, so a line under it would never be read for the task it is
569
+ about — and would then greet whoever opened the next card, announcing a number about a
570
+ task nobody touched. It belongs to the screen, which outlives the delete (`board.tsx`). */}
571
+ {task.status === ArchivedBoardStatusId ? (
572
+ <p className="px-4 pb-4 text-xs text-muted-foreground">{i18n.t("board.archivedHint")}</p>
573
+ ) : null}
574
+ </aside>
575
+ );
576
+ }
577
+
578
+ /**
579
+ * One labelled block of the panel.
580
+ *
581
+ * ⚠️ A `<fieldset>`, deliberately NOT a `<label>`. Half of these hold a list and a form rather than
582
+ * one control — subtasks, dependencies, references — and a `<label>` around several controls names
583
+ * none of them. The legend announces the section on entry, and the control inside carries its own
584
+ * accessible name.
585
+ */
586
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
587
+ return (
588
+ <fieldset className="flex min-w-0 flex-col gap-1.5">
589
+ <legend className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
590
+ {label}
591
+ </legend>
592
+ {children}
593
+ </fieldset>
594
+ );
595
+ }
596
+
597
+ function SmallButton({ type, label }: { type: "submit" | "button"; label: string }) {
598
+ return (
599
+ <button
600
+ type={type}
601
+ className="h-8 shrink-0 rounded-md border px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
602
+ >
603
+ {label}
604
+ </button>
605
+ );
606
+ }
607
+
608
+ function AgentOption({ nodeId }: { nodeId: string }) {
609
+ const entry = useEntryTitle(nodeId);
610
+ return <option value={`agent:${nodeId}`}>{entry.title}</option>;
611
+ }
612
+
613
+ // A reference is a node id; what a reader needs is its name, and clicking it has to lead there.
614
+ // ⚠️ A target the reader may not see is absent from the graph `useEntryTitle` reads, and it says
615
+ // so rather than naming it — the same answer a deleted one gets, on purpose (#41).
616
+ function ReferenceLink({ nodeId, open }: { nodeId: string; open(): void }) {
617
+ const entry = useEntryTitle(nodeId);
618
+ return (
619
+ <button
620
+ type="button"
621
+ onClick={open}
622
+ disabled={!entry.known}
623
+ className="inline-flex min-w-0 flex-1 items-center gap-1.5 rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground disabled:hover:no-underline"
624
+ >
625
+ <ExternalLink aria-hidden="true" className="size-3.5 shrink-0" />
626
+ <span className="min-w-0 truncate">{entry.title}</span>
627
+ </button>
628
+ );
629
+ }