@startsimpli/ui 0.4.140 → 0.4.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.tsx +17 -12
  3. package/src/components/ActorIdentity.tsx +15 -2
  4. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  5. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  6. package/src/components/activity/types.ts +10 -5
  7. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  8. package/src/components/collection/InlineReviewActions.tsx +3 -2
  9. package/src/components/collection/ReviewDrawer.tsx +35 -7
  10. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  11. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  12. package/src/components/collection/data.ts +16 -1
  13. package/src/components/collection/index.ts +2 -1
  14. package/src/components/collection/scoped-client.ts +4 -1
  15. package/src/components/collection/types.ts +19 -1
  16. package/src/components/history/FieldAttribution.tsx +308 -0
  17. package/src/components/history/RecordActivityPanel.tsx +344 -0
  18. package/src/components/history/RecordHistoryPanel.tsx +381 -29
  19. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  20. package/src/components/history/RevisionFields.tsx +78 -6
  21. package/src/components/history/StaleSaveDialog.tsx +28 -7
  22. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  23. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  24. package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
  25. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  26. package/src/components/history/__tests__/restore.test.ts +209 -0
  27. package/src/components/history/index.ts +72 -1
  28. package/src/components/history/record-history-panel-default-class-names.ts +1 -1
  29. package/src/components/history/restore.ts +295 -0
  30. package/src/components/history/revision-feed.ts +105 -0
  31. package/src/components/history/revision-history.ts +22 -0
  32. package/src/components/history/types.ts +153 -1
@@ -40,30 +40,60 @@
40
40
  * because this route serves every historical value a record ever held, so it
41
41
  * is a richer leak than the row read — and because a `{}` rendered silently
42
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".
43
+ * 3. WHEN HISTORY IS DECLARED OFF FOR THIS KIND OF RECORD. Every write path
44
+ * records a revision now — `annotate`, `move-scope`, `DELETE` and the
45
+ * ingestion worker were the last four, closed by bd startsim-jkkn7.6 — so the
46
+ * old standing caveat that "some writes are not recorded yet" described a gap
47
+ * that no longer exists, and was removed (bd startsim-jkkn7.11). What CAN
48
+ * still leave a trail empty is a DECLARATION: `EntityTypeDef.config.history
49
+ * = {"enabled": false}` on the type (or the reserved `"*"` row). That is a
50
+ * decision somebody made, not a silent door, so it gets different words —
51
+ * "history is not recorded for this kind of record" — and it is said only
52
+ * when the policy says so: the host's `historyEnabled={false}`, or — since
53
+ * bd startsim-jkkn7.17 put the resolved policy on the revisions envelope —
54
+ * `history.enabled: false` on the first page. Otherwise the empty state
55
+ * says "no edits recorded" and nothing more — and never "never changed".
56
+ *
57
+ * ── RESTORE (bd startsim-zkyk4; route bd startsim-ti1yv) ────────────────────
58
+ *
59
+ * "Restore this value" on a changed field and "Restore this version" on a row,
60
+ * offered only when the host injects `client.restore` and only where it would
61
+ * change something. Nothing is written until {@link RestoreConfirmDialog} has
62
+ * shown what will move, computed from the trail this panel already holds by the
63
+ * same backwards undo the server runs. Every restore carries the version the
64
+ * HOST holds (`currentVersion`, read at confirm time so an autosave in between
65
+ * is honoured) — the route 428s without one — and a 412 reuses StaleSaveDialog,
66
+ * where only an explicit "restore anyway" adopts the version the refusal named.
67
+ * A narrowed (`?field=`) trail is that field's own, so it never offers a
68
+ * whole-record restore.
51
69
  */
52
- import { ReactNode, useMemo } from 'react';
53
- import { useInfiniteQuery } from '@tanstack/react-query';
54
- import { Bot, FilePlus2, Pencil, ShieldAlert, Tag } from 'lucide-react';
70
+ import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
71
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
72
+ import { Bot, FilePlus2, Pencil, ShieldAlert, Tag, Undo2, X } from 'lucide-react';
55
73
 
56
74
  import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
57
75
  import { ErrorState } from '../states';
58
76
  import { RevisionFields } from './RevisionFields';
77
+ import { RestoreConfirmDialog } from './RestoreConfirmDialog';
78
+ import { StaleSaveDialog } from './StaleSaveDialog';
79
+ import type { StaleSaveConflict } from './useConditionalSave';
80
+ import { newestRevision } from './conditional-write';
81
+ import {
82
+ latestFieldVersions,
83
+ parseRestoreOutcome,
84
+ restorePreview,
85
+ type ParsedRestoreOutcome,
86
+ type RestorePreview,
87
+ } from './restore';
59
88
  import {
60
89
  COLLAPSE_WINDOW_MS,
61
90
  foldRevisions,
91
+ normalizeRevision,
62
92
  sittingHeadline,
63
93
  sittingSummary,
64
94
  type RevisionSitting,
65
95
  } from './revision-history';
66
- import type { EntityRevision, RevisionClient, RevisionPage } from './types';
96
+ import type { EntityRevision, RestoreRequest, RevisionClient, RevisionPage } from './types';
67
97
  import {
68
98
  RECORD_HISTORY_PANEL_DEFAULTS,
69
99
  type RecordHistoryPanelClassNames,
@@ -77,6 +107,7 @@ export const REVISION_ENTRY_TYPES = {
77
107
  renamed: 'revision_renamed',
78
108
  attempt: 'revision_attempt',
79
109
  machine: 'revision_machine',
110
+ restored: 'revision_restored',
80
111
  } as const;
81
112
 
82
113
  const DEFAULT_ICONS = {
@@ -85,10 +116,14 @@ const DEFAULT_ICONS = {
85
116
  [REVISION_ENTRY_TYPES.renamed]: Tag,
86
117
  [REVISION_ENTRY_TYPES.attempt]: ShieldAlert,
87
118
  [REVISION_ENTRY_TYPES.machine]: Bot,
119
+ [REVISION_ENTRY_TYPES.restored]: Undo2,
88
120
  };
89
121
 
90
- function entryType(sitting: RevisionSitting): string {
122
+ /** The timeline entry type for a sitting. Exported so the cross-record feed
123
+ * (RecordActivityPanel) icons a row exactly as this panel does. */
124
+ export function revisionEntryType(sitting: RevisionSitting): string {
91
125
  if (sitting.isAttempt) return REVISION_ENTRY_TYPES.attempt;
126
+ if (sitting.restoredFrom !== undefined) return REVISION_ENTRY_TYPES.restored;
92
127
  if (sitting.action === 'created') return REVISION_ENTRY_TYPES.created;
93
128
  if (sitting.actorKind === 'machine') return REVISION_ENTRY_TYPES.machine;
94
129
  if (Object.keys(sitting.changed).length === 0 && sitting.name) {
@@ -97,10 +132,12 @@ function entryType(sitting: RevisionSitting): string {
97
132
  return REVISION_ENTRY_TYPES.updated;
98
133
  }
99
134
 
135
+ export const REVISION_ENTRY_ICONS = DEFAULT_ICONS;
136
+
100
137
  /** The version this sitting spans. Shown because `version` is the chain's own
101
138
  * gapless sequence, which is the only thing a reader (or a later span-blame
102
139
  * pass) can use to tell a complete trail from a lossy one. */
103
- function versionNote(sitting: RevisionSitting): string {
140
+ export function versionNote(sitting: RevisionSitting): string {
104
141
  return sitting.fromVersion === sitting.toVersion
105
142
  ? `v${sitting.toVersion}`
106
143
  : `v${sitting.fromVersion}–v${sitting.toVersion}`;
@@ -140,9 +177,51 @@ export interface RecordHistoryPanelProps {
140
177
  errorTitle?: string;
141
178
  notFoundTitle?: string;
142
179
  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. */
180
+ /** Whether this record's TYPE keeps a history, as resolved from
181
+ * `EntityTypeDef.config.history` (the type's own declaration, else the
182
+ * reserved `"*"` row, else on). `false` makes the panel say that history is
183
+ * not recorded for this kind of record, beside any rows written before it
184
+ * was switched off. Omitted or `true` says nothing — the panel never
185
+ * invents a caveat it cannot back. bd startsim-jkkn7.11. */
186
+ historyEnabled?: boolean;
187
+ /** The sentence shown when {@link historyEnabled} is `false`. */
188
+ declaredOffNote?: ReactNode;
189
+ /** Narrow the trail to one field — `?field=` on the read (bd startsim-jkkn7.8).
190
+ * What `FieldAttribution` opens. A narrowed panel offers no whole-record
191
+ * restore: the narrowed read misses name changes and held-back attempts
192
+ * (bd startsim-jkkn7.18), so a preview over it would be partial. */
193
+ field?: string;
194
+ /** "Show all fields" while narrowed. Omit to hide the control. */
195
+ onClearField?: () => void;
196
+ /** Narrow from inside a row ("only this field's history"). Omit to hide. */
197
+ onNarrowToField?: (field: string) => void;
198
+ /** The version the HOST holds for this record — what a restore asserts. A
199
+ * function is read at confirm time, so a draft editor can pass its live
200
+ * conditional-save version and an autosave between opening the dialog and
201
+ * confirming it does not turn the restore into a 412 against the reviewer's
202
+ * own save. Falls back to the newest loaded revision on an unnarrowed trail. */
203
+ currentVersion?: number | (() => number | undefined);
204
+ /** Whether the viewer may write this record. Restore is offered only when the
205
+ * client has `restore` AND this is not `false`. */
206
+ canRestore?: boolean;
207
+ /**
208
+ * Runs before the restore is sent, and BEFORE `currentVersion` is read — the
209
+ * host's chance to stop pending autosaves and flush unsaved typing, so nothing
210
+ * lands after the restore with the old text and nothing typed is lost. May
211
+ * return a cleanup, run once the restore has settled either way (success,
212
+ * refusal or 412). THROWING ABORTS the restore with the error's message: a
213
+ * flush that was itself refused must not be followed by a restore.
214
+ */
215
+ beforeRestore?: () => void | (() => void) | Promise<void | (() => void)>;
216
+ /** After a restore landed. The host reloads its own state (an editor's local
217
+ * copy) from `outcome.record`; the panel re-reads its trail itself. */
218
+ onRestored?: (outcome: Extract<ParsedRestoreOutcome, { kind: 'restored' }>) => void | Promise<void>;
219
+ /** "this draft", "this topic" — for the confirm and stale dialogs. */
220
+ recordLabel?: string;
221
+ /** An extra caveat under the trail, for a host that knows something about
222
+ * this record's coverage the panel cannot. None by default: since bd
223
+ * startsim-jkkn7.6 every write path records, and a standing "some edits may
224
+ * be missing" would teach readers to discount a complete trail. */
146
225
  incompleteNote?: ReactNode;
147
226
  classNames?: RecordHistoryPanelClassNames;
148
227
  }
@@ -166,24 +245,187 @@ export function RecordHistoryPanel({
166
245
  notFoundTitle = 'History is not available to you',
167
246
  notFoundMessage =
168
247
  '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.',
248
+ historyEnabled,
249
+ declaredOffNote =
250
+ 'History is not recorded for this kind of record — it has been switched off for this type, so new edits do not appear here.',
251
+ incompleteNote = null,
252
+ field,
253
+ onClearField,
254
+ onNarrowToField,
255
+ currentVersion,
256
+ canRestore,
257
+ beforeRestore,
258
+ onRestored,
259
+ recordLabel = 'this record',
171
260
  classNames = {},
172
261
  }: RecordHistoryPanelProps) {
173
262
  const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
263
+ const queryClient = useQueryClient();
174
264
 
175
265
  const query = useInfiniteQuery<RevisionPage>({
176
- queryKey: [...queryKey, { pageSize, windowMs }],
177
- queryFn: ({ pageParam }) => client.list({ page: pageParam as number, pageSize }),
266
+ queryKey: [...queryKey, { pageSize, windowMs, field: field ?? null }],
267
+ queryFn: ({ pageParam }) =>
268
+ client.list({ page: pageParam as number, pageSize, ...(field ? { field } : {}) }),
178
269
  initialPageParam: 1,
179
270
  getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
180
271
  });
181
272
 
273
+ // The host's word wins; otherwise the envelope's resolved policy
274
+ // (bd startsim-jkkn7.17). Absent from both says nothing.
275
+ const envelopePolicy = query.data?.pages?.[0]?.history;
276
+ const declaredOff = (historyEnabled ?? envelopePolicy?.enabled) === false;
277
+
182
278
  const revisions = useMemo<EntityRevision[]>(
183
279
  () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
184
280
  [query.data],
185
281
  );
186
282
 
283
+ /* ------------------------------------------------------------------ */
284
+ /* RESTORE */
285
+ /* ------------------------------------------------------------------ */
286
+
287
+ const restoreEnabled = typeof client.restore === 'function' && canRestore !== false;
288
+ const latest = useMemo(() => latestFieldVersions(revisions), [revisions]);
289
+ const newestLoaded = useMemo(
290
+ () => normalizeRevision(newestRevision(revisions) ?? { id: '', version: 0 }).version,
291
+ [revisions],
292
+ );
293
+
294
+ const [pending, setPending] = useState<{ target: number; fields?: string[] } | null>(null);
295
+ const [busy, setBusy] = useState(false);
296
+ const [restoreError, setRestoreError] = useState<string | null>(null);
297
+ const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
298
+ const [done, setDone] = useState<string | null>(null);
299
+ /** The request a 412 refused, kept so "restore anyway" re-sends exactly it. */
300
+ const refused = useRef<RestoreRequest | null>(null);
301
+ const inFlight = useRef(false);
302
+
303
+ const preview = useMemo<RestorePreview | null>(
304
+ () =>
305
+ pending
306
+ ? restorePreview(revisions, pending.target, {
307
+ ...(pending.fields ? { fields: pending.fields } : {}),
308
+ narrowed: !!field,
309
+ })
310
+ : null,
311
+ [pending, revisions, field],
312
+ );
313
+
314
+ const heldVersion = useCallback((): number | undefined => {
315
+ const held = typeof currentVersion === 'function' ? currentVersion() : currentVersion;
316
+ if (typeof held === 'number' && Number.isInteger(held)) return held;
317
+ // Only an UNNARROWED trail's newest row is the record's current version.
318
+ return field ? undefined : newestLoaded || undefined;
319
+ }, [currentVersion, field, newestLoaded]);
320
+
321
+ const refresh = useCallback(
322
+ () => queryClient.invalidateQueries({ queryKey: [...queryKey] }),
323
+ [queryClient, queryKey],
324
+ );
325
+
326
+ const send = useCallback(
327
+ async (request: RestoreRequest) => {
328
+ if (!client.restore || inFlight.current) return;
329
+ inFlight.current = true;
330
+ setBusy(true);
331
+ setRestoreError(null);
332
+ let outcome: unknown;
333
+ try {
334
+ outcome = await client.restore(request);
335
+ } catch (error) {
336
+ outcome = error;
337
+ }
338
+ const parsed = parseRestoreOutcome(outcome);
339
+ try {
340
+ if (parsed.kind === 'restored') {
341
+ setPending(null);
342
+ setConflict(null);
343
+ refused.current = null;
344
+ const moved = parsed.summary.fields;
345
+ const from = `v${parsed.summary.restoredFrom ?? request.version}`;
346
+ setDone(
347
+ parsed.summary.revision === null
348
+ ? `Nothing to restore — already as it was at ${from}.`
349
+ : `Restored ${moved.length ? moved.map(fieldLabel).join(', ') : 'the record'} from ${from}, saved as v${parsed.summary.revision}.`,
350
+ );
351
+ await onRestored?.(parsed);
352
+ await refresh();
353
+ } else if (parsed.kind === 'stale') {
354
+ // NOT retried. The record moved since the version the host holds; a
355
+ // person decides, in the same dialog an editor's stale save uses.
356
+ setPending(null);
357
+ refused.current = request;
358
+ const next: StaleSaveConflict = {
359
+ ...(parsed.currentVersion !== undefined ? { currentVersion: parsed.currentVersion } : {}),
360
+ ...(parsed.detail ? { detail: parsed.detail } : {}),
361
+ sentVersion: request.expectedVersion,
362
+ refusedBody: { ...request },
363
+ };
364
+ setConflict(next);
365
+ try {
366
+ const page = await client.list({ page: 1, pageSize: 1 });
367
+ const row = newestRevision(page?.results);
368
+ setConflict((cur) =>
369
+ cur === next
370
+ ? row
371
+ ? { ...cur, changedBy: normalizeRevision(row) }
372
+ : { ...cur, identityGap: 'empty' }
373
+ : cur,
374
+ );
375
+ } catch {
376
+ setConflict((cur) => (cur === next ? { ...cur, identityGap: 'error' } : cur));
377
+ }
378
+ } else {
379
+ setRestoreError(parsed.message);
380
+ }
381
+ } finally {
382
+ inFlight.current = false;
383
+ setBusy(false);
384
+ }
385
+ },
386
+ [client, fieldLabel, onRestored, refresh],
387
+ );
388
+
389
+ const confirmRestore = useCallback(async () => {
390
+ if (!pending || inFlight.current) return;
391
+ let cleanup: void | (() => void);
392
+ setBusy(true);
393
+ try {
394
+ cleanup = await beforeRestore?.();
395
+ } catch (error) {
396
+ setBusy(false);
397
+ setRestoreError(
398
+ (error instanceof Error && error.message) ||
399
+ 'Your unsaved edits could not be saved first, so nothing was restored.',
400
+ );
401
+ return;
402
+ }
403
+ try {
404
+ // Read AFTER the host flushed: its version may have just advanced.
405
+ const expectedVersion = heldVersion();
406
+ if (expectedVersion === undefined) {
407
+ setBusy(false);
408
+ setRestoreError(
409
+ 'The current version of this record is not known here, so a restore cannot be checked against it. Reload and try again.',
410
+ );
411
+ return;
412
+ }
413
+ await send({
414
+ version: pending.target,
415
+ ...(pending.fields ? { fields: pending.fields } : {}),
416
+ expectedVersion,
417
+ });
418
+ } finally {
419
+ if (typeof cleanup === 'function') cleanup();
420
+ }
421
+ }, [pending, heldVersion, beforeRestore, send]);
422
+
423
+ const openRestore = useCallback((target: number, fields?: string[]) => {
424
+ setRestoreError(null);
425
+ setDone(null);
426
+ setPending({ target, ...(fields ? { fields } : {}) });
427
+ }, []);
428
+
187
429
  // THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
188
430
  const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
189
431
 
@@ -191,7 +433,7 @@ export function RecordHistoryPanel({
191
433
  () =>
192
434
  sittings.map((sitting) => ({
193
435
  id: sitting.id,
194
- type: entryType(sitting),
436
+ type: revisionEntryType(sitting),
195
437
  title: sittingHeadline(sitting, fieldLabel),
196
438
  occurredAt: sitting.at,
197
439
  actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
@@ -203,9 +445,26 @@ export function RecordHistoryPanel({
203
445
  tone: sitting.isAttempt ? 'warn' : undefined,
204
446
  subtitle: versionNote(sitting),
205
447
  note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
206
- detail: <RevisionFields sitting={sitting} fieldLabel={fieldLabel} />,
448
+ detail: (
449
+ <RevisionFields
450
+ sitting={sitting}
451
+ fieldLabel={fieldLabel}
452
+ {...(onNarrowToField && !field ? { onNarrowToField } : {})}
453
+ {...(restoreEnabled && !sitting.isAttempt
454
+ ? {
455
+ // Only where a later row moved the field: on the row whose
456
+ // value IS the current one, a restore changes nothing.
457
+ canRestoreField: (f: string) => (latest[f] ?? -1) > sitting.toVersion,
458
+ onRestoreField: (f: string) => openRestore(sitting.toVersion, [f]),
459
+ ...(!field && sitting.toVersion < newestLoaded
460
+ ? { onRestoreVersion: () => openRestore(sitting.toVersion) }
461
+ : {}),
462
+ }
463
+ : {})}
464
+ />
465
+ ),
207
466
  })),
208
- [sittings, fieldLabel],
467
+ [sittings, fieldLabel, restoreEnabled, latest, field, newestLoaded, openRestore, onNarrowToField],
209
468
  );
210
469
 
211
470
  // Sentence 1 — only when some loaded row really is unknown, so a tenant past
@@ -214,6 +473,13 @@ export function RecordHistoryPanel({
214
473
  () => sittings.some((s) => s.actorKind === 'unknown'),
215
474
  [sittings],
216
475
  );
476
+ // A claimed person is undecorated exactly like an unknown row, so when both
477
+ // are loaded "the rest" would sweep in the one row whose kind WAS recorded
478
+ // (bd startsim-jkkn7.15). Each row's own title/sr-only text says which.
479
+ const anyClaimedPerson = useMemo(
480
+ () => sittings.some((s) => s.actorKind === 'person' && s.actorKindSource === 'claim'),
481
+ [sittings],
482
+ );
217
483
 
218
484
  // Sentence 2 — the server's own roll-up over this page's rows, unioned
219
485
  // across the pages loaded so far.
@@ -242,12 +508,45 @@ export function RecordHistoryPanel({
242
508
  {headerAside}
243
509
  </div>
244
510
 
511
+ {field && (
512
+ <p className={cx.notice} data-testid="record-history-narrowed">
513
+ Showing changes to <code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
514
+ only.{' '}
515
+ {onClearField && (
516
+ <button
517
+ type="button"
518
+ data-testid="record-history-clear-field"
519
+ onClick={onClearField}
520
+ className="inline-flex items-center gap-0.5 underline underline-offset-2 hover:text-foreground"
521
+ >
522
+ <X className="h-3 w-3" aria-hidden="true" />
523
+ Show all fields
524
+ </button>
525
+ )}
526
+ </p>
527
+ )}
528
+
529
+ {done && (
530
+ <p className={cx.notice} role="status" data-testid="restore-done">
531
+ {done}
532
+ </p>
533
+ )}
534
+
245
535
  {anyUnknownKind && (
246
536
  <p className={cx.notice} data-testid="record-history-unknown-kind">
247
537
  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.
538
+ made by an automation.{' '}
539
+ {anyClaimedPerson
540
+ ? 'Some unmarked entries were recorded as made by a person; for the others, whether a person or a machine made the edit was not recorded'
541
+ : 'For the rest, whether a person or a machine made the edit was not recorded'}{' '}
542
+ — it is not available for edits made before this was switched on, or by anyone who has
543
+ not signed in since.{anyClaimedPerson && ' Hover a name to see which.'}
544
+ </p>
545
+ )}
546
+
547
+ {declaredOff && !query.isError && (
548
+ <p className={cx.notice} data-testid="record-history-declared-off">
549
+ {declaredOffNote}
251
550
  </p>
252
551
  )}
253
552
 
@@ -277,7 +576,9 @@ export function RecordHistoryPanel({
277
576
  hasMore={!!query.hasNextPage}
278
577
  onLoadMore={loadMore}
279
578
  emptyTitle={emptyTitle}
280
- emptyDescription={emptyDescription}
579
+ // Declared off: "edits will appear here" would be untrue, and the
580
+ // notice above already says why the trail is empty.
581
+ emptyDescription={declaredOff ? '' : emptyDescription}
281
582
  activityIcons={DEFAULT_ICONS}
282
583
  defaultIcon={Pencil}
283
584
  loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
@@ -292,12 +593,63 @@ export function RecordHistoryPanel({
292
593
  </p>
293
594
  )}
294
595
 
295
- {/* Sentence 3 — always, when there is anything to qualify. */}
596
+ {/* A host-supplied caveat only; none by default (see sentence 3). */}
296
597
  {incompleteNote && !query.isError && (
297
598
  <p className={cx.countLine} data-testid="record-history-incomplete">
298
599
  {incompleteNote}
299
600
  </p>
300
601
  )}
602
+
603
+ {restoreEnabled && (
604
+ <>
605
+ <RestoreConfirmDialog
606
+ open={pending !== null}
607
+ preview={preview}
608
+ {...(pending?.fields ? { fields: pending.fields } : {})}
609
+ recordLabel={recordLabel}
610
+ fieldLabel={fieldLabel}
611
+ busy={busy}
612
+ error={restoreError}
613
+ onConfirm={() => void confirmRestore()}
614
+ onCancel={() => {
615
+ setPending(null);
616
+ setRestoreError(null);
617
+ }}
618
+ />
619
+ <StaleSaveDialog
620
+ conflict={conflict}
621
+ open={conflict !== null}
622
+ recordLabel={recordLabel}
623
+ whilePhrase="after you opened its history"
624
+ reassurance="Nothing was restored. Restoring now would replace their change too."
625
+ dismissLabel="Cancel"
626
+ reviewLabel="See what changed"
627
+ keepMineLabel="Restore anyway, replacing their change"
628
+ busy={busy}
629
+ fieldLabel={fieldLabel}
630
+ onDismiss={() => {
631
+ setConflict(null);
632
+ refused.current = null;
633
+ }}
634
+ onReviewChange={() => {
635
+ setConflict(null);
636
+ refused.current = null;
637
+ void refresh();
638
+ }}
639
+ // The ONLY path that adopts the version the refusal named — a person
640
+ // has been shown who changed it and chose to replace it anyway.
641
+ {...(conflict?.currentVersion !== undefined
642
+ ? {
643
+ onKeepMine: () => {
644
+ const request = refused.current;
645
+ if (!request || conflict?.currentVersion === undefined) return;
646
+ void send({ ...request, expectedVersion: conflict.currentVersion });
647
+ },
648
+ }
649
+ : {})}
650
+ />
651
+ </>
652
+ )}
301
653
  </section>
302
654
  );
303
655
  }