@anchrd/intel-ui 0.46.0 → 0.48.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.46.0",
3
+ "version": "0.48.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.25.0",
36
+ "@anchrd/intel-contract": "^0.27.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -3,6 +3,7 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
3
3
  import {
4
4
  Archive as ArchiveIcon,
5
5
  ChevronsUpDown,
6
+ List,
6
7
  LogOut,
7
8
  RefreshCw,
8
9
  Settings,
@@ -113,6 +114,14 @@ export function UserFooter() {
113
114
  <Wrench aria-hidden="true" />
114
115
  {i18n.t("nav.tools")}
115
116
  </DropdownMenuItem>
117
+ {/* ⚠️ The feed lives here and NOT in the tree, and the placement is the decision
118
+ (#742): it is not a place things are in, and it is not something anybody needs
119
+ twice a day. No counter and no dot on the icon either — a badge is a request for
120
+ attention, and this surface is deliberately not making one. */}
121
+ <DropdownMenuItem onSelect={() => void navigate({ to: "/feed" })}>
122
+ <List aria-hidden="true" />
123
+ {i18n.t("feed.title")}
124
+ </DropdownMenuItem>
116
125
  {/* The archive lives here rather than in the tree: it is not a place things are IN,
117
126
  it is where they went when they left the tree (#113). */}
118
127
  <DropdownMenuItem onSelect={() => void navigate({ to: "/archive" })}>
@@ -26,7 +26,11 @@ interface ArchivedEntry {
26
26
  // Both sides of the tree can be purged (#457) — each through its own door, because node and flow
27
27
  // stay separate everywhere else (ADR-0004).
28
28
  purge(): Promise<{ purged: true; title: string }>;
29
- previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
29
+ previewPurge?(): Promise<{
30
+ inboundLinks: number;
31
+ totalItems: number;
32
+ items: Array<{ id: string; title: string }>;
33
+ }>;
30
34
  }
31
35
 
32
36
  /**
@@ -179,6 +183,8 @@ export function Archive() {
179
183
  // that it does not come back. The state lives here because the row underneath disappears the
180
184
  // moment the purge goes through.
181
185
  const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
186
+ // Whether anything has scrolled away under the header. See the header markup for why.
187
+ const [scrolled, setScrolled] = useState(false);
182
188
  const preview = useQuery({
183
189
  queryKey: ["purge-preview", confirming?.id],
184
190
  queryFn: async () => await confirming?.previewPurge?.(),
@@ -202,13 +208,37 @@ export function Archive() {
202
208
 
203
209
  return (
204
210
  <div className="flex min-h-0 flex-1 flex-col">
205
- <div className="border-b px-6 py-4">
211
+ {/* ⚠️ The rule below the header belongs to the SCROLL STATE, not to the header. While nothing
212
+ has scrolled away, it separates a heading from the content that heading is about, which is
213
+ a line drawn for no reason. It earns its place the moment content slides underneath.
214
+
215
+ The width is set from the start and only the COLOUR changes. Adding the border on scroll
216
+ would grow the header by one pixel at that moment and shove the whole list down — visible,
217
+ and exactly at the point where the eye is already following movement (#741).
218
+
219
+ ⚠️ `motion-reduce:transition-none` is not decoration. Nothing in this repository disarms a
220
+ colour transition for a reader who asked for less movement — there is no global
221
+ `prefers-reduced-motion` rule in the stylesheets — so a fade added here would run for them
222
+ too unless it says otherwise. The line still appears either way; only the fade is dropped. */}
223
+ <div
224
+ data-slot="pane-header"
225
+ className={cn(
226
+ "border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
227
+ scrolled && "border-border",
228
+ )}
229
+ >
206
230
  <h1 className="text-lg font-medium text-foreground">{i18n.t("archive.title")}</h1>
207
231
  <p className="mt-1 max-w-prose text-sm text-muted-foreground">
208
232
  {i18n.t("archive.description")}
209
233
  </p>
210
234
  </div>
211
- <div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
235
+ <div
236
+ data-slot="pane-body"
237
+ // Reading the position off the event rather than holding a ref: React skips the re-render
238
+ // when the boolean has not changed, so a scroll of 400 pixels still costs one render.
239
+ onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
240
+ className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
241
+ >
212
242
  {archived.isError ? (
213
243
  <p role="alert" className="text-sm text-destructive">
214
244
  {i18n.t("archive.failed")}
@@ -315,6 +345,23 @@ export function Archive() {
315
345
  {confirming.previewPurge && preview.data ? (
316
346
  <div className="mt-2 space-y-2 text-sm text-muted-foreground">
317
347
  <p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
348
+ {/* ⚠️ The names, not only the number (D71, #733): what is confirmed here cannot be
349
+ undone, and "82 items" is not something a person can decide about. The list the
350
+ API sends is capped, so whatever it does not carry is counted out loud. */}
351
+ {preview.data.items.length > 0 ? (
352
+ <ul className="max-h-40 list-disc overflow-y-auto pl-5">
353
+ {preview.data.items.map((entry) => (
354
+ <li key={entry.id}>{entry.title}</li>
355
+ ))}
356
+ </ul>
357
+ ) : null}
358
+ {preview.data.totalItems > preview.data.items.length ? (
359
+ <p>
360
+ {i18n.t("archive.purge.items.more", {
361
+ count: preview.data.totalItems - preview.data.items.length,
362
+ })}
363
+ </p>
364
+ ) : null}
318
365
  <p>
319
366
  {preview.data.inboundLinks === 0
320
367
  ? i18n.t("archive.purge.links.none")
@@ -77,7 +77,14 @@ function Card({
77
77
  onClick={() => onOpen(card.id)}
78
78
  // `relative` and `overflow-hidden` are for the depth pattern: it is positioned against this
79
79
  // card, and nothing of it may spill past the rounded edge.
80
- className="relative w-full cursor-grab overflow-hidden rounded-md border border-border bg-card p-2 text-left text-sm shadow-xs focus-visible:outline-2 focus-visible:outline-ring"
80
+ /* ⚠️ A quiet grey FIELD, not a box (#748). It carried `bg-card` plus `border-border` plus
81
+ `shadow-xs` — three means saying the same thing ("this is a card"), and on a white board
82
+ the three together read as a container rather than a surface. `bg-muted` is 0.97 against
83
+ a 1.0 ground in light and 0.269 against 0.145 in dark, so the card stands off its column
84
+ in both without a line around it.
85
+ ⚠️ The hover is what replaces the border: a surface still has to answer the pointer, and
86
+ without a line there is nothing else left to do it. */
87
+ className="relative w-full cursor-grab overflow-hidden rounded-md bg-muted p-2 text-left text-sm transition-colors hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-ring"
81
88
  onKeyDown={(event) => {
82
89
  // ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
83
90
  // not-yet-updated `board.columns`, compute a position independently and both write — the
@@ -585,7 +592,11 @@ export function BoardKanban({
585
592
  cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
586
593
  <DragOverlay>
587
594
  {carriedCard === undefined ? null : (
588
- <div className="w-64 rounded-md border border-border bg-card p-2 text-left text-sm shadow-lg">
595
+ <div /* ⚠️ The same surface as the card it stands for (#748), plus the shadow that is the ONE
596
+ thing this element says beyond it: this one is lifted. A card that changed colour on
597
+ being picked up would read as a different card. */
598
+ className="w-64 rounded-md bg-muted p-2 text-left text-sm shadow-lg"
599
+ >
589
600
  {carriedCard.title}
590
601
  </div>
591
602
  )}
@@ -2,7 +2,6 @@ import type { BoardTask } from "@anchrd/intel-contract/board";
2
2
  import { Lock, Plus, Square, SquareCheck } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
5
- import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
6
5
  import { BoardChip } from "@/board/board-chip/board-chip.tsx";
7
6
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
8
7
  import { DatesChip } from "@/board/board-dates/board-dates.tsx";
@@ -122,17 +121,21 @@ export function BoardTaskDocument({
122
121
  />
123
122
  ))}
124
123
 
125
- <Adder task={task} board={board} write={write} />
124
+ {/* ⚠️ **The last VALUE, and the plus comes after it** (#750, Jack's decision 2026-08-22). It
125
+ is the only round element in the row; standing between the chips it breaks the line. But
126
+ the plus is not a value, it is the control that adds one, and a control belongs behind
127
+ everything it can add to.
126
128
 
127
- {/* ⚠️ **Last, always, and after the plus** (#724). It is the only round element in the row;
128
- standing between the chips it breaks the line. And it is drawn even when nobody has the
129
- card: an empty circle is the one control on a row that says the card COULD be given to
130
- somebody, which is precisely what an unowned card needs. */}
129
+ ⚠️ Drawn even when nobody has the card: an empty circle is the one control on the row
130
+ that says the card COULD be given to somebody, which is precisely what an unowned card
131
+ needs (#724). */}
131
132
  <AssigneeChip
132
133
  id={task.assigneeId}
133
134
  boardId={board.boardId}
134
135
  onPick={(assigneeId) => write({ assigneeId })}
135
136
  />
137
+
138
+ <Adder task={task} board={board} write={write} />
136
139
  </div>
137
140
 
138
141
  {/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
@@ -178,10 +181,11 @@ export function BoardTaskDocument({
178
181
  * is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
179
182
  * a missing one, because it looks like the feature is there (`#700`).
180
183
  */
181
- // ⚠️ `assignee` is first, and that is Jacks Vorgabe 2026-08-21 read back into the menu: on a card
182
- // the assignee chip stands LAST so the round element does not break the row, while in the menu the
183
- // most-used entry stands first. The two orders answer different questions and are not a mismatch.
184
- const addable = ["assignee", "label", "start", "due"] as const;
184
+ // ⚠️ **No `assignee` here** (#750). It stood first once, back when the circle was drawn only for
185
+ // somebody and an unowned card had nothing to press. Since #724 the circle is always there and opens
186
+ // the same picker, so an entry here would be a second way to the same thing, two lines apart — and
187
+ // the one somebody finds first would depend on where they happened to look.
188
+ const addable = ["label", "start", "due"] as const;
185
189
  const linkable = ["subtask", "blocker"] as const;
186
190
  type Addable = (typeof addable)[number] | (typeof linkable)[number];
187
191
 
@@ -271,27 +275,7 @@ function Adder({
271
275
  </DropdownMenuContent>
272
276
  </DropdownMenu>
273
277
 
274
- {kind !== "assignee" ? null : (
275
- <AssigneePicker
276
- boardId={board.boardId}
277
- current={task.assigneeId}
278
- onPick={(assigneeId) => write({ assigneeId })}
279
- open
280
- onOpenChange={(next) => !next && close()}
281
- >
282
- {/* ⚠️ The anchor. Opened from the plus menu there is nothing on screen to hang the menu
283
- on, so the picker brings its own pill — the same shape the blocker branch below uses,
284
- and for the same reason. */}
285
- <button
286
- type="button"
287
- className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
288
- >
289
- {i18n.t("board.add.assignee")}
290
- </button>
291
- </AssigneePicker>
292
- )}
293
-
294
- {kind === null || kind === "blocker" || kind === "assignee" ? null : (
278
+ {kind === null || kind === "blocker" ? null : (
295
279
  <input
296
280
  // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
297
281
  autoFocus
@@ -8,6 +8,7 @@ import {
8
8
  BoardView,
9
9
  } from "@anchrd/intel-contract/board";
10
10
  import { BundleImportResult } from "@anchrd/intel-contract/bundle";
11
+ import { FeedListResponse } from "@anchrd/intel-contract/feed";
11
12
  import {
12
13
  ArchiveFlowInput,
13
14
  CreateFlowInput,
@@ -580,6 +581,14 @@ export function createIntelDataProvider(
580
581
  body: JSON.stringify(parsed),
581
582
  });
582
583
  },
584
+ async listFeed(input) {
585
+ // Only what was asked for travels: an empty `actor` or `before` in the query string is not
586
+ // the same as leaving it out, and the contract refuses the empty string.
587
+ const params = new URLSearchParams({ limit: String(input.limit) });
588
+ if (input.actor) params.set("actor", input.actor);
589
+ if (input.before) params.set("before", input.before);
590
+ return await request(`/feed?${params}`, FeedListResponse);
591
+ },
583
592
  async listFlowRuns(input) {
584
593
  const parsed = ListFlowRunsInput.parse(input);
585
594
  // Only what was actually asked for travels. `failedOnly` is the parameter's presence rather
@@ -8,6 +8,7 @@ import type {
8
8
  BoardView,
9
9
  } from "@anchrd/intel-contract/board";
10
10
  import type { BundleImportResult } from "@anchrd/intel-contract/bundle";
11
+ import type { FeedListResponse } from "@anchrd/intel-contract/feed";
11
12
  import type {
12
13
  ArchiveFlowInput,
13
14
  CreateFlowInput,
@@ -235,6 +236,13 @@ export interface IntelDataProvider {
235
236
  // What this flow has done, newest first, one page at a time. ⚠️ It carries what a run did and
236
237
  // never what it produced: a run reads nodes with the rights of whoever started it, so its
237
238
  // result is not automatically readable for everyone who may read the flow.
239
+ // The activity feed, newest first (#742). `before` is the previous answer's `nextCursor`,
240
+ // handed back unchanged; `actor` narrows it to one person or one agent.
241
+ listFeed(input: {
242
+ limit: number;
243
+ before?: string | undefined;
244
+ actor?: string | undefined;
245
+ }): Promise<FeedListResponse>;
238
246
  listFlowRuns(input: ListFlowRunsInput): Promise<FlowRunList>;
239
247
  getFlowRun(runId: string): Promise<FlowRunStep>;
240
248
  // The drill-down behind one run: every step it took, and the call chain it belongs to.
@@ -0,0 +1,312 @@
1
+ import type { FeedEvent } from "@anchrd/intel-contract/feed";
2
+ import { useInfiniteQuery, useQueries } from "@tanstack/react-query";
3
+ import { useNavigate } from "@tanstack/react-router";
4
+ import { Check, Filter } from "lucide-react";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuLabel,
11
+ DropdownMenuTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import { useI18n } from "@/i18n/i18n-context.tsx";
14
+ import { cn } from "@/lib/utils.ts";
15
+ import { useIntelRouterContext } from "@/router/router-context.ts";
16
+ import { RelativeTime } from "@/time/relative-time.tsx";
17
+ import { initials, useSessionUser } from "@/user-name/user-name.ts";
18
+
19
+ const PageSize = 30;
20
+
21
+ // Who to show. Only the first two reach the server; `people` is applied here because whether an
22
+ // actor is a machine is Gate's answer and arrives with the names, not with the events.
23
+ type Audience = "all" | "mine" | "people";
24
+
25
+ /**
26
+ * What happened, newest first, one card per event.
27
+ *
28
+ * ⚠️ **The shape is the archive's** — a header with a rule that appears on scroll, then the
29
+ * scrolling area — because this is a page of the same kind and a second layout for it would be a
30
+ * second set of answers to "where does the title go". What differs is inside: the list is capped
31
+ * and centred rather than full width.
32
+ */
33
+ export function Feed() {
34
+ const { data } = useIntelRouterContext();
35
+ const i18n = useI18n();
36
+ const me = useSessionUser();
37
+ const navigate = useNavigate();
38
+ const [audience, setAudience] = useState<Audience>("all");
39
+ const [scrolled, setScrolled] = useState(false);
40
+
41
+ // ⚠️ The audience belongs in the key. "Only mine" is a different question with a different
42
+ // answer, and sharing one cache entry would show the wrong list for a frame after the switch.
43
+ //
44
+ // ⚠️ `mine` reaches the server, `people` does not: the server has no idea which actor is a
45
+ // machine. Putting `people` in the key anyway would split the cache for a filter that changes
46
+ // nothing about the request — so it is deliberately absent from what travels.
47
+ const actor = audience === "mine" ? (me?.id ?? null) : null;
48
+ const feed = useInfiniteQuery({
49
+ queryKey: ["feed", actor],
50
+ queryFn: ({ pageParam }) =>
51
+ data.listFeed({
52
+ limit: PageSize,
53
+ ...(pageParam ? { before: pageParam } : {}),
54
+ ...(actor ? { actor } : {}),
55
+ }),
56
+ initialPageParam: null as string | null,
57
+ getNextPageParam: (last) => last.nextCursor,
58
+ // Asking with `actor: null` for "only mine" would be the whole feed, which is the opposite of
59
+ // what was asked for. Better to render nothing for the instant before the session arrives.
60
+ enabled: audience !== "mine" || me !== null,
61
+ });
62
+
63
+ const events = useMemo(() => feed.data?.pages.flatMap((page) => page.events) ?? [], [feed.data]);
64
+
65
+ // ⚠️ One resolve per PAGE, and PER page rather than over everything loaded so far. A page is
66
+ // thirty cards and often three people, so resolving a single id per card would open thirty
67
+ // requests for three names — that much is obvious. The half that is not: asking again for the
68
+ // GROWING union on every page walks straight into the contract's `max(100)` and takes the endless
69
+ // scroll down with a Zod error, on exactly the busy folder the feed is for.
70
+ //
71
+ // One query per page keeps every ask at most a page wide, and TanStack keeps the earlier answers
72
+ // rather than re-fetching them.
73
+ const nameQueries = useQueries({
74
+ queries: (feed.data?.pages ?? []).map((page) => {
75
+ const ids = [...new Set(page.events.map((entry) => entry.actorId))].sort();
76
+ return {
77
+ queryKey: ["feed-actor-names", ids],
78
+ // ⚠️ Never reached with an empty list: the contract refuses it (`min(1)`), and the refusal
79
+ // would surface as a broken page rather than as the empty answer it means.
80
+ enabled: ids.length > 0,
81
+ queryFn: () => data.resolveBoardAssignees(ids),
82
+ staleTime: 5 * 60 * 1000,
83
+ };
84
+ }),
85
+ });
86
+ // ⚠️ Built on every render rather than memoised, and the first attempt at this is why it says
87
+ // so: keyed on `feed.data` the map was never rebuilt when the NAMES arrived, because the pages
88
+ // had not changed — every card showed "a user account" for good. A few dozen entries cost
89
+ // nothing to rebuild, and a dependency that has to be exactly right to be correct is a worse
90
+ // trade than the work it saves.
91
+ const people = new Map<string, { name: string; isMachine: boolean }>();
92
+ for (const query of nameQueries) {
93
+ for (const person of query.data?.items ?? []) {
94
+ people.set(person.id, { name: person.name, isMachine: person.isMachine });
95
+ }
96
+ }
97
+
98
+ // ⚠️ Filtering people out here can empty a whole page while older ones still hold cards, so the
99
+ // sentinel below has to keep asking rather than stopping at the first empty result.
100
+ const shown =
101
+ audience === "people"
102
+ ? events.filter((event) => people.get(event.actorId)?.isMachine !== true)
103
+ : events;
104
+
105
+ const sentinel = useRef<HTMLDivElement | null>(null);
106
+ useEffect(() => {
107
+ const element = sentinel.current;
108
+ if (!element) return;
109
+ const observer = new IntersectionObserver((entries) => {
110
+ // `hasNextPage` and `isFetchingNextPage` are read inside the callback rather than closed over
111
+ // at setup: the observer outlives several renders, and a stale `false` would end the scroll
112
+ // silently at whatever page happened to be loaded when it was created.
113
+ if (entries.some((entry) => entry.isIntersecting)) void feed.fetchNextPage();
114
+ });
115
+ observer.observe(element);
116
+ return () => observer.disconnect();
117
+ }, [feed.fetchNextPage]);
118
+
119
+ return (
120
+ <div className="flex min-h-0 flex-1 flex-col">
121
+ {/* The rule belongs to the scroll state, and the width is set from the start so that showing
122
+ it moves nothing (#741). */}
123
+ <div
124
+ data-slot="pane-header"
125
+ className={cn(
126
+ "flex items-start justify-between gap-4 border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
127
+ scrolled && "border-border",
128
+ )}
129
+ >
130
+ <div>
131
+ <h1 className="text-lg font-medium text-foreground">{i18n.t("feed.title")}</h1>
132
+ <p className="mt-1 max-w-prose text-sm text-muted-foreground">
133
+ {i18n.t("feed.description")}
134
+ </p>
135
+ </div>
136
+ <DropdownMenu>
137
+ <DropdownMenuTrigger
138
+ aria-label={i18n.t("feed.filter")}
139
+ className="rounded-md p-1 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
140
+ >
141
+ <Filter aria-hidden="true" className="size-4" />
142
+ </DropdownMenuTrigger>
143
+ <DropdownMenuContent align="end">
144
+ <DropdownMenuLabel>{i18n.t("feed.filter")}</DropdownMenuLabel>
145
+ {(["all", "mine", "people"] as const).map((option) => (
146
+ <DropdownMenuItem
147
+ key={option}
148
+ onSelect={() => setAudience(option)}
149
+ className="gap-2"
150
+ data-slot={`feed-audience-${option}`}
151
+ aria-current={audience === option}
152
+ >
153
+ <Check
154
+ aria-hidden="true"
155
+ className={cn("size-4", audience !== option && "invisible")}
156
+ />
157
+ {i18n.t(`feed.audience.${option}`)}
158
+ </DropdownMenuItem>
159
+ ))}
160
+ </DropdownMenuContent>
161
+ </DropdownMenu>
162
+ </div>
163
+
164
+ <div
165
+ data-slot="pane-body"
166
+ onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
167
+ className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
168
+ >
169
+ <div className="mx-auto max-w-3xl">
170
+ {feed.isPending && (
171
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
172
+ )}
173
+ {feed.isError && (
174
+ <div role="alert" className="space-y-3 text-sm">
175
+ <p className="text-destructive">{i18n.t("feed.failed")}</p>
176
+ <button
177
+ type="button"
178
+ onClick={() => void feed.refetch()}
179
+ className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
180
+ >
181
+ {i18n.t("common.retry")}
182
+ </button>
183
+ </div>
184
+ )}
185
+ {feed.data && shown.length === 0 && (
186
+ <div className="space-y-2 text-sm text-muted-foreground">
187
+ <p>{i18n.t("feed.empty")}</p>
188
+ {/* ⚠️ Two states look identical and are not, so the second one is said out loud. A
189
+ feed showing only yourself is what sharing nothing with anybody looks like, and a
190
+ reader who takes it for a fault will go looking for one that is not there. */}
191
+ <p>{i18n.t("feed.emptyWhy")}</p>
192
+ {/* ⚠️ The SECOND state the ticket names, and it is not an empty list: it is a gap
193
+ inside a list that looks complete. Somebody whose read access to a folder was
194
+ withdrawn loses their OWN older entries from it, and without this sentence the
195
+ next reader goes looking for a fault that is not there — or loosens the filter
196
+ and opens the authorization while "repairing" it. */}
197
+ <p>{i18n.t("feed.gapWhy")}</p>
198
+ </div>
199
+ )}
200
+ {shown.length > 0 && (
201
+ <ul className="flex flex-col gap-2">
202
+ {shown.map((event) => (
203
+ <FeedCard
204
+ key={event.id}
205
+ event={event}
206
+ person={people.get(event.actorId) ?? null}
207
+ isMe={event.actorId === me?.id}
208
+ open={(nodeId) => void navigate({ to: "/nodes", search: { select: nodeId } })}
209
+ />
210
+ ))}
211
+ </ul>
212
+ )}
213
+ {/* The end of the list, and what keeps it going. There is no button: the answer to "is
214
+ there more" is the cursor, and the observer above asks for it. */}
215
+ <div ref={sentinel} aria-hidden="true" className="h-8" />
216
+ {feed.isFetchingNextPage && (
217
+ <p className="py-2 text-center text-xs text-muted-foreground">
218
+ {i18n.t("common.loading")}
219
+ </p>
220
+ )}
221
+ {feed.data && !feed.hasNextPage && shown.length > 0 && (
222
+ <div className="space-y-1 py-2 text-center text-xs text-muted-foreground">
223
+ <p>{i18n.t("feed.end")}</p>
224
+ {/* The same sentence at the other end: this is where somebody notices that an older
225
+ entry of their own is missing, and where the reason belongs. */}
226
+ <p>{i18n.t("feed.gapWhy")}</p>
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function FeedCard({
236
+ event,
237
+ person,
238
+ isMe,
239
+ open,
240
+ }: {
241
+ event: FeedEvent;
242
+ person: { name: string; isMachine: boolean } | null;
243
+ isMe: boolean;
244
+ open(nodeId: string): void;
245
+ }) {
246
+ const i18n = useI18n();
247
+ // ⚠️ Never the raw id. A person we cannot name is "a user account", which is the wording every
248
+ // other surface uses for the same gap (#258) — an id under a name is the question again in
249
+ // smaller type.
250
+ const who = isMe ? i18n.t("feed.you") : (person?.name ?? i18n.t("node.someUser"));
251
+ // What the button on this card says, and it is not always a version. A `node.save` records
252
+ // `{versionId, sequence}`, a `node.share` records `{principalType, verb}` — so a sharing card
253
+ // names the RIGHT that was granted. Both lead to the node: there is no route to a single version
254
+ // in this interface, and the access summary lives on the node itself (#742).
255
+ const sequence = typeof event.metadata.sequence === "number" ? event.metadata.sequence : null;
256
+ const verb = typeof event.metadata.verb === "string" ? event.metadata.verb : null;
257
+ const mark =
258
+ sequence !== null
259
+ ? i18n.t("feed.version", { sequence: String(sequence) })
260
+ : verb !== null
261
+ ? i18n.t(`node.verb.${verb}`)
262
+ : null;
263
+
264
+ return (
265
+ <li className="flex items-start gap-3 rounded-lg bg-muted/40 px-4 py-3">
266
+ <span
267
+ aria-hidden="true"
268
+ className={cn(
269
+ "grid size-8 shrink-0 place-items-center text-xs font-medium",
270
+ // A square for an agent, a circle for a person: the two are told apart before either
271
+ // name is read.
272
+ person?.isMachine
273
+ ? "rounded-md bg-accent text-accent-foreground"
274
+ : "rounded-full bg-secondary text-secondary-foreground",
275
+ )}
276
+ >
277
+ {initials(person?.name ?? who)}
278
+ </span>
279
+ <span className="grid min-w-0 flex-1 gap-1">
280
+ <span className="text-sm leading-snug">
281
+ {i18n.t(`feed.action.${event.action}`, { who, title: event.nodeTitle })}
282
+ </span>
283
+ <span className="flex items-center justify-between gap-3">
284
+ <span className="truncate text-xs text-muted-foreground">
285
+ {event.path.map((step) => step.title).join(" / ")}
286
+ </span>
287
+ {/* ⚠️ A button, not a label. The whole point of the mark is getting there, and a span
288
+ carries no focus — so the keyboard requirement could not be met by a surface that had
289
+ nothing to focus at all.
290
+
291
+ ⚠️ It opens the NODE in both cases. There is no route to a single version here, and
292
+ the access summary lives on the node; the mark still says WHICH version or WHICH
293
+ right the card was about, which is the part the reader came for. */}
294
+ {mark !== null && (
295
+ <button
296
+ type="button"
297
+ onClick={() => open(event.nodeId)}
298
+ aria-label={i18n.t("feed.open", { title: event.nodeTitle, mark })}
299
+ className="shrink-0 rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring"
300
+ >
301
+ {mark}
302
+ </button>
303
+ )}
304
+ </span>
305
+ {/* The shared component, not a second way of saying the same thing: it carries the exact
306
+ moment in a tooltip that is reachable by keyboard, and "19 hours ago" is not something
307
+ anybody translates back into a time of day. */}
308
+ <RelativeTime value={event.occurredAt} className="text-xs text-muted-foreground" />
309
+ </span>
310
+ </li>
311
+ );
312
+ }
package/src/i18n/de.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "archive.purge.confirm": "Endgültig löschen",
12
12
  "archive.purge.grants": "Die Freigaben werden ebenfalls gelöscht.",
13
13
  "archive.purge.items": "Insgesamt endgültig gelöscht: {count}.",
14
+ "archive.purge.items.more": "Hier nicht aufgeführt: {count}.",
14
15
  "archive.purge.links.many": "{count} andere Dokumente verweisen darauf; diese Verweise werden brechen.",
15
16
  "archive.purge.links.none": "Keine anderen Dokumente verweisen darauf.",
16
17
  "archive.purge.links.one": "Ein anderes Dokument verweist darauf; dieser Verweis wird brechen.",
@@ -76,7 +77,6 @@
76
77
  "auth.signOut": "Abmelden",
77
78
  "auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
78
79
  "board.add": "Etwas hinzufügen",
79
- "board.add.assignee": "Zuständigkeit",
80
80
  "board.add.due": "Fällig",
81
81
  "board.add.label": "Label",
82
82
  "board.add.start": "Start",
@@ -180,6 +180,27 @@
180
180
  "common.unsavedLeave": "Ohne Speichern verlassen",
181
181
  "common.unsavedStay": "Bleiben und speichern",
182
182
  "common.unsavedTitle": "Ungespeicherte Änderungen",
183
+ "feed.action.node.append": "{who} hat etwas an {title} angehängt",
184
+ "feed.action.node.archive": "{who} hat {title} archiviert",
185
+ "feed.action.node.create": "{who} hat {title} angelegt",
186
+ "feed.action.node.revoke": "{who} hat eine Freigabe für {title} entzogen",
187
+ "feed.action.node.save": "{who} hat {title} geändert",
188
+ "feed.action.node.share": "{who} hat {title} freigegeben",
189
+ "feed.action.node.update": "{who} hat {title} umbenannt",
190
+ "feed.audience.all": "Alle",
191
+ "feed.audience.mine": "Nur ich",
192
+ "feed.audience.people": "Ohne Agenten",
193
+ "feed.description": "Was zuletzt passiert ist, im Umfang deiner Freigaben.",
194
+ "feed.empty": "Hier ist noch nichts.",
195
+ "feed.emptyWhy": "Der Feed zeigt nur, was an Knoten passiert, die du lesen darfst. Teilst du mit niemandem eine Freigabe, stehst nur du selbst darin.",
196
+ "feed.end": "Das war alles.",
197
+ "feed.failed": "Der Feed konnte nicht geladen werden.",
198
+ "feed.filter": "Wen zeigen",
199
+ "feed.gapWhy": "Wurde dir das Leserecht auf einen Ordner entzogen, fehlen von dort auch deine eigenen älteren Einträge.",
200
+ "feed.open": "{title} öffnen ({mark})",
201
+ "feed.title": "Feed",
202
+ "feed.version": "v{sequence}",
203
+ "feed.you": "Du",
183
204
  "flow.shareUnreadable": "Diese Freigabe deckt nicht ab, was dieser Flow liest: {titles}.",
184
205
  "flow.shareUnreadableHidden": "Diese Freigabe deckt nicht alles ab, was dieser Flow liest: {count} davon kannst du nicht sehen.",
185
206
  "flow.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf hält für sie schlicht an dieser Stelle an.",
package/src/i18n/en.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "archive.purge.confirm": "Delete for good",
12
12
  "archive.purge.grants": "Its shares will also be deleted.",
13
13
  "archive.purge.items": "Deleted for good in total: {count}.",
14
+ "archive.purge.items.more": "Not listed here: {count}.",
14
15
  "archive.purge.links.many": "{count} other documents link to it and those links will break.",
15
16
  "archive.purge.links.none": "No other documents link to it.",
16
17
  "archive.purge.links.one": "One other document links to it and that link will break.",
@@ -76,7 +77,6 @@
76
77
  "auth.signOut": "Sign out",
77
78
  "auth.signOutFailed": "Signing out failed. Check your connection and try again.",
78
79
  "board.add": "Add something",
79
- "board.add.assignee": "Assignee",
80
80
  "board.add.due": "Due",
81
81
  "board.add.label": "Label",
82
82
  "board.add.start": "Start",
@@ -180,6 +180,27 @@
180
180
  "common.unsavedLeave": "Leave without saving",
181
181
  "common.unsavedStay": "Stay and save",
182
182
  "common.unsavedTitle": "Unsaved changes",
183
+ "feed.action.node.append": "{who} appended to {title}",
184
+ "feed.action.node.archive": "{who} archived {title}",
185
+ "feed.action.node.create": "{who} created {title}",
186
+ "feed.action.node.revoke": "{who} withdrew access to {title}",
187
+ "feed.action.node.save": "{who} changed {title}",
188
+ "feed.action.node.share": "{who} shared {title}",
189
+ "feed.action.node.update": "{who} renamed {title}",
190
+ "feed.audience.all": "Everyone",
191
+ "feed.audience.mine": "Only me",
192
+ "feed.audience.people": "Without agents",
193
+ "feed.description": "What happened recently, as far as your access reaches.",
194
+ "feed.empty": "Nothing here yet.",
195
+ "feed.emptyWhy": "The feed only shows what happens to nodes you may read. If you share access with nobody, only your own work appears.",
196
+ "feed.end": "That is everything.",
197
+ "feed.failed": "The feed could not be loaded.",
198
+ "feed.filter": "Who to show",
199
+ "feed.gapWhy": "If your read access to a folder was withdrawn, your own older entries from it are missing too.",
200
+ "feed.open": "Open {title} ({mark})",
201
+ "feed.title": "Feed",
202
+ "feed.version": "v{sequence}",
203
+ "feed.you": "You",
183
204
  "flow.shareUnreadable": "This grant does not cover what this flow reads: {titles}.",
184
205
  "flow.shareUnreadableHidden": "This grant does not cover everything this flow reads: {count} you cannot see.",
185
206
  "flow.shareUnreadableHint": "Nothing is blocked. A run will simply stop at that step for them.",
package/src/i18n/es.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "archive.purge.confirm": "Eliminar definitivamente",
12
12
  "archive.purge.grants": "Sus permisos compartidos también se eliminarán.",
13
13
  "archive.purge.items": "Eliminado definitivamente en total: {count}.",
14
+ "archive.purge.items.more": "No se enumeran aquí: {count}.",
14
15
  "archive.purge.links.many": "Otros {count} documentos contienen enlaces a este elemento y dejarán de funcionar.",
15
16
  "archive.purge.links.none": "Ningún otro documento contiene un enlace a este elemento.",
16
17
  "archive.purge.links.one": "Otro documento contiene un enlace a este elemento y ese enlace dejará de funcionar.",
@@ -76,7 +77,6 @@
76
77
  "auth.signOut": "Cerrar sesión",
77
78
  "auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
78
79
  "board.add": "Añadir algo",
79
- "board.add.assignee": "Responsable",
80
80
  "board.add.due": "Vencimiento",
81
81
  "board.add.label": "Etiqueta",
82
82
  "board.add.start": "Inicio",
@@ -180,6 +180,27 @@
180
180
  "common.unsavedLeave": "Salir sin guardar",
181
181
  "common.unsavedStay": "Quedarse y guardar",
182
182
  "common.unsavedTitle": "Cambios sin guardar",
183
+ "feed.action.node.append": "{who} añadió algo a {title}",
184
+ "feed.action.node.archive": "{who} archivó {title}",
185
+ "feed.action.node.create": "{who} creó {title}",
186
+ "feed.action.node.revoke": "{who} retiró el acceso a {title}",
187
+ "feed.action.node.save": "{who} cambió {title}",
188
+ "feed.action.node.share": "{who} compartió {title}",
189
+ "feed.action.node.update": "{who} renombró {title}",
190
+ "feed.audience.all": "Todos",
191
+ "feed.audience.mine": "Solo yo",
192
+ "feed.audience.people": "Sin agentes",
193
+ "feed.description": "Lo que ha pasado, hasta donde llega tu acceso.",
194
+ "feed.empty": "Aquí todavía no hay nada.",
195
+ "feed.emptyWhy": "La actividad solo muestra lo que ocurre en nodos que puedes leer. Si no compartes acceso con nadie, solo aparece tu propio trabajo.",
196
+ "feed.end": "Eso es todo.",
197
+ "feed.failed": "No se pudo cargar la actividad.",
198
+ "feed.filter": "A quién mostrar",
199
+ "feed.gapWhy": "Si te retiraron el acceso de lectura a una carpeta, tus propias entradas antiguas de allí también faltan.",
200
+ "feed.open": "Abrir {title} ({mark})",
201
+ "feed.title": "Actividad",
202
+ "feed.version": "v{sequence}",
203
+ "feed.you": "Tú",
183
204
  "flow.shareUnreadable": "Este acceso no cubre lo que lee este flujo: {titles}.",
184
205
  "flow.shareUnreadableHidden": "Este acceso no cubre todo lo que lee este flujo: {count} que no puedes ver.",
185
206
  "flow.shareUnreadableHint": "No se bloquea nada. Una ejecución simplemente se detendrá en ese paso para esa persona.",
@@ -27,6 +27,12 @@ const flowsRoute = createRoute({
27
27
  validateSearch: selectionSearch,
28
28
  component: lazyRouteComponent(() => import("@/flows/flows.tsx"), "Flows"),
29
29
  });
30
+ const feedRoute = createRoute({
31
+ getParentRoute: () => rootRoute,
32
+ path: "/feed",
33
+ component: lazyRouteComponent(() => import("@/feed/feed.tsx"), "Feed"),
34
+ });
35
+
30
36
  const archiveRoute = createRoute({
31
37
  getParentRoute: () => rootRoute,
32
38
  path: "/archive",
@@ -44,6 +50,7 @@ const routeTree = rootRoute.addChildren([
44
50
  nodesRoute,
45
51
  flowsRoute,
46
52
  toolsRoute,
53
+ feedRoute,
47
54
  archiveRoute,
48
55
  ]);
49
56