@startsimpli/ui 0.4.134 → 0.4.136

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.
@@ -387,6 +387,94 @@ export function applyDecisionToData(
387
387
  return declaredBlob(next, declaredNames);
388
388
  }
389
389
 
390
+ /** True when a status name reads like the END of a pipeline (published/written/sent). */
391
+ export function isTerminalStatus(status: string): boolean {
392
+ return TERMINAL_RE.test(status);
393
+ }
394
+
395
+ /**
396
+ * Which review decision a record's STATE currently reflects — the key of the
397
+ * button that should render as active, or null when nobody has decided yet.
398
+ *
399
+ * STATUS IS THE DECISION; THE VERDICT IS NOT (bd startsim-m7fdm.12). This used
400
+ * to resolve by verdict first wherever a type declared one, and on OGMC's topics
401
+ * that made the machine's opinion render as the human's: the n8n re-rank agent
402
+ * writes `team_verdict` WITHOUT writing status — deliberately, because
403
+ * startsim-ozpjw.8 stopped an LLM being able to push a record to `ready` on its
404
+ * own say-so — so 20 of 171 live topics sat at `suggested` while the table drew
405
+ * a darkened green tick over them. A reviewer reads that as "I approved this".
406
+ * Nothing had been approved, and a topic at `suggested` is invisible to the
407
+ * auto-write poll, so those twenty looked done and would never be written.
408
+ *
409
+ * The rule, therefore: WHERE A TYPE HAS A STATUS PIPELINE, ONLY STATUS DECIDES
410
+ * what renders as in force. Status is the one field a human's click writes that
411
+ * the pipeline agents do not, so it is the only honest record of a human
412
+ * decision — and that invariant is what guarantees no machine-set verdict can
413
+ * light any button. `team_verdict` keeps being written (it is the re-rank
414
+ * agent's input) and keeps being READABLE as its own column; it just stops
415
+ * impersonating an approval.
416
+ *
417
+ * Three cases, resolved in this order so the answer never depends on the order a
418
+ * caller happens to hold its decisions in:
419
+ *
420
+ * 1. EXACT, past the entry stage — the record's status is where some decision
421
+ * puts it, and that status exists only because a decision wrote it.
422
+ * 2. BACK AT THE ENTRY STAGE — a "Needs work" writes status back to the stage
423
+ * every record starts on, so it leaves nothing status can tell apart from
424
+ * "untouched". On a verdict-bearing type that is rendered as NO decision:
425
+ * the agent writes the `edit` verdict too ("RESOLVED from edit -> good"), so
426
+ * trusting it here would just move the same lie from the green tick to the
427
+ * amber one. On a type with no verdict vocabulary at all (news_item, which
428
+ * passes `verdicts: []`) the status match is all there is, and stands.
429
+ * 3. FORWARD, PAST APPROVAL, TO A TERMINAL STAGE — `ready → written`. The
430
+ * approval is still the decision in force; a written topic rendering as
431
+ * undecided would be the same lie pointing the other way, and with the State
432
+ * column off by default (bd startsim-m7fdm.11) the tick is the only state a
433
+ * row shows. Only TERMINAL stages count: news_item declares `ignored` and
434
+ * `preview_only` after `acceptable`, and neither is a consequence of
435
+ * accepting anything.
436
+ *
437
+ * A type with no status enum at all keeps resolving by verdict — there, nothing
438
+ * but the buttons themselves writes one, so the verdict IS the decision.
439
+ */
440
+ export function activeDecisionKey(
441
+ data: Record<string, unknown> | undefined,
442
+ r: ResolvedReview,
443
+ decisions: ReviewDecision[] = reviewDecisions(r),
444
+ ): string | null {
445
+ const choices = r.statusChoices;
446
+
447
+ // No status pipeline → the verdict is the only place a decision is recorded.
448
+ if (choices.length === 0) {
449
+ const v = fieldStr(data, r.verdictAttr);
450
+ return v ? decisions.find((d) => d.verdict && d.verdict === v)?.key ?? null : null;
451
+ }
452
+
453
+ const status = fieldStr(data, r.statusName);
454
+ const entry = choices[0];
455
+
456
+ // (1) a status past the entry stage is there because a decision wrote it.
457
+ const exact = decisions.find((d) => !!d.status && d.status !== entry && d.status === status);
458
+ if (exact) return exact.key;
459
+
460
+ // (2) back at the entry stage — indistinguishable from untouched, unless the
461
+ // type carries no verdict vocabulary and the status match is all there is.
462
+ const sentBack = decisions.find((d) => !!d.status && d.status === entry);
463
+ if (sentBack && status === entry) return sentBack.verdict ? null : sentBack.key;
464
+
465
+ // (3) forward, past approval, to a terminal stage: still approved.
466
+ const approve = r.transitions.approve
467
+ ? decisions.find((d) => d.status === r.transitions.approve)
468
+ : undefined;
469
+ if (approve) {
470
+ const at = choices.indexOf(status);
471
+ const approveAt = choices.indexOf(r.transitions.approve as string);
472
+ const rejectAt = r.transitions.reject ? choices.indexOf(r.transitions.reject) : -1;
473
+ if (at > approveAt && at !== rejectAt && isTerminalStatus(status)) return approve.key;
474
+ }
475
+ return null;
476
+ }
477
+
390
478
  // ---- small read helpers used by the component (kept here so they're tested) ----
391
479
 
392
480
  /** String read of an attr off a record's data blob ('' when missing). */
@@ -0,0 +1,303 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * RecordHistoryPanel — who changed which field, from what, to what, and whether
5
+ * a machine did it (bd startsim-g4rwo; trail bd startsim-o1qib).
6
+ *
7
+ * The customer's ask, in their words: "similar to google sheets we need to be
8
+ * able to see who editted what piece of the text and when". This is that
9
+ * surface. It reads `/api/v1/entities/<id>/revisions/` through an injected
10
+ * client, folds the autosave noise in the browser, and renders each sitting
11
+ * through the shared `ActivityTimeline` — the same primitive, and the same
12
+ * conventions, as the control plane's foundry activity stream, rather than a
13
+ * second timeline (rule #9).
14
+ *
15
+ * CLIENT-INJECTED, exactly like `ActivityFeedPanel`: the app passes
16
+ * `{ list: (p) => api.entities.revisions(id, p) }`, which is what keeps
17
+ * @startsimpli/api out of packages/ui.
18
+ *
19
+ * SERVER-SIDE PAGINATION (rule #8) via `useInfiniteQuery`, and the fold runs
20
+ * over the ACCUMULATED pages rather than per page — otherwise one sitting that
21
+ * straddles a page boundary renders as "3 saves" followed by "2 saves", which
22
+ * is wrong and silently so.
23
+ *
24
+ * ── THE THREE SENTENCES THIS PANEL OWES THE READER ──────────────────────────
25
+ *
26
+ * Each exists because a silence in its place reads as a defect, and each is
27
+ * said ONCE for the panel rather than once per row — which is the whole trick
28
+ * to rendering `unknown` without making the panel look broken:
29
+ *
30
+ * 1. WHAT AN UNMARKED ROW MEANS. For up to the 14-day refresh expiry after the
31
+ * actor_kind rollout, a genuine human edit carries no kind at all (the claim
32
+ * is minted at LOGIN and inherited across refreshes). So `unknown` is the
33
+ * COMMON case for real people, and a per-row badge would make every tenant's
34
+ * panel look broken for two weeks while training readers to ignore the one
35
+ * badge that matters. Instead a machine is ALWAYS marked, an unmarked row is
36
+ * left calm, and this panel explains the convention once — and only when
37
+ * some loaded row is actually unknown.
38
+ * 2. THAT SOMETHING IS BEING WITHHELD. The envelope's `redacted_fields` rolls
39
+ * up every field the per-field read gate held back on this page. Named here
40
+ * because this route serves every historical value a record ever held, so it
41
+ * is a richer leak than the row read — and because a `{}` rendered silently
42
+ * reads as data loss.
43
+ * 3. THAT THE TRAIL IS NOT THE WHOLE STORY. Four write paths record NO revision
44
+ * today — `annotate`, `move-scope`, `DELETE` and the ingestion worker
45
+ * (bd startsim-jkkn7.6). The version sequence is still provably gapless
46
+ * (a write that records nothing allocates no number), so there is no hole to
47
+ * see — which is exactly why it has to be SAID. A reader must not conclude
48
+ * from a quiet trail that no machine touched this record. The panel
49
+ * therefore never claims completeness, and its empty state says "no edits
50
+ * recorded" and not "never changed".
51
+ */
52
+ import { ReactNode, useMemo } from 'react';
53
+ import { useInfiniteQuery } from '@tanstack/react-query';
54
+ import { Bot, FilePlus2, Pencil, ShieldAlert, Tag } from 'lucide-react';
55
+
56
+ import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
57
+ import { ErrorState } from '../states';
58
+ import { RevisionFields } from './RevisionFields';
59
+ import {
60
+ COLLAPSE_WINDOW_MS,
61
+ foldRevisions,
62
+ sittingHeadline,
63
+ sittingSummary,
64
+ type RevisionSitting,
65
+ } from './revision-history';
66
+ import type { EntityRevision, RevisionClient, RevisionPage } from './types';
67
+ import {
68
+ RECORD_HISTORY_PANEL_DEFAULTS,
69
+ type RecordHistoryPanelClassNames,
70
+ } from './record-history-panel-default-class-names';
71
+
72
+ /** Timeline entry `type` per sitting, so a host can re-icon or re-colour one
73
+ * class of row without knowing anything else about this module. */
74
+ export const REVISION_ENTRY_TYPES = {
75
+ created: 'revision_created',
76
+ updated: 'revision_updated',
77
+ renamed: 'revision_renamed',
78
+ attempt: 'revision_attempt',
79
+ machine: 'revision_machine',
80
+ } as const;
81
+
82
+ const DEFAULT_ICONS = {
83
+ [REVISION_ENTRY_TYPES.created]: FilePlus2,
84
+ [REVISION_ENTRY_TYPES.updated]: Pencil,
85
+ [REVISION_ENTRY_TYPES.renamed]: Tag,
86
+ [REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
87
+ [REVISION_ENTRY_TYPES.machine]: Bot,
88
+ };
89
+
90
+ function entryType(sitting: RevisionSitting): string {
91
+ if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
92
+ if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
93
+ if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
94
+ if (Object.keys(sitting.changed).length === 0 && sitting.name) {
95
+ return REVISION_ENTRY_TYPES.renamed;
96
+ }
97
+ return REVISION_ENTRY_TYPES.updated;
98
+ }
99
+
100
+ /** The version this sitting spans. Shown because `version` is the chain's own
101
+ * gapless sequence, which is the only thing a reader (or a later span-blame
102
+ * pass) can use to tell a complete trail from a lossy one. */
103
+ function versionNote(sitting: RevisionSitting): string {
104
+ return sitting.fromVersion === sitting.toVersion
105
+ ? `v${sitting.toVersion}`
106
+ : `v${sitting.fromVersion}–v${sitting.toVersion}`;
107
+ }
108
+
109
+ /** A 404 here means the caller may not read the PARENT record: the route is
110
+ * nested and `get_object()` resolves the parent through the gated queryset, so
111
+ * an unreadable row is simply absent. That is a DIFFERENT fact from "this
112
+ * record has no history" and must never be rendered as an empty trail. */
113
+ function isNotFound(error: unknown): boolean {
114
+ const status = (error as { status?: number; response?: { status?: number } } | null)?.status
115
+ ?? (error as { response?: { status?: number } } | null)?.response?.status;
116
+ if (status === 404) return true;
117
+ const message = (error as Error | null)?.message ?? '';
118
+ return /\b404\b|not found/i.test(message);
119
+ }
120
+
121
+ export interface RecordHistoryPanelProps {
122
+ /** The injected, authed reader for ONE record's revisions. */
123
+ client: RevisionClient;
124
+ /** Cache namespace, e.g. `['entity', id, 'revisions']`. */
125
+ queryKey?: readonly unknown[];
126
+ pageSize?: number;
127
+ /** How close two writes by the same actor count as one sitting. 0 renders a
128
+ * row per save. See {@link COLLAPSE_WINDOW_MS}. */
129
+ windowMs?: number;
130
+ title?: ReactNode;
131
+ description?: ReactNode;
132
+ headerAside?: ReactNode;
133
+ /** Map a wire field name to a reader-facing label. */
134
+ fieldLabel?: (field: string) => string;
135
+ /** Group entries under day headings. On by default — a record's history is
136
+ * read by date far more often than as a flat list. */
137
+ groupByDay?: boolean;
138
+ emptyTitle?: string;
139
+ emptyDescription?: string;
140
+ errorTitle?: string;
141
+ notFoundTitle?: string;
142
+ notFoundMessage?: string;
143
+ /** The standing caveat that the trail has known silent write paths. Pass
144
+ * `null` only if the surrounding page already says it — not to make the
145
+ * panel look more complete than it is. */
146
+ incompleteNote?: ReactNode;
147
+ classNames?: RecordHistoryPanelClassNames;
148
+ }
149
+
150
+ export function RecordHistoryPanel({
151
+ client,
152
+ queryKey = ['record-history'],
153
+ pageSize = 50,
154
+ windowMs = COLLAPSE_WINDOW_MS,
155
+ title = 'History',
156
+ // The customer's question, not a feature name.
157
+ description = 'Who changed which field, from what to what, newest first.',
158
+ headerAside,
159
+ fieldLabel = (field) => field,
160
+ groupByDay = true,
161
+ emptyTitle = 'No edits recorded for this record',
162
+ // NOT "this record has never changed" — see sentence 3 in the header.
163
+ emptyDescription =
164
+ 'Edits made through the app will appear here, newest first.',
165
+ errorTitle = 'Could not load history',
166
+ notFoundTitle = 'History is not available to you',
167
+ notFoundMessage =
168
+ 'This record, or your access to it, has changed. Reopen the record to see its history.',
169
+ incompleteNote =
170
+ 'Some writes are not recorded yet — bulk imports, scope moves, annotations and deletions leave no entry — so a quiet history is not proof that nothing else touched this record.',
171
+ classNames = {},
172
+ }: RecordHistoryPanelProps) {
173
+ const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
174
+
175
+ const query = useInfiniteQuery<RevisionPage>({
176
+ queryKey: [...queryKey, { pageSize, windowMs }],
177
+ queryFn: ({ pageParam }) => client.list({ page: pageParam as number, pageSize }),
178
+ initialPageParam: 1,
179
+ getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
180
+ });
181
+
182
+ const revisions = useMemo<EntityRevision[]>(
183
+ () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
184
+ [query.data],
185
+ );
186
+
187
+ // THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
188
+ const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
189
+
190
+ const items = useMemo<ActivityTimelineItem[]>(
191
+ () =>
192
+ sittings.map((sitting) => ({
193
+ id: sitting.id,
194
+ type: entryType(sitting),
195
+ title: sittingHeadline(sitting, fieldLabel),
196
+ occurredAt: sitting.at,
197
+ actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
198
+ actorKind: sitting.actorKind,
199
+ actorKindSource: sitting.actorKindSource,
200
+ // An attempt is the one row that is NOT a change to the record, so it
201
+ // is the one row that earns a tone. `warn` and not `error`: nothing
202
+ // failed — a guard did its job.
203
+ tone: sitting.isAttempt ? 'warn' : undefined,
204
+ subtitle: versionNote(sitting),
205
+ note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
206
+ detail: <RevisionFields sitting={sitting} fieldLabel={fieldLabel} />,
207
+ })),
208
+ [sittings, fieldLabel],
209
+ );
210
+
211
+ // Sentence 1 — only when some loaded row really is unknown, so a tenant past
212
+ // the healing window never sees it.
213
+ const anyUnknownKind = useMemo(
214
+ () => sittings.some((s) => s.actorKind === 'unknown'),
215
+ [sittings],
216
+ );
217
+
218
+ // Sentence 2 — the server's own roll-up over this page's rows, unioned
219
+ // across the pages loaded so far.
220
+ const withheld = useMemo(() => {
221
+ const names = new Set<string>();
222
+ for (const page of query.data?.pages ?? []) {
223
+ for (const name of page?.redacted_fields ?? page?.redactedFields ?? []) names.add(name);
224
+ }
225
+ return [...names].sort();
226
+ }, [query.data]);
227
+
228
+ const total = query.data?.pages?.[0]?.count ?? revisions.length;
229
+ const notFound = query.isError && isNotFound(query.error);
230
+
231
+ const loadMore = () => {
232
+ if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
233
+ };
234
+
235
+ return (
236
+ <section className={cx.root} data-testid="record-history-panel">
237
+ <div className={cx.header}>
238
+ <div className={cx.heading}>
239
+ <h2 className={cx.title}>{title}</h2>
240
+ {description && <p className={cx.description}>{description}</p>}
241
+ </div>
242
+ {headerAside}
243
+ </div>
244
+
245
+ {anyUnknownKind && (
246
+ <p className={cx.notice} data-testid="record-history-unknown-kind">
247
+ Entries marked <strong className="font-medium text-foreground">machine</strong> were
248
+ made by an automation. For the rest, whether a person or a machine made the edit was
249
+ not recorded — it is not available for edits made before this was switched on, or by
250
+ anyone who has not signed in since.
251
+ </p>
252
+ )}
253
+
254
+ {withheld.length > 0 && (
255
+ <p className={cx.notice} data-testid="record-history-redacted">
256
+ Some values are not shown to you:{' '}
257
+ {withheld.map((f) => fieldLabel(f)).join(', ')}. The edits are recorded and listed —
258
+ your access does not include reading those fields on this record.
259
+ </p>
260
+ )}
261
+
262
+ <div className={cx.body}>
263
+ {notFound ? (
264
+ <ErrorState title={notFoundTitle} message={notFoundMessage} />
265
+ ) : query.isError ? (
266
+ <ErrorState
267
+ title={errorTitle}
268
+ message={(query.error as Error)?.message || 'Please try again.'}
269
+ onRetry={() => query.refetch()}
270
+ />
271
+ ) : (
272
+ <ActivityTimeline
273
+ activities={items}
274
+ loading={query.isLoading}
275
+ groupByDay={groupByDay}
276
+ autoLoadMore
277
+ hasMore={!!query.hasNextPage}
278
+ onLoadMore={loadMore}
279
+ emptyTitle={emptyTitle}
280
+ emptyDescription={emptyDescription}
281
+ activityIcons={DEFAULT_ICONS}
282
+ defaultIcon={Pencil}
283
+ loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
284
+ />
285
+ )}
286
+ </div>
287
+
288
+ {revisions.length > 0 && total > revisions.length && (
289
+ <p className={cx.countLine}>
290
+ Showing {sittings.length} {sittings.length === 1 ? 'entry' : 'entries'} from{' '}
291
+ {revisions.length} of {total} saves.
292
+ </p>
293
+ )}
294
+
295
+ {/* Sentence 3 — always, when there is anything to qualify. */}
296
+ {incompleteNote && !query.isError && (
297
+ <p className={cx.countLine} data-testid="record-history-incomplete">
298
+ {incompleteNote}
299
+ </p>
300
+ )}
301
+ </section>
302
+ );
303
+ }
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * What moved inside one sitting, field by field (bd startsim-g4rwo).
5
+ *
6
+ * ── THE THREE WAYS A FIELD CAN BE NAMED WITHOUT ITS VALUES ──────────────────
7
+ *
8
+ * All three arrive as `{}` and they mean different things, so rendering them
9
+ * the same way — or worse, rendering `{}` as "changed to nothing" — turns a
10
+ * permission boundary and a storage cap into apparent data loss. Each gets its
11
+ * own sentence:
12
+ *
13
+ * - WITHHELD (`redacted_fields` names it): you are not allowed to read this
14
+ * field, so you may not read its history either. The FACT of the edit is
15
+ * real and is shown; the values are not. This route serves every historical
16
+ * value a record ever held, which makes it a RICHER leak than the row read —
17
+ * so the gate is honoured here and SAID OUT LOUD, because silence reads as
18
+ * data loss and would send somebody looking for a bug.
19
+ * - OMITTED (`metadata.values_omitted`): the whole revision was over the byte
20
+ * cap, so every field is named and no values were stored. Nobody is being
21
+ * kept out; the values were never written.
22
+ * - TRUNCATED (`truncated: true` on the entry): the value was too large to
23
+ * store. The critical consequence is that A MISSING SIDE NOW SAYS NOTHING —
24
+ * everywhere else a missing `after` means the write DELETED the key — so the
25
+ * created/deleted inference is suppressed and the diff is REFUSED with its
26
+ * reason, rather than showing a deletion that never happened.
27
+ */
28
+ import { useState } from 'react';
29
+ import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
30
+
31
+ import { cn } from '../../lib/utils';
32
+ import { DiffViewer } from '../diff';
33
+ import {
34
+ canDiffFieldChange,
35
+ classifyFieldChange,
36
+ type FoldedFieldChange,
37
+ type RevisionSitting,
38
+ } from './revision-history';
39
+ import { unifiedTextDiff } from './text-diff';
40
+
41
+ /** A stored value as one line of text, for the side-by-side summary. Objects
42
+ * are JSON so a reader can at least see the shape; nothing is invented. */
43
+ function preview(value: unknown): string {
44
+ if (typeof value === 'string') return value;
45
+ if (value === null) return 'null';
46
+ if (value === undefined) return '';
47
+ try {
48
+ return JSON.stringify(value);
49
+ } catch {
50
+ return String(value);
51
+ }
52
+ }
53
+
54
+ const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
55
+
56
+ /** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
57
+ * screen-reader text — never as an empty box that looks like "" was stored. */
58
+ function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
59
+ return (
60
+ <div className="min-w-0 flex-1">
61
+ <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
62
+ {label}
63
+ </div>
64
+ {has ? (
65
+ <pre className={cn('rounded border border-border bg-muted/40 p-2 font-mono text-xs', CLAMP)}>
66
+ {preview(value)}
67
+ </pre>
68
+ ) : (
69
+ <p className="rounded border border-dashed border-border p-2 text-xs text-muted-foreground">
70
+ <span aria-hidden="true">—</span>
71
+ <span className="sr-only">not set</span>
72
+ <span className="ms-1">not set</span>
73
+ </p>
74
+ )}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange }) {
80
+ const kind = classifyFieldChange(change);
81
+
82
+ if (kind === 'withheld') {
83
+ return (
84
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
85
+ <EyeOff className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
86
+ <span>
87
+ This field was changed, but <strong className="font-medium text-foreground">its
88
+ values are not shown to you</strong> — your access does not include reading{' '}
89
+ <code className="rounded bg-muted px-1">{field}</code> on this record. The edit itself
90
+ is real and is recorded.
91
+ </span>
92
+ </p>
93
+ );
94
+ }
95
+
96
+ if (kind === 'omitted') {
97
+ return (
98
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
99
+ <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
100
+ <span>
101
+ This field changed, but this revision was too large to store its values. The field
102
+ names were kept; the before and after were not written.
103
+ </span>
104
+ </p>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className="space-y-2">
110
+ {kind === 'truncated' && (
111
+ <p className="flex items-start gap-2 text-xs text-muted-foreground">
112
+ <Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
113
+ <span>
114
+ This value was too large to store in full, so what is shown may be a prefix and{' '}
115
+ <strong className="font-medium text-foreground">a missing side means nothing
116
+ here</strong>. No comparison is offered, because a comparison against a shortened
117
+ value would show a deletion that never happened.
118
+ </span>
119
+ </p>
120
+ )}
121
+ <div className="flex flex-wrap gap-3 sm:flex-nowrap">
122
+ <Side label="Before" has={change.hasBefore} value={change.before} />
123
+ <Side label="After" has={change.hasAfter} value={change.after} />
124
+ </div>
125
+ {canDiffFieldChange(change) && (
126
+ <DiffViewer
127
+ diff={unifiedTextDiff(String(change.before), String(change.after), field)}
128
+ emptyLabel="No line-level change"
129
+ className="max-h-72 overflow-auto rounded border border-border"
130
+ />
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /** The one-word verdict next to a field name. Neutral tokens throughout: a
137
+ * deletion is a thing a reviewer did on purpose far more often than it is an
138
+ * accident, so it is not painted as an error. */
139
+ const KIND_LABEL: Record<ReturnType<typeof classifyFieldChange>, string> = {
140
+ withheld: 'not shown to you',
141
+ omitted: 'values not stored',
142
+ truncated: 'too large to store',
143
+ created: 'set',
144
+ deleted: 'cleared',
145
+ updated: 'changed',
146
+ };
147
+
148
+ export interface RevisionFieldsProps {
149
+ sitting: RevisionSitting;
150
+ /** Map a wire field name to something a reader recognises. Identity by
151
+ * default — see `types.ts` on why this module never re-cases a name. */
152
+ fieldLabel?: (field: string) => string;
153
+ }
154
+
155
+ export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
156
+ const [open, setOpen] = useState<string | null>(null);
157
+ const entries: Array<[string, FoldedFieldChange]> = [
158
+ ...Object.keys(sitting.changed)
159
+ .sort()
160
+ .map((field) => [field, sitting.changed[field]!] as [string, FoldedFieldChange]),
161
+ // The name COLUMN, kept namespaced away from the data keys because a tenant
162
+ // may declare a data key literally called "name".
163
+ ...(sitting.name ? ([['name (title)', sitting.name]] as Array<[string, FoldedFieldChange]>) : []),
164
+ ];
165
+
166
+ return (
167
+ <div className="space-y-2" data-testid="revision-fields">
168
+ {/* AN ATTEMPT IS NOT A CHANGE. A machine tried to write and
169
+ `human_edits.guard_machine_write` stopped it, so the record did not
170
+ move — and the whole value of this row is that the attempt is on the
171
+ record. It is listed separately from `changed` for that reason. */}
172
+ {sitting.preserved.length > 0 && (
173
+ <ul className="space-y-1" data-testid="revision-preserved">
174
+ {sitting.preserved.map((field) => (
175
+ <li key={field} className="text-xs text-muted-foreground">
176
+ <code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
177
+ was not written — a person&apos;s edit was kept instead.
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ )}
182
+
183
+ {sitting.regenerate.length > 0 && (
184
+ <p className="text-xs text-muted-foreground" data-testid="revision-regenerate">
185
+ Claimed permission to re-author{' '}
186
+ {sitting.regenerate.map((f) => fieldLabel(f)).join(', ')}.
187
+ </p>
188
+ )}
189
+
190
+ {entries.length > 0 && (
191
+ <ul className="divide-y divide-border rounded border border-border">
192
+ {entries.map(([field, change]) => {
193
+ const kind = classifyFieldChange(change);
194
+ const isOpen = open === field;
195
+ return (
196
+ <li key={field}>
197
+ <button
198
+ type="button"
199
+ aria-expanded={isOpen}
200
+ onClick={() => setOpen(isOpen ? null : field)}
201
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
202
+ >
203
+ <ChevronRight
204
+ className={cn('h-3.5 w-3.5 shrink-0 transition-transform', isOpen && 'rotate-90')}
205
+ aria-hidden="true"
206
+ />
207
+ <code className="min-w-0 truncate font-mono text-foreground">
208
+ {fieldLabel(field)}
209
+ </code>
210
+ <span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
211
+ </button>
212
+ {isOpen && (
213
+ <div className="border-t border-border bg-muted/20 px-3 py-2">
214
+ <FieldBody field={field} change={change} />
215
+ </div>
216
+ )}
217
+ </li>
218
+ );
219
+ })}
220
+ </ul>
221
+ )}
222
+ </div>
223
+ );
224
+ }