@beyondwork/docx-react-component 1.0.86 → 1.0.88

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +580 -40
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +8 -0
  29. package/src/ui/editor-surface-controller.tsx +1 -0
  30. package/src/ui/headless/revision-decoration-model.ts +11 -13
  31. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  32. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  33. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  34. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  35. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  36. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  37. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  38. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  39. package/src/ui-tailwind/editor-surface/preserve-position.ts +31 -6
  40. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +92 -50
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  43. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
  44. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  45. package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
@@ -137,7 +137,23 @@ export function restoreScrollAnchor(
137
137
  anchor: ScrollAnchor | null,
138
138
  options?: FindScrollAnchorOptions,
139
139
  ): void {
140
- if (!root || !anchor) return;
140
+ const targetScrollTop = resolveScrollTopForAnchor(root, anchor, options);
141
+ if (root && targetScrollTop !== null) {
142
+ root.scrollTop = targetScrollTop;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve the `scrollTop` that would restore `anchor` without mutating the
148
+ * scroll container. Used by the edit-path preservation guard so it can reject
149
+ * unsafe restores before writing scroll state.
150
+ */
151
+ export function resolveScrollTopForAnchor(
152
+ root: HTMLElement | null,
153
+ anchor: ScrollAnchor | null,
154
+ options?: FindScrollAnchorOptions,
155
+ ): number | null {
156
+ if (!root || !anchor) return null;
141
157
 
142
158
  if (options?.geometryFacet && !options.prepaintFallback) {
143
159
  const geometry = options.geometryFacet.getBlock(anchor.blockId);
@@ -150,15 +166,14 @@ export function restoreScrollAnchor(
150
166
  // newScrollTop = newBlockTop + offsetWithinBlock.
151
167
  // Matches the DOM-path formula below (round-trip verified by
152
168
  // `test/ui/mode-toggle-scroll-anchor.test.ts`).
153
- root.scrollTop = rect.topPx + anchor.offsetWithinBlock;
154
- return;
169
+ return rect.topPx + anchor.offsetWithinBlock;
155
170
  }
156
171
  // No block match through facet; fall through to DOM path.
157
172
  }
158
173
 
159
174
  const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
160
175
  const block = root.querySelector<HTMLElement>(selector);
161
- if (!block) return;
176
+ if (!block) return null;
162
177
  // Cold-open / pre-paint DOM fallback — same rationale as
163
178
  // findScrollAnchor's fallback above.
164
179
  // geometry:allow-dom-fallback
@@ -171,7 +186,7 @@ export function restoreScrollAnchor(
171
186
  // capture time). Scrolling by `delta` shifts rects by `-delta`, so
172
187
  // solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
173
188
  const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
174
- root.scrollTop = root.scrollTop + delta;
189
+ return root.scrollTop + delta;
175
190
  }
176
191
 
177
192
  /**
@@ -51,6 +51,7 @@ import {
51
51
  } from "./pm-command-bridge";
52
52
  import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
+ import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
54
55
  import { DecorationSet } from "prosemirror-view";
55
56
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
56
57
  import { buildPagePreviewMaps } from "../../api/public-types";
@@ -63,11 +64,15 @@ import {
63
64
  } from "./perf-probe";
64
65
  import { buildPositionMap, type PositionMap } from "./pm-position-map";
65
66
  import { createLocalEditSessionState } from "./local-edit-session-state";
66
- import { createFastTextEditLane } from "./fast-text-edit-lane";
67
+ import {
68
+ createFastTextEditLane,
69
+ getTextCommandRefreshClass,
70
+ } from "./fast-text-edit-lane";
67
71
  import { createPredictedTxGate } from "./predicted-tx-gate";
68
72
  import { replaceStatePreservingPosition } from "./preserve-position";
69
73
  import {
70
74
  createScopeTagRegistry,
75
+ storyTargetsEqual,
71
76
  type ScopeTagRegistry,
72
77
  } from "../../api/public-types";
73
78
  import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
@@ -88,6 +93,24 @@ import { chartNodeViews } from "./chart-node-view.tsx";
88
93
  import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
89
94
  import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
90
95
 
96
+ type RebuildScrollAnchorPolicy = "bounded-same-story";
97
+
98
+ const BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX = 256;
99
+
100
+ export function shouldPreserveScrollAnchorForRebuild(options: {
101
+ policy: RebuildScrollAnchorPolicy | null;
102
+ view: Pick<EditorView, "hasFocus"> | null;
103
+ geometryFacet?: import("../../runtime/geometry/index.ts").GeometryFacet;
104
+ previousStory: EditorStoryTarget | null;
105
+ nextStory: EditorStoryTarget;
106
+ }): boolean {
107
+ if (options.policy !== "bounded-same-story") return false;
108
+ if (!options.view?.hasFocus()) return false;
109
+ if (!options.geometryFacet) return false;
110
+ if (!options.previousStory) return false;
111
+ return storyTargetsEqual(options.previousStory, options.nextStory);
112
+ }
113
+
91
114
  /**
92
115
  * Build page-break widget decorations from the layout facet's current page
93
116
  * graph. Returns `[]` when the facet is unavailable, the active story is
@@ -148,9 +171,12 @@ function buildPageBreakDecorationsFromProps(
148
171
  : undefined;
149
172
 
150
173
  // L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
151
- // render frame's page offsets + the surface blocks list. Each block has a
152
- // `from`/`to` offset; we find the first and last block whose offset range
153
- // falls within each page's [startOffset, nextPage.startOffset) window.
174
+ // render frame's page offsets + the surface blocks list. Each block has a
175
+ // `from`/`to` offset; a block belongs to every page whose offset window it
176
+ // overlaps. That matters for large tables/objects that can straddle a page
177
+ // boundary: matching only `block.from` would omit the active block from the
178
+ // page marker, cull it on the next viewport refresh, and remap the caret to
179
+ // the wrong PM position during snapshot replacement.
154
180
  // This map is passed into `buildPageBreakDecorations` so the chrome widgets
155
181
  // carry `data-page-first-block-index` / `data-page-last-block-index`
156
182
  // attributes needed by `useVisibleBlockRange`.
@@ -160,24 +186,9 @@ function buildPageBreakDecorationsFromProps(
160
186
  for (let pi = 0; pi < frame.pages.length; pi++) {
161
187
  const page = frame.pages[pi]!;
162
188
  if (page.page.isBlankFiller) continue;
163
- const pageStart = page.page.startOffset;
164
- const pageEnd =
165
- pi + 1 < frame.pages.length
166
- ? frame.pages[pi + 1]!.page.startOffset
167
- : Infinity;
168
- let first = -1;
169
- let last = -1;
170
- for (let bi = 0; bi < surfaceBlocks.length; bi++) {
171
- const block = surfaceBlocks[bi]!;
172
- const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
173
- // Block belongs to this page if its start falls within the page's offset window.
174
- if (blockFrom >= pageStart && blockFrom < pageEnd) {
175
- if (first === -1) first = bi;
176
- last = bi;
177
- }
178
- }
179
- if (first !== -1) {
180
- blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
189
+ const range = findBlockIndexRangeForPage(surfaceBlocks, page.page);
190
+ if (range) {
191
+ blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
181
192
  }
182
193
  }
183
194
  }
@@ -290,6 +301,7 @@ export interface TwProseMirrorSurfaceProps {
290
301
  }) => void;
291
302
  onCommentActivated?: (commentId: string) => void;
292
303
  onRevisionActivated?: (revisionId: string) => void;
304
+ onRevisionHovered?: (revisionId: string | null) => void;
293
305
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
294
306
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
295
307
  workflowScopes?: readonly WorkflowScope[];
@@ -441,7 +453,9 @@ export const TwProseMirrorSurface = forwardRef<
441
453
  const suppressSelectionEchoRef = useRef(false);
442
454
  const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
443
455
  const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
444
- const equivalentAckKeyRef = useRef<string | null>(null);
456
+ const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
457
+ const pendingRebuildScrollAnchorRef = useRef<RebuildScrollAnchorPolicy | null>(null);
458
+ const lastBuiltStoryRef = useRef<EditorStoryTarget | null>(null);
445
459
  const selectionToolbarFrameRef = useRef<number | null>(null);
446
460
  const lastSelectionToolbarMeasurementRef = useRef<{
447
461
  key: string | null;
@@ -653,10 +667,11 @@ export const TwProseMirrorSurface = forwardRef<
653
667
  createContextualInteractionPlugin({
654
668
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
655
669
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
670
+ onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
656
671
  }),
657
672
  createSearchPlugin(),
658
673
  ];
659
- }, [props.awareness, props.onCommentActivated, props.onRevisionActivated]);
674
+ }, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
660
675
 
661
676
  const applyDecorationProps = useCallback(
662
677
  (view: EditorView, positionMap: PositionMap): void => {
@@ -771,6 +786,8 @@ export const TwProseMirrorSurface = forwardRef<
771
786
  useEffect(() => {
772
787
  if (!props.dispatchRuntimeCommand || !sessionRef.current) {
773
788
  laneRef.current = null;
789
+ equivalentAckLedgerRef.current.clear();
790
+ pendingRebuildScrollAnchorRef.current = null;
774
791
  return;
775
792
  }
776
793
  // Wave 1 Slice E1/E2 — lane observability.
@@ -815,28 +832,34 @@ export const TwProseMirrorSurface = forwardRef<
815
832
  toRuntime,
816
833
  );
817
834
  },
818
- onEquivalentAck: () => {
819
- // INVARIANT: this marker is set only by onEquivalentAck, which the
820
- // runtime invokes synchronously from dispatchRuntimeCommand. The
821
- // rebuild effect's short-circuit (search for "Predicted-lane
822
- // short-circuit" below) reads it during the same React render cycle
823
- // that the predicted dispatch triggered. If the runtime ack ever
824
- // becomes async (microtask, animation frame, network round-trip),
825
- // this marker will be stale by the time the rebuild effect runs and
826
- // the short-circuit must be replaced with a
827
- // `pendingEquivalentAckOpIds: Set<string>` ledger keyed by opId.
828
- equivalentAckKeyRef.current = documentBuildKeyRef.current;
835
+ onEquivalentAck: (ack) => {
836
+ pendingRebuildScrollAnchorRef.current = null;
837
+ if (
838
+ ack.opId &&
839
+ ack.newRevisionToken &&
840
+ getTextCommandRefreshClass(ack) === "local-text-equivalent"
841
+ ) {
842
+ equivalentAckLedgerRef.current.set(ack.newRevisionToken, ack.opId);
843
+ return;
844
+ }
845
+ equivalentAckLedgerRef.current.clear();
829
846
  },
830
- onAdjustedAck: () => {
847
+ onAdjustedAck: (ack) => {
831
848
  // Adjusted path: allow the rebuild effect to run (it will call
832
849
  // view.updateState with the canonical snapshot).
833
- equivalentAckKeyRef.current = null;
850
+ equivalentAckLedgerRef.current.clear();
851
+ pendingRebuildScrollAnchorRef.current =
852
+ getTextCommandRefreshClass(ack) === "surface-only"
853
+ ? "bounded-same-story"
854
+ : null;
834
855
  },
835
856
  onRejectedAck: () => {
836
- equivalentAckKeyRef.current = null;
857
+ equivalentAckLedgerRef.current.clear();
858
+ pendingRebuildScrollAnchorRef.current = null;
837
859
  },
838
860
  onStructuralDivergence: () => {
839
- equivalentAckKeyRef.current = null;
861
+ equivalentAckLedgerRef.current.clear();
862
+ pendingRebuildScrollAnchorRef.current = null;
840
863
  },
841
864
  });
842
865
  }, [props.dispatchRuntimeCommand, scopeTagRegistry]);
@@ -845,6 +868,7 @@ export const TwProseMirrorSurface = forwardRef<
845
868
  if (!mountRef.current || !surface) return;
846
869
 
847
870
  if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
871
+ pendingRebuildScrollAnchorRef.current = null;
848
872
  return;
849
873
  }
850
874
 
@@ -852,13 +876,16 @@ export const TwProseMirrorSurface = forwardRef<
852
876
  // ack, the PM doc already matches the canonical snapshot. Update tracking
853
877
  // refs and decorations without rebuilding the PM state.
854
878
  //
855
- // INVARIANT: reads `equivalentAckKeyRef.current` set by `onEquivalentAck`
856
- // above. Depends on the runtime ack being synchronous so the marker is
857
- // already in place when this effect runs after the predicted dispatch.
858
- // See the comment at `onEquivalentAck` for the async-ack migration path.
879
+ // INVARIANT: equivalent acks are tracked by revision token and op id, not
880
+ // by the previous build key. This keeps the short-circuit valid if the ack
881
+ // and render snapshot stop arriving in the same synchronous React pass.
882
+ const equivalentAckOpId =
883
+ sessionRef.current && !sessionRef.current.hasPending()
884
+ ? equivalentAckLedgerRef.current.get(snapshot.revisionToken)
885
+ : undefined;
859
886
  if (
860
887
  viewRef.current &&
861
- equivalentAckKeyRef.current !== null &&
888
+ equivalentAckOpId !== undefined &&
862
889
  sessionRef.current &&
863
890
  !sessionRef.current.hasPending() &&
864
891
  sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
@@ -868,7 +895,9 @@ export const TwProseMirrorSurface = forwardRef<
868
895
  positionMapRef.current = buildPositionMap(surface);
869
896
  documentBuildKeyRef.current = documentBuildKey;
870
897
  applyDecorationProps(viewRef.current, positionMapRef.current);
871
- equivalentAckKeyRef.current = null;
898
+ equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
899
+ pendingRebuildScrollAnchorRef.current = null;
900
+ lastBuiltStoryRef.current = snapshot.activeStory;
872
901
  if (pendingTypingProbeRef.current) {
873
902
  finishPerfProbe(pendingTypingProbeRef.current);
874
903
  pendingTypingProbeRef.current = null;
@@ -924,24 +953,37 @@ export const TwProseMirrorSurface = forwardRef<
924
953
  // AFTER, so PM's internal selection-change events during the
925
954
  // swap are swallowed by the selection-sync plugin.
926
955
  //
927
- // Scroll-anchor preservation (`preserveScrollAnchor: true`) is
928
- // currently OFF by default after the 2026-04-24 jump-to-top
929
- // regression report (see hotfix commit). Re-enable under a
930
- // diagnosed-safe codepath only; the capture/restore helpers
931
- // remain tested and ready.
956
+ // Scroll-anchor preservation is narrowly re-enabled only when a
957
+ // predicted edit receives a `surface-only` adjusted ack and the
958
+ // replacement stays in the same focused story with a live geometry
959
+ // facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
960
+ // would move by more than the small local-edit budget, the helper
961
+ // refuses the restore and leaves the browser/runtime position alone.
932
962
  //
933
963
  // Ordering invariant is regression-guarded by
934
964
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
965
+ const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
966
+ pendingRebuildScrollAnchorRef.current = null;
967
+ const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
968
+ policy: scrollAnchorPolicy,
969
+ view: viewRef.current,
970
+ geometryFacet: props.geometryFacet,
971
+ previousStory: lastBuiltStoryRef.current,
972
+ nextStory: snapshot.activeStory,
973
+ });
935
974
  replaceStatePreservingPosition(
936
975
  {
937
976
  view: viewRef.current,
938
977
  geometryFacet: props.geometryFacet,
939
978
  suppressionRef: suppressSelectionEchoRef,
979
+ preserveScrollAnchor,
980
+ maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
940
981
  },
941
982
  state,
942
983
  );
943
984
  }
944
985
  documentBuildKeyRef.current = documentBuildKey;
986
+ lastBuiltStoryRef.current = snapshot.activeStory;
945
987
  applyDecorationProps(viewRef.current, positionMap);
946
988
 
947
989
  if (activeSearchRef.current) {
@@ -172,7 +172,7 @@ function resolveBlockRangeFromOffsetSpan(input: {
172
172
  const block = blocks[index];
173
173
  if (!block) continue;
174
174
  if (block.from >= endOffset) break;
175
- if (block.from >= startOffset && block.from < endOffset) {
175
+ if (block.from < endOffset && block.to > startOffset) {
176
176
  if (first < 0) first = index;
177
177
  last = index;
178
178
  }
@@ -4,6 +4,7 @@ import { Check, MessageSquare, X } from "lucide-react";
4
4
  import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
5
5
  import { selectVisibleRevisions } from "../../ui/shared/revision-filters";
6
6
  import type { MarkupDisplay } from "../../ui/headless/comment-decoration-model";
7
+ import { getAuthorColor } from "../../ui/headless/revision-decoration-model";
7
8
 
8
9
  export interface TwRevisionSidebarProps {
9
10
  trackedChanges: TrackedChangesSnapshot;
@@ -37,6 +38,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
37
38
 
38
39
  const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
39
40
  const [authorFilter, setAuthorFilter] = React.useState<string | null>(null);
41
+ const activeCardRef = React.useRef<HTMLDivElement | null>(null);
40
42
 
41
43
  // Derive distinct authors from all visible revisions
42
44
  const authors = React.useMemo(() => {
@@ -79,6 +81,11 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
79
81
  }
80
82
  }, [filteredRevisions, typeFilter, authorFilter, props.onRejectAllChanges, props.onRejectRevision]);
81
83
 
84
+ React.useEffect(() => {
85
+ if (!activeRevisionId) return;
86
+ activeCardRef.current?.scrollIntoView({ block: "nearest" });
87
+ }, [activeRevisionId, filteredRevisions]);
88
+
82
89
  return (
83
90
  <div className="flex flex-col outline-none">
84
91
  {/* Stats header */}
@@ -119,17 +126,28 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
119
126
  <div className="space-y-2">
120
127
  {filteredRevisions.map((rev) => {
121
128
  const isActive = activeRevisionId === rev.revisionId;
129
+ const authorColor = getAuthorColor(rev.authorId);
122
130
 
123
131
  return (
124
132
  <div
125
133
  key={rev.revisionId}
126
- className={`w-full text-left flex rounded-md bg-surface/90 transition-colors ring-1 ring-border ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
134
+ ref={(node) => {
135
+ if (isActive) {
136
+ activeCardRef.current = node;
137
+ }
138
+ }}
139
+ className={`w-full text-left flex rounded-md bg-surface/90 transition-colors ring-1 ring-border ${isActive ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]" : "hover:bg-surface"}`}
140
+ style={
141
+ authorColor && isActive
142
+ ? { boxShadow: `0 0 0 1px ${authorColor}, var(--shadow-soft)` }
143
+ : undefined
144
+ }
127
145
  >
128
146
  <div className={`w-0.5 shrink-0 rounded-l-md ${
129
147
  rev.kind === "insertion" ? "bg-insert"
130
148
  : rev.kind === "deletion" ? "bg-danger"
131
149
  : "bg-tertiary"
132
- }`} />
150
+ }`} style={authorColor ? { backgroundColor: authorColor } : undefined} />
133
151
  <div className="flex-1 min-w-0">
134
152
  <button
135
153
  type="button"
@@ -140,7 +158,18 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
140
158
  <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
141
159
  <RevisionBadge status={rev.status} actionability={rev.actionability} />
142
160
  </div>
143
- <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
161
+ <p className="mb-1 flex items-center gap-1.5 text-[10px] text-tertiary">
162
+ {authorColor ? (
163
+ <span
164
+ aria-hidden="true"
165
+ className="h-2 w-2 rounded-full"
166
+ style={{ backgroundColor: authorColor }}
167
+ />
168
+ ) : null}
169
+ <span className="truncate">{rev.authorId}</span>
170
+ <span aria-hidden="true">·</span>
171
+ <span>{rev.createdAt}</span>
172
+ </p>
144
173
  {rev.excerpt ? (
145
174
  <p className={`text-[11px] ${
146
175
  rev.kind === "insertion" ? "text-insert"
@@ -21,7 +21,7 @@ export interface ReviewRailState {
21
21
  * Review-rail open/close state + the responsive transition effect.
22
22
  *
23
23
  * When the responsive signature flips (narrow↔wide, or
24
- * `reviewRailAvailable` changes), the rail resets to its default open
24
+ * `reviewRailAvailable` changes), the rail resets to its default closed
25
25
  * state per `getInitialReviewRailOpen`. A ref guards the effect so it
26
26
  * only fires on actual transitions, not every viewport resize.
27
27
  *
@@ -181,6 +181,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
181
181
  // in the render tree below.
182
182
  const { bodySlotRef, pmSurfaceElement } = usePmSurfaceCapture();
183
183
  const { scrollRootRef, pageStackScrollRoot } = useScrollRootCapture();
184
+ const lastHoveredRevisionIdRef = useRef<string | null>(null);
184
185
  const caps = props.capabilities;
185
186
  const isPageWorkspace = props.workspaceMode === "page";
186
187
  const markupDisplay = props.markupDisplay;
@@ -248,6 +249,22 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
248
249
  reviewRailAvailable,
249
250
  viewportWidth,
250
251
  });
252
+ const handleDocumentMouseOver = useCallback(
253
+ (event: React.MouseEvent<HTMLDivElement>) => {
254
+ const element = event.target as HTMLElement | null;
255
+ const revisionId =
256
+ element?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id") ?? null;
257
+ if (!revisionId || (revisionId === lastHoveredRevisionIdRef.current && reviewRailOpen)) {
258
+ return;
259
+ }
260
+ lastHoveredRevisionIdRef.current = revisionId;
261
+ if (reviewRailAvailable) {
262
+ setReviewRailOpen(true);
263
+ props.onActiveRailTabChange?.("changes");
264
+ }
265
+ },
266
+ [props.onActiveRailTabChange, reviewRailAvailable, reviewRailOpen, setReviewRailOpen],
267
+ );
251
268
  // Incremented on zoom_changed / render_frame_ready so the placement
252
269
  // useMemo below re-executes when the render kernel emits new rects.
253
270
  const renderFrameRevision = useLayoutFacetRenderSignal(props.layoutFacet);
@@ -928,6 +945,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
928
945
  <div className="flex flex-1 flex-col min-w-0">
929
946
  <div
930
947
  ref={scrollRootRef}
948
+ onMouseOver={handleDocumentMouseOver}
931
949
  className="flex-1 overflow-y-auto bg-surface"
932
950
  data-wre-scroll-root="true"
933
951
  >