@anchrd/intel-ui 0.17.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.11.0",
36
+ "@anchrd/intel-contract": "^0.12.0",
37
37
  "@assistant-ui/react": "^0.15.4",
38
38
  "@assistant-ui/react-ai-sdk": "^1.4.4",
39
39
  "@blocknote/core": "^0.52.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
@@ -489,7 +489,7 @@ export type GanttEdit = "move" | "start" | "end";
489
489
  *
490
490
  * ⚠️ It answers `null` for a gesture that changed no date, and that is half of what it is for. A
491
491
  * bar picked up and put back is the commonest drag there is; sent anyway it would mint a version,
492
- * bump the board and stand in the audit as work that did not happen — the same rule `moveFromDrop`
492
+ * bump the board and stand in the audit as work that did not happen — the same rule `dropOutcome`
493
493
  * holds for a card dropped on its own place.
494
494
  *
495
495
  * ⚠️ A move shifts the dates the task HAS. A task with only a due date is drawn as the single day it
@@ -3,22 +3,42 @@ import type { MoveTask } from "@/board/board-data/board-data.types.ts";
3
3
  import type { KanbanDrop } from "@/components/ui/kanban.tsx";
4
4
 
5
5
  /**
6
- * What a drop means, as the one `board_task_move` it is worth.
6
+ * What a drop DID: the one `board_task_move` it is worth, or the reason there is none.
7
7
  *
8
+ * ⚠️ Three answers rather than a move or a `null`, and the third one is the whole of
9
+ * anchrd/intel#345. Two of the ways a drag ends write nothing, and they are not the same event to
10
+ * whoever is being told about it: a card let go on its own place stayed put, and a card let go over
11
+ * a lane the board cannot write into was REFUSED. Announced as one — or, as it was, announced as a
12
+ * move that never happened — the difference is exactly the one a reader needs, because since
13
+ * anchrd/intel#315 the announcement is the only feedback the keyboard path has.
14
+ */
15
+ export type DropOutcome =
16
+ // ⚠️ `status` is optional on the wire — a move may be a reparent and name no column — but a drop
17
+ // always lands in a lane and always names it. Narrowed here so the sentence a reader hears can
18
+ // name the lane the card really went into rather than the one the pointer was last over.
19
+ | { kind: "move"; move: MoveTask & { status: string } }
20
+ // The status the drop resolved to: drawn by the board, and not one it may write into. Carried
21
+ // rather than looked up again, so the sentence a reader hears names the lane that said no.
22
+ | { kind: "refused"; status: string }
23
+ // Nothing to write and nothing refused: the card ends where it began, or it was let go over
24
+ // something the board does not draw.
25
+ | { kind: "unchanged" };
26
+
27
+ /**
8
28
  * ⚠️ Neighbours, never an index. The server mints the fractional key between the two tasks named
9
29
  * here (#285), so one drag writes one task and renumbers nothing — the reason a board is addressed
10
30
  * by task id and not by position at all. A UI that sent a position would have to send the whole
11
31
  * column with it to be unambiguous.
12
32
  *
13
- * ⚠️ `null` for a drag that changed nothing, and that is half the point of this function. dnd-kit
14
- * reports a drop wherever the pointer was let go, including back on the card's own place; sending
15
- * a move for that would write a version, bump the board and show up in the audit as work that did
16
- * not happen.
33
+ * ⚠️ Nothing to write for a drag that changed nothing, and that is half the point of this function.
34
+ * dnd-kit reports a drop wherever the pointer was let go, including back on the card's own place;
35
+ * sending a move for that would write a version, bump the board and show up in the audit as work
36
+ * that did not happen.
17
37
  *
18
38
  * `lanes` is what the board DRAWS — root tasks per column, in order. Subtasks are not lane cards
19
39
  * (they are a counter on their parent), so they are not in it and cannot be dropped on either.
20
40
  */
21
- export function moveFromDrop(
41
+ export function dropOutcome(
22
42
  drop: KanbanDrop,
23
43
  lanes: Map<string, BoardTask[]>,
24
44
  byId: Map<string, BoardTask>,
@@ -27,22 +47,39 @@ export function moveFromDrop(
27
47
  // not be somewhere a card can be MOVED. The server refuses a task in a status the board does not
28
48
  // have, so a drop there would be a refusal, an invalidation and no word to the reader.
29
49
  writable: ReadonlySet<string>,
30
- ): MoveTask | null {
50
+ ): DropOutcome {
31
51
  const active = byId.get(drop.activeId);
32
52
  // ⚠️ Let go over itself is the commonest drag of all — a click that travelled five pixels — and
33
53
  // it has to be nothing before any of the arithmetic below runs. Read as "dropped on a card", it
34
54
  // would compute a landing place for a card that never left, which is the shape of a write that
35
55
  // says work happened.
36
- if (drop.overId === null || drop.overId === drop.activeId || active === undefined) return null;
56
+ //
57
+ // ⚠️ And it is `unchanged` even when the card stands in a lane that cannot be written into: what
58
+ // happened there is that a card came back to its own place, not that a lane turned a card away.
59
+ if (drop.overId === null || drop.overId === drop.activeId || active === undefined)
60
+ return { kind: "unchanged" };
37
61
 
38
62
  // The drop landed either on a column's empty space — then its id is the status — or on a card,
39
63
  // and then the column is that card's. A card the board does not draw (a subtask, or one filtered
40
64
  // away) resolves to neither and the drag is a no-op rather than a guess.
41
65
  const overTask = byId.get(drop.overId);
42
66
  const status = overTask?.status ?? (lanes.has(drop.overId) ? drop.overId : null);
43
- if (status === null || !writable.has(status)) return null;
67
+ if (status === null) return { kind: "unchanged" };
44
68
 
45
69
  const lane = lanes.get(status) ?? [];
70
+ // ⚠️ "Is this a card the board draws" is read BEFORE the refusal, and the order is the whole of
71
+ // it. `byId` holds subtasks too — they are counters on their parent, not lane cards — and one of
72
+ // them left in a status the board no longer lists resolves to a status without ever standing in
73
+ // that lane. Refused first, such a drop would be announced as a lane turning the card away while
74
+ // no lane was involved at all. Nothing renders a subtask as a droppable today; this is a guard on
75
+ // the order, so that the day one does, the sentence does not start lying.
76
+ if (overTask !== undefined && !lane.some((task) => task.id === overTask.id))
77
+ return { kind: "unchanged" };
78
+ // ⚠️ Refused BEFORE the request, and said so. The lane is drawn and its cards are still sortables,
79
+ // so a drop lands here on both the pointer path and the keyboard one; the server would answer a
80
+ // task in a status the board does not have with a refusal.
81
+ if (!writable.has(status)) return { kind: "refused", status };
82
+
46
83
  const from = lane.findIndex((task) => task.id === active.id);
47
84
  const rest = lane.filter((task) => task.id !== active.id);
48
85
 
@@ -51,8 +88,8 @@ export function moveFromDrop(
51
88
  // Let go over the column rather than over a card: the end of it.
52
89
  landing = rest.length;
53
90
  } else {
91
+ // In the lane (checked above) and not the active card (checked at the top), so it is in `rest`.
54
92
  const overIndex = rest.findIndex((task) => task.id === overTask.id);
55
- if (overIndex === -1) return null;
56
93
  // Dragged downwards inside its own column, the card takes the place BELOW the one it was let go
57
94
  // over — the same reading `arrayMove` has, and the one the pointer suggests. Every other case
58
95
  // puts it above.
@@ -61,13 +98,16 @@ export function moveFromDrop(
61
98
  }
62
99
 
63
100
  // Re-inserting where it came from is the arrangement it already had.
64
- if (from !== -1 && landing === from) return null;
101
+ if (from !== -1 && landing === from) return { kind: "unchanged" };
65
102
 
66
103
  return {
67
- taskId: active.id,
68
- status,
69
- afterTaskId: rest[landing - 1]?.id ?? null,
70
- beforeTaskId: rest[landing]?.id ?? null,
104
+ kind: "move",
105
+ move: {
106
+ taskId: active.id,
107
+ status,
108
+ afterTaskId: rest[landing - 1]?.id ?? null,
109
+ beforeTaskId: rest[landing]?.id ?? null,
110
+ },
71
111
  };
72
112
  }
73
113
 
@@ -12,7 +12,7 @@ import {
12
12
  KanbanProvider,
13
13
  } from "@/components/ui/kanban.tsx";
14
14
  import { useI18n } from "@/i18n/i18n-context.tsx";
15
- import { moveFromDrop, subtaskCount } from "./board-kanban.ts";
15
+ import { dropOutcome, subtaskCount } from "./board-kanban.ts";
16
16
 
17
17
  /**
18
18
  * The board as lanes (anchrd/intel#286).
@@ -98,6 +98,33 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
98
98
  [lanes],
99
99
  );
100
100
 
101
+ /**
102
+ * What a finished drag is announced as — and it is the OUTCOME that is announced, not what the
103
+ * pointer was last over (anchrd/intel#345).
104
+ *
105
+ * ⚠️ Asked of the same function the write is asked of, which is the point: a second reading of a
106
+ * drop would be a second opinion about it, and the one a reader hears would be the one nothing
107
+ * checks. Two of the three answers write nothing, and each says so in its own words — a lane that
108
+ * cannot take the card names itself, and a card that came back to its own place says it stayed.
109
+ * Announced as "dropped into X" (which is what all three used to say), a keyboard user who never
110
+ * sees the card jump has nothing to correct it with.
111
+ */
112
+ const dropped = (activeId: string, overId: string | null) => {
113
+ const active = board.byId.get(activeId);
114
+ const task = active?.title ?? "";
115
+ const from = columnName(active?.status ?? "", columns);
116
+ const outcome = dropOutcome({ activeId, overId }, lanes, board.byId, writable);
117
+ if (outcome.kind === "move")
118
+ return i18n.t("board.dnd.end", { task, lane: columnName(outcome.move.status, columns) });
119
+ if (outcome.kind === "refused")
120
+ return i18n.t("board.dnd.endRefused", {
121
+ task,
122
+ lane: columnName(outcome.status, columns),
123
+ from,
124
+ });
125
+ return i18n.t("board.dnd.endUnchanged", { task, lane: from });
126
+ };
127
+
101
128
  const card = (task: BoardTask) => (
102
129
  <BoardCardBody
103
130
  task={task}
@@ -123,28 +150,31 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
123
150
  lane: over === null ? "" : laneName(String(over.id), board, columns),
124
151
  }),
125
152
  onDragEnd: ({ active, over }) =>
126
- i18n.t("board.dnd.end", {
127
- task: board.byId.get(String(active.id))?.title ?? "",
128
- lane: over === null ? "" : laneName(String(over.id), board, columns),
129
- }),
153
+ dropped(String(active.id), over === null ? null : String(over.id)),
130
154
  onDragCancel: ({ active }) =>
131
155
  i18n.t("board.dnd.cancel", { task: board.byId.get(String(active.id))?.title ?? "" }),
132
156
  }}
157
+ instructions={i18n.t("board.dnd.instructions")}
133
158
  overlay={(activeId) => {
134
159
  const task = board.byId.get(activeId);
135
160
  return task ? (
136
- <div className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring">
161
+ <div
162
+ data-slot="board-card-overlay"
163
+ className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring"
164
+ >
137
165
  {card(task)}
138
166
  </div>
139
167
  ) : null;
140
168
  }}
141
169
  // ⚠️ Exactly one write per drag, and only when the card actually went somewhere.
142
- // `moveFromDrop` answers `null` for a drop back onto its own place, and the mutation names
143
- // the NEIGHBOURS rather than an index the server mints the order key between them, so one
144
- // drag writes one task and the other cards are not touched (#285).
170
+ // `dropOutcome` answers with something other than a move for a drop back onto its own place
171
+ // and for a lane the board cannot write into, and the mutation names the NEIGHBOURS rather
172
+ // than an index the server mints the order key between them, so one drag writes one task
173
+ // and the other cards are not touched (#285). The same answer is what the reader is told
174
+ // about (anchrd/intel#345), so the sentence and the write can never drift apart.
145
175
  onDrop={(drop) => {
146
- const move = moveFromDrop(drop, lanes, board.byId, writable);
147
- if (move) board.move.mutate(move);
176
+ const outcome = dropOutcome(drop, lanes, board.byId, writable);
177
+ if (outcome.kind === "move") board.move.mutate(outcome.move);
148
178
  }}
149
179
  >
150
180
  {(column) => (
@@ -171,18 +201,27 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
171
201
  <KanbanCard
172
202
  key={task.id}
173
203
  id={task.id}
174
- dragLabel={i18n.t("board.dragCard", { task: task.title })}
204
+ roleDescription={i18n.t("board.card.roleDescription")}
175
205
  >
176
- {/* Two controls, each with a name of its own: the handle beside this one drags,
177
- this one opens. The pointer sensor's four-pixel threshold is what keeps a
178
- click on it from counting as a drag of nothing. */}
179
- <button
180
- type="button"
181
- className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
182
- onClick={() => select(task.id)}
183
- >
184
- {card(task)}
185
- </button>
206
+ {/* ⚠️ ONE control per card, and it means two things click or Enter opens the
207
+ task, Space picks it up to be moved by the arrow keys (anchrd/intel#315).
208
+ `control` is what makes the second half true: it carries dnd-kit's activator
209
+ ref, so the keyboard sensor accepts a key press here and nowhere else in the
210
+ card, and the `aria-roledescription`/`aria-describedby` that say so.
211
+
212
+ The pointer sensor's four-pixel threshold is what keeps a click from counting
213
+ as a drag of nothing. */}
214
+ {({ ref, ...control }) => (
215
+ <button
216
+ type="button"
217
+ ref={ref}
218
+ {...control}
219
+ className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
220
+ onClick={() => select(task.id)}
221
+ >
222
+ {card(task)}
223
+ </button>
224
+ )}
186
225
  </KanbanCard>
187
226
  ))}
188
227
  {(lanes.get(column.id) ?? []).length === 0 ? (
@@ -200,12 +239,19 @@ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
200
239
  );
201
240
  }
202
241
 
242
+ // What stands over a lane: the column's name, and the raw status id for a lane the board no longer
243
+ // lists — the same fallback `labelOf` makes, so one status is not called two things on one screen.
244
+ function columnName(statusId: string, columns: { id: string; name: string }[]): string {
245
+ return columns.find((column) => column.id === statusId)?.name ?? statusId;
246
+ }
247
+
248
+ // What a drag is currently OVER, read as a lane: a card answers with its own lane, a column with
249
+ // itself. Only the running commentary needs this — what a finished drag is announced as comes from
250
+ // the outcome, not from the thing under the pointer (anchrd/intel#345).
203
251
  function laneName(
204
252
  overId: string,
205
253
  board: BoardViewProps["board"],
206
254
  columns: { id: string; name: string }[],
207
255
  ): string {
208
- const task = board.byId.get(overId);
209
- const statusId = task?.status ?? overId;
210
- return columns.find((column) => column.id === statusId)?.name ?? statusId;
256
+ return columnName(board.byId.get(overId)?.status ?? overId, columns);
211
257
  }
@@ -2,6 +2,7 @@ import {
2
2
  type Announcements,
3
3
  closestCorners,
4
4
  DndContext,
5
+ type DraggableAttributes,
5
6
  DragOverlay,
6
7
  type DragStartEvent,
7
8
  KeyboardSensor,
@@ -42,13 +43,14 @@ import { cn } from "@/lib/utils";
42
43
  * ⚠️ **The screen reader announcements are handed in.** The originals are English string literals
43
44
  * in the component; Intel speaks three languages and its text comes from `useI18n()`.
44
45
  *
45
- * ⚠️ **The card is not itself a `role="button"`.** The original spread `useSortable().attributes`
46
- * onto the card, which contributes `role="button"` and `tabIndex={0}` — and a card that also has to
47
- * be OPENED then holds a real button inside that one. Two tab stops computing the same accessible
48
- * name from the same contents, and an Enter that means "start dragging" on the outside and "open
49
- * this" on the inside. Split instead: the card keeps `listeners`, so a pointer can still drag it
50
- * from anywhere, and `attributes` moves to a handle that is the one tab stop for dragging and says
51
- * so.
46
+ * ⚠️ **The card is not itself a `role="button"`, and it holds exactly ONE tab stop.** The original
47
+ * spread `useSortable().attributes` onto the card, which contributes `role="button"` and
48
+ * `tabIndex={0}` — and a card that also has to be OPENED then holds a real button inside that one:
49
+ * two tab stops computing the same accessible name from the same contents, and an Enter that means
50
+ * "start dragging" outside and "open this" inside (anchrd/intel#286). This version keeps the
51
+ * `listeners` on the card so a pointer still drags it from anywhere, and hands the ACTIVATOR to the
52
+ * caller's own control instead of minting a second button beside it (anchrd/intel#315). What
53
+ * separates the two meanings is then the key, not the element: see `KanbanProvider`.
52
54
  */
53
55
 
54
56
  export interface KanbanColumn {
@@ -112,16 +114,33 @@ export function KanbanCards({ id, children }: { id: string; children: ReactNode
112
114
  );
113
115
  }
114
116
 
117
+ /**
118
+ * What a card hands to the one control inside it: the activator ref and dnd-kit's ARIA attributes.
119
+ *
120
+ * ⚠️ It is spread onto a control the CALLER renders, which is the whole point — the alternative is
121
+ * a handle of the primitive's own beside it, and that is the second tab stop anchrd/intel#315 is
122
+ * about. `role` and `tabIndex` are in here and are what a native `<button>` already is; what is not
123
+ * already there is `aria-roledescription` and the `aria-describedby` pointing at dnd-kit's keyboard
124
+ * instructions, and those are the half a reader needs to be told the control can also be picked up.
125
+ */
126
+ export type KanbanCardControl = DraggableAttributes & {
127
+ ref: (node: HTMLElement | null) => void;
128
+ };
129
+
115
130
  export function KanbanCard({
116
131
  id,
117
- dragLabel,
132
+ roleDescription,
118
133
  children,
119
134
  className,
120
135
  }: {
121
136
  id: string;
122
- // What the drag handle is called. Handed in because only the caller knows what the card is of.
123
- dragLabel: string;
124
- children: ReactNode;
137
+ // What a reader is told this card IS, past its accessible name dnd-kit's `aria-roledescription`,
138
+ // whose own default is the English literal "draggable". Handed in because text comes from
139
+ // `useI18n()` and only the caller has it.
140
+ roleDescription: string;
141
+ // A function, not a node: the one control in a card has to receive `control`, and a primitive that
142
+ // rendered its own control instead would be back to two tab stops.
143
+ children: (control: KanbanCardControl) => ReactNode;
125
144
  className?: string;
126
145
  }) {
127
146
  const {
@@ -132,22 +151,24 @@ export function KanbanCard({
132
151
  transition,
133
152
  transform,
134
153
  isDragging,
135
- } = useSortable({ id });
154
+ } = useSortable({ id, attributes: { roleDescription } });
136
155
  return (
137
156
  <div
138
157
  ref={setNodeRef}
139
158
  style={{ transition, transform: CSS.Transform.toString(transform) }}
140
159
  // ⚠️ `listeners` here, `attributes` NOT. The pointer sensor has to see a press anywhere on the
141
160
  // card — a kanban whose cards can only be dragged by a grip is a kanban nobody drags — but
142
- // `attributes` is what carries `role="button"` and `tabIndex`, and those on the card would
143
- // wrap the control that OPENS it in a second button. Pointer drag from the whole card,
144
- // keyboard drag from the handle, one tab stop each.
161
+ // `attributes` carries `role="button"` and `tabIndex`, and those on the card would wrap the
162
+ // control that OPENS it in a second button.
145
163
  //
146
- // ⚠️ The split only holds because the handle registers itself as the ACTIVATOR below. Without
147
- // that, `listeners` on this element make every Enter and Space inside the card a drag: the
148
- // key handler bubbles up from whatever is focused, `KeyboardSensor` accepts it and calls
149
- // `preventDefault()`, and the button that opens the task never sees its own activation.
164
+ // ⚠️ Handing the activator down only holds because something claims to BE it. dnd-kit's
165
+ // `KeyboardSensor` opens with `if (activator && event.target !== activator) return false` a
166
+ // guard that does not exist until `setActivatorNodeRef` has been given a node. Unclaimed, the
167
+ // keyboard sensor accepts a key press from anywhere under these listeners, which is how a card
168
+ // ended up impossible to open by keyboard (anchrd/intel#286). The pointer drag is unaffected
169
+ // either way: `PointerSensor` never consults the activator node.
150
170
  {...listeners}
171
+ data-slot="kanban-card"
151
172
  className={cn(
152
173
  "flex cursor-grab items-start gap-1 rounded-lg border bg-card p-3 text-left shadow-sm",
153
174
  // Left where it was, faded, rather than removed: a lane that reflows while a card is over
@@ -156,35 +177,47 @@ export function KanbanCard({
156
177
  className,
157
178
  )}
158
179
  >
159
- {/* The keyboard half of the drag: `attributes` puts the card into the tab order exactly once,
160
- here, where it has a name of its own that says what it does.
161
-
162
- ⚠️ `setActivatorNodeRef` is what makes that true rather than merely intended. dnd-kit's
163
- `KeyboardSensor` opens with `if (activator && event.target !== activator) return false` —
164
- a guard that only exists once something has claimed to BE the activator. Unclaimed, the
165
- keyboard sensor accepts a key press from anywhere under the listeners, so Enter on the
166
- card's own "open" button started a drag and swallowed the click. The pointer drag is
167
- unaffected: `PointerSensor` never consults the activator node. */}
168
- <button
169
- type="button"
170
- ref={setActivatorNodeRef}
171
- aria-label={dragLabel}
172
- {...listeners}
173
- {...attributes}
174
- className="mt-0.5 shrink-0 cursor-grab rounded text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
175
- >
176
- <GripVertical aria-hidden="true" className="size-4" />
177
- </button>
178
- <div className="min-w-0 flex-1">{children}</div>
180
+ {/* Decoration, and deliberately not a control: it says "this can be taken hold of" to whoever
181
+ uses a pointer, and a pointer can take hold of the whole card anyway. As a button it was a
182
+ tab stop of its own on every card in the lane. */}
183
+ <GripVertical aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
184
+ <div className="min-w-0 flex-1">{children({ ...attributes, ref: setActivatorNodeRef })}</div>
179
185
  </div>
180
186
  );
181
187
  }
182
188
 
189
+ /**
190
+ * ⚠️ Space picks a card up, Enter does not — and that is the whole of anchrd/intel#315.
191
+ *
192
+ * dnd-kit's default is `start: ['Space', 'Enter']`, which forces the activator to be an element of
193
+ * its own: a control that is also the card's "open" button cannot answer both keys with two
194
+ * different things. With the start key narrowed to Space, the two meanings are told apart by the
195
+ * KEY instead of by the element, and a card costs one tab stop rather than two — fifty cards, fifty
196
+ * stops instead of a hundred.
197
+ *
198
+ * ⚠️ `start` is the ONLY list that is narrowed. `end` and `cancel` are dnd-kit's defaults spelled
199
+ * out — Space, Enter and **Tab** put a card down, Escape leaves it where it was — and dropping one
200
+ * of them here is not a smaller change than it looks. Tab is neither an end key nor a cancel key
201
+ * once it is missing from this list, and `defaultKeyboardCoordinateGetter` answers nothing for it,
202
+ * so nothing calls `preventDefault()`: the focus walks on to the next card while the first one is
203
+ * still in the air and still following the arrow keys.
204
+ *
205
+ * Enter stays an END key, and it cannot open the card while ending a move: `handleEnd` calls
206
+ * `preventDefault()` before the browser turns the key press into a click. Somebody holding a card
207
+ * who presses the key they associate with "yes" should put it down, not be told nothing happened.
208
+ */
209
+ const kanbanKeyboardCodes = {
210
+ start: ["Space"],
211
+ cancel: ["Escape"],
212
+ end: ["Space", "Enter", "Tab"],
213
+ };
214
+
183
215
  export function KanbanProvider({
184
216
  columns,
185
217
  itemsByColumn,
186
218
  children,
187
219
  announcements,
220
+ instructions,
188
221
  overlay,
189
222
  onDrop,
190
223
  className,
@@ -196,6 +229,10 @@ export function KanbanProvider({
196
229
  itemsByColumn: Map<string, string[]>;
197
230
  children: (column: KanbanColumn) => ReactNode;
198
231
  announcements: Announcements;
232
+ // ⚠️ Not optional, and not a nicety. dnd-kit's built-in text is an English literal naming the
233
+ // default keys, and this board answers different ones. A card whose `aria-describedby` says
234
+ // "press space or enter" while Enter opens it describes a widget that is not there.
235
+ instructions: string;
199
236
  overlay(activeId: string): ReactNode;
200
237
  onDrop(drop: KanbanDrop): void;
201
238
  className?: string;
@@ -205,12 +242,12 @@ export function KanbanProvider({
205
242
  // ⚠️ A distance before a drag starts, and it is not decoration: without it every click on a
206
243
  // card is a drag of zero pixels, and the card can then no longer be opened by clicking it.
207
244
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
208
- useSensor(KeyboardSensor),
245
+ useSensor(KeyboardSensor, { keyboardCodes: kanbanKeyboardCodes }),
209
246
  );
210
247
 
211
248
  return (
212
249
  <DndContext
213
- accessibility={{ announcements }}
250
+ accessibility={{ announcements, screenReaderInstructions: { draggable: instructions } }}
214
251
  // Corners rather than centres: a tall card over a short one wins on centre distance even
215
252
  // when the pointer is nowhere near it.
216
253
  collisionDetection={closestCorners}
package/src/i18n/de.json CHANGED
@@ -373,6 +373,7 @@
373
373
  "agent.calendarZones": "Jede Zeit steht in der Zeitzone ihres eigenen Zeitplans, denn dann läuft der Agent. Weicht deine eigene Zeitzone ab, steht sie daneben.",
374
374
  "agent.calendarYourTime": "{when} bei dir",
375
375
  "agent.calendarUnreadable": "Keiner der Zeitpläne dieses Agenten lässt sich als Cron-Ausdruck lesen, es ist also nichts geplant.",
376
+ "agent.calendarPaused": "Dieser Agent ist pausiert, keiner dieser Läufe findet statt. Sie zeigen, was er täte, sobald er fortgesetzt wird.",
376
377
  "agent.log": "Läufe",
377
378
  "agent.logEmpty": "Dieser Agent ist noch nicht gelaufen.",
378
379
  "agent.logFailed": "Die Läufe konnten nicht gelesen werden. Vielleicht ist die Agenten-Laufzeit von hier aus nicht erreichbar.",
@@ -507,7 +508,10 @@
507
508
  "board.dnd.start": "{task} aufgenommen",
508
509
  "board.dnd.over": "{task} über {lane}",
509
510
  "board.dnd.end": "{task} in {lane} abgelegt",
511
+ "board.dnd.endUnchanged": "{task} wurde nicht verschoben und steht weiterhin in {lane}",
512
+ "board.dnd.endRefused": "{lane} nimmt keine Karten mehr auf, {task} wurde nicht verschoben und steht weiterhin in {from}",
510
513
  "board.dnd.cancel": "Ablegen von {task} abgebrochen",
511
- "board.dragCard": "{task} ziehen",
514
+ "board.dnd.instructions": "Mit der Eingabetaste diese Aufgabe öffnen. Zum Verschieben stattdessen die Leertaste darauf drücken und sie mit den Pfeiltasten an eine andere Position oder in eine andere Spalte bringen, dann mit Leertaste, Eingabetaste oder Tabulator ablegen oder mit Escape dort lassen, wo sie war.",
515
+ "board.card.roleDescription": "Aufgabe, verschiebbar",
512
516
  "board.statusTerminal": "Gilt als erledigt"
513
517
  }
package/src/i18n/en.json CHANGED
@@ -373,6 +373,7 @@
373
373
  "agent.calendarZones": "Each time is shown in the timezone of its own schedule, because that is when the agent runs. Where your own timezone differs, it is added beside it.",
374
374
  "agent.calendarYourTime": "{when} your time",
375
375
  "agent.calendarUnreadable": "None of this agent's schedules can be read as a cron expression, so nothing is planned.",
376
+ "agent.calendarPaused": "This agent is paused, so none of these runs will happen. They are what it would do once it is resumed.",
376
377
  "agent.log": "Runs",
377
378
  "agent.logEmpty": "This agent has not run yet.",
378
379
  "agent.logFailed": "The runs could not be read. The agent runtime may be unreachable from here.",
@@ -507,7 +508,10 @@
507
508
  "board.dnd.start": "Picked up {task}",
508
509
  "board.dnd.over": "{task} is over {lane}",
509
510
  "board.dnd.end": "Dropped {task} into {lane}",
511
+ "board.dnd.endUnchanged": "{task} was not moved and is still in {lane}",
512
+ "board.dnd.endRefused": "{lane} no longer takes cards, {task} was not moved and is still in {from}",
510
513
  "board.dnd.cancel": "Dropping {task} was cancelled",
511
- "board.dragCard": "Drag {task}",
514
+ "board.dnd.instructions": "Press enter to open this task. To move it instead, press the space bar on it and use the arrow keys to take it to another position or lane, then press space, enter or tab to put it down, or escape to leave it where it was.",
515
+ "board.card.roleDescription": "task, movable",
512
516
  "board.statusTerminal": "Means done"
513
517
  }
package/src/i18n/es.json CHANGED
@@ -373,6 +373,7 @@
373
373
  "agent.calendarZones": "Cada hora se muestra en la zona horaria de su propio horario, porque es cuando se ejecuta el agente. Si tu propia zona horaria difiere, se añade al lado.",
374
374
  "agent.calendarYourTime": "{when} en tu hora",
375
375
  "agent.calendarUnreadable": "Ninguno de los horarios de este agente se puede leer como expresión cron, así que no hay nada planificado.",
376
+ "agent.calendarPaused": "Este agente está en pausa, así que ninguna de estas ejecuciones ocurrirá. Muestran lo que haría al reanudarlo.",
376
377
  "agent.log": "Ejecuciones",
377
378
  "agent.logEmpty": "Este agente todavía no se ha ejecutado.",
378
379
  "agent.logFailed": "Las ejecuciones no se han podido leer. Puede que el entorno de ejecución de agentes no sea accesible desde aquí.",
@@ -507,7 +508,10 @@
507
508
  "board.dnd.start": "{task} tomada",
508
509
  "board.dnd.over": "{task} sobre {lane}",
509
510
  "board.dnd.end": "{task} soltada en {lane}",
511
+ "board.dnd.endUnchanged": "{task} no se movió y sigue en {lane}",
512
+ "board.dnd.endRefused": "{lane} ya no admite tarjetas, {task} no se movió y sigue en {from}",
510
513
  "board.dnd.cancel": "Se canceló soltar {task}",
511
- "board.dragCard": "Arrastrar {task}",
514
+ "board.dnd.instructions": "Pulsa intro para abrir esta tarea. Para moverla, pulsa la barra espaciadora sobre ella y llévala con las flechas a otra posición o columna; después suéltala con espacio, intro o tabulador, o pulsa escape para dejarla donde estaba.",
515
+ "board.card.roleDescription": "tarea, movible",
512
516
  "board.statusTerminal": "Cuenta como hecho"
513
517
  }
@@ -105,8 +105,9 @@ export function NodeTablePanel({ node }: { node: Node }) {
105
105
  <thead className="sticky top-0 bg-card">
106
106
  <tr>
107
107
  {table.data.columns.map((column) => (
108
- // Column names are distinct by contract (`DefineTableInput`), so the
109
- // name is the key and nothing has to fall back to a position.
108
+ // Column names are distinct on every path that writes a table — `DefineTableInput`,
109
+ // `RedefineTableInput` and, since #322, the bundle import, which used to walk past
110
+ // both — so the name is the key and nothing has to fall back to a position.
110
111
  <th
111
112
  key={column}
112
113
  scope="col"
@@ -357,12 +357,32 @@ export function ResourceMenu({
357
357
  <p className="text-sm">{i18n.t("flows.validateReady")}</p>
358
358
  ) : (
359
359
  <ul className="space-y-2">
360
- {validation.problems.map((problem) => (
361
- <li key={problem.code} className="rounded-md border p-3 text-sm">
362
- <span className="block font-medium">
363
- {i18n.t(`flows.problem.${problem.code}`)}
364
- </span>
365
- <span className="mt-1 block text-xs text-muted-foreground">{problem.detail}</span>
360
+ {/* ⚠️ Grouped by code, one entry per reason with every detail under it (#324).
361
+ `validateFlow` files one problem per sub-flow node, so a flow calling two
362
+ unpublished flows — the NORMAL case of that error, not its edge — produced two
363
+ entries whose head sentence is the same line of text, one under the other, and
364
+ two React children under one key.
365
+
366
+ Grouping rather than making the keys unique, because the head sentence is per CODE
367
+ (`flows.problem.<code>` is one string) and repeating it under itself says nothing
368
+ the reader did not just read. What differs is the detail, and every detail is
369
+ still here — which is the promise the comment above makes: somebody with two
370
+ missing tools should not have to ask twice. */}
371
+ {[
372
+ ...validation.problems
373
+ .reduce((byCode, problem) => {
374
+ byCode.set(problem.code, [...(byCode.get(problem.code) ?? []), problem.detail]);
375
+ return byCode;
376
+ }, new Map<string, string[]>())
377
+ .entries(),
378
+ ].map(([code, details]) => (
379
+ <li key={code} className="rounded-md border p-3 text-sm">
380
+ <span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
381
+ {details.map((detail) => (
382
+ <span key={detail} className="mt-1 block text-xs text-muted-foreground">
383
+ {detail}
384
+ </span>
385
+ ))}
366
386
  </li>
367
387
  ))}
368
388
  </ul>