@plannotator/ui 0.33.0 → 0.35.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.
@@ -2,8 +2,16 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { Canvas } from './Canvas';
3
3
  import { Toolbar } from './Toolbar';
4
4
  import { renderStroke } from './utils';
5
- import type { Point, AnnotatorState } from './types';
6
- import { DEFAULT_STATE } from './types';
5
+ import type { Point } from './types';
6
+ import { useImageAnnotatorShortcuts } from '../../shortcuts';
7
+ import {
8
+ DEFAULT_STROKE_HISTORY_STATE,
9
+ clearStrokeHistory,
10
+ recordStroke,
11
+ redoStroke,
12
+ undoStroke,
13
+ type StrokeHistoryState,
14
+ } from './strokeHistory';
7
15
 
8
16
  interface ImageAnnotatorProps {
9
17
  imageSrc: string;
@@ -21,7 +29,7 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
21
29
  onClose,
22
30
  initialName = '',
23
31
  }) => {
24
- const [state, setState] = useState<AnnotatorState>(DEFAULT_STATE);
32
+ const [state, setState] = useState<StrokeHistoryState>(DEFAULT_STROKE_HISTORY_STATE);
25
33
  const [saving, setSaving] = useState(false);
26
34
  const [name, setName] = useState(initialName);
27
35
  const imageRef = useRef<HTMLImageElement | null>(null);
@@ -30,51 +38,11 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
30
38
  // Reset state when dialog opens
31
39
  useEffect(() => {
32
40
  if (isOpen) {
33
- setState(DEFAULT_STATE);
41
+ setState(DEFAULT_STROKE_HISTORY_STATE);
34
42
  setName(initialName);
35
43
  }
36
44
  }, [isOpen, initialName]);
37
45
 
38
- // Keyboard shortcuts
39
- useEffect(() => {
40
- if (!isOpen) return;
41
-
42
- const handleKeyDown = (e: KeyboardEvent) => {
43
- // Don't intercept when typing in the name input
44
- const target = e.target as HTMLElement;
45
- if (target.tagName === 'INPUT') {
46
- if (e.key === 'Escape') {
47
- // Blur and let the next Escape close
48
- target.blur();
49
- e.preventDefault();
50
- }
51
- return;
52
- }
53
-
54
- // Escape or Enter to accept
55
- if (e.key === 'Escape' || e.key === 'Enter') {
56
- e.preventDefault();
57
- handleAccept();
58
- return;
59
- }
60
-
61
- // Cmd+Z to undo
62
- if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
63
- e.preventDefault();
64
- handleUndo();
65
- return;
66
- }
67
-
68
- // 1/2/3 to switch tools
69
- if (e.key === '1') setState(s => ({ ...s, tool: 'pen' }));
70
- if (e.key === '2') setState(s => ({ ...s, tool: 'arrow' }));
71
- if (e.key === '3') setState(s => ({ ...s, tool: 'circle' }));
72
- };
73
-
74
- window.addEventListener('keydown', handleKeyDown);
75
- return () => window.removeEventListener('keydown', handleKeyDown);
76
- }, [isOpen, state.strokes]);
77
-
78
46
  const handleStrokeStart = useCallback((point: Point) => {
79
47
  const id = crypto.randomUUID();
80
48
  setState(s => ({
@@ -107,27 +75,20 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
107
75
  if (!s.currentStroke || s.currentStroke.points.length < 2) {
108
76
  return { ...s, currentStroke: null };
109
77
  }
110
- return {
111
- ...s,
112
- strokes: [...s.strokes, s.currentStroke],
113
- currentStroke: null,
114
- };
78
+ return recordStroke(s, s.currentStroke);
115
79
  });
116
80
  }, []);
117
81
 
118
82
  const handleUndo = useCallback(() => {
119
- setState(s => ({
120
- ...s,
121
- strokes: s.strokes.slice(0, -1),
122
- }));
83
+ setState(undoStroke);
84
+ }, []);
85
+
86
+ const handleRedo = useCallback(() => {
87
+ setState(redoStroke);
123
88
  }, []);
124
89
 
125
90
  const handleClear = useCallback(() => {
126
- setState(s => ({
127
- ...s,
128
- strokes: [],
129
- currentStroke: null,
130
- }));
91
+ setState(clearStrokeHistory);
131
92
  }, []);
132
93
 
133
94
  const handleImageLoad = useCallback((img: HTMLImageElement) => {
@@ -159,7 +120,8 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
159
120
 
160
121
  // Composite image + drawings
161
122
  const canvas = document.createElement('canvas');
162
- const ctx = canvas.getContext('2d')!;
123
+ const ctx = canvas.getContext('2d');
124
+ if (!ctx) throw new Error('Canvas 2D context is unavailable');
163
125
 
164
126
  canvas.width = img.naturalWidth;
165
127
  canvas.height = img.naturalHeight;
@@ -190,6 +152,29 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
190
152
  }
191
153
  };
192
154
 
155
+ const keyboardTargetIsInput = (event: KeyboardEvent): boolean =>
156
+ event.composedPath()[0] instanceof HTMLInputElement;
157
+
158
+ useImageAnnotatorShortcuts({
159
+ handlers: {
160
+ penTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'pen' })) },
161
+ arrowTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'arrow' })) },
162
+ circleTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'circle' })) },
163
+ undo: {
164
+ when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.strokes.length > 0,
165
+ handle: handleUndo,
166
+ },
167
+ redo: {
168
+ when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.futureStrokes.length > 0,
169
+ handle: handleRedo,
170
+ },
171
+ save: {
172
+ when: (event) => isOpen && !saving && !keyboardTargetIsInput(event),
173
+ handle: () => { void handleAccept(); },
174
+ },
175
+ },
176
+ });
177
+
193
178
  const handleBackdropClick = (e: React.MouseEvent) => {
194
179
  if (e.target === e.currentTarget) {
195
180
  handleAccept();
@@ -212,10 +197,12 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
212
197
  color={state.color}
213
198
  strokeSize={state.strokeSize}
214
199
  canUndo={state.strokes.length > 0}
200
+ canRedo={state.futureStrokes.length > 0}
215
201
  onToolChange={(tool) => setState(s => ({ ...s, tool }))}
216
202
  onColorChange={(color) => setState(s => ({ ...s, color }))}
217
203
  onStrokeSizeChange={(strokeSize) => setState(s => ({ ...s, strokeSize }))}
218
204
  onUndo={handleUndo}
205
+ onRedo={handleRedo}
219
206
  onClear={handleClear}
220
207
  onSave={handleAccept}
221
208
  />
@@ -242,6 +229,11 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
242
229
  value={name}
243
230
  onChange={(e) => setName(e.target.value)}
244
231
  onKeyDown={(e) => {
232
+ if (e.key === 'Escape') {
233
+ e.preventDefault();
234
+ e.currentTarget.blur();
235
+ return;
236
+ }
245
237
  if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
246
238
  e.preventDefault();
247
239
  handleAccept();
@@ -0,0 +1,58 @@
1
+ import { DEFAULT_STATE, type AnnotatorState, type Stroke } from './types';
2
+
3
+ /** Internal image-annotator state that adds redo without widening the published state contract. */
4
+ export interface StrokeHistoryState extends AnnotatorState {
5
+ /** Strokes removed by undo, newest redo candidate last. */
6
+ futureStrokes: Stroke[];
7
+ }
8
+
9
+ /** Initial state for the image annotator's internal stroke history. */
10
+ export const DEFAULT_STROKE_HISTORY_STATE: StrokeHistoryState = {
11
+ ...DEFAULT_STATE,
12
+ futureStrokes: [],
13
+ };
14
+
15
+ /** Commit a completed stroke and invalidate the abandoned redo branch. */
16
+ export function recordStroke(state: StrokeHistoryState, stroke: Stroke): StrokeHistoryState {
17
+ return {
18
+ ...state,
19
+ strokes: [...state.strokes, stroke],
20
+ futureStrokes: [],
21
+ currentStroke: null,
22
+ };
23
+ }
24
+
25
+ /** Move the latest visible stroke to the redo stack. */
26
+ export function undoStroke(state: StrokeHistoryState): StrokeHistoryState {
27
+ const stroke = state.strokes.at(-1);
28
+ if (!stroke) return state;
29
+ return {
30
+ ...state,
31
+ strokes: state.strokes.slice(0, -1),
32
+ futureStrokes: [...state.futureStrokes, stroke],
33
+ currentStroke: null,
34
+ };
35
+ }
36
+
37
+ /** Restore the latest stroke removed by undo. */
38
+ export function redoStroke(state: StrokeHistoryState): StrokeHistoryState {
39
+ const stroke = state.futureStrokes.at(-1);
40
+ if (!stroke) return state;
41
+ return {
42
+ ...state,
43
+ strokes: [...state.strokes, stroke],
44
+ futureStrokes: state.futureStrokes.slice(0, -1),
45
+ currentStroke: null,
46
+ };
47
+ }
48
+
49
+ /** Clear the canvas and invalidate both stroke branches. */
50
+ export function clearStrokeHistory(state: StrokeHistoryState): StrokeHistoryState {
51
+ if (state.strokes.length === 0 && state.futureStrokes.length === 0 && state.currentStroke === null) return state;
52
+ return {
53
+ ...state,
54
+ strokes: [],
55
+ futureStrokes: [],
56
+ currentStroke: null,
57
+ };
58
+ }
@@ -9,6 +9,12 @@ import {
9
9
  loadMermaidRuntime,
10
10
  __setMermaidRuntimeLoaderForTests,
11
11
  } from '../utils/mermaid';
12
+ import { loadMathRenderer } from '../utils/math';
13
+ import { hasMermaidMath } from '../utils/mermaid-math-slot';
14
+ import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
15
+
16
+ /** One Retry re-attempts every block whose runtime import failed (see utils/runtimeRetry). */
17
+ const mermaidRetryEpoch = createRuntimeRetryEpoch();
12
18
 
13
19
  // Re-exported: the config pin test and the lazy-retry test import them from here.
14
20
  export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
@@ -133,6 +139,16 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
133
139
  const [retryToken, setRetryToken] = useState(0);
134
140
  const [showSource, setShowSource] = useState(false);
135
141
  const [isExpanded, setIsExpanded] = useState(false);
142
+ // A sibling's Retry re-attempts this block too, but only while its own
143
+ // failure was the shared runtime import; a healthy block or a diagram
144
+ // syntax error is left alone.
145
+ const runtimeUnavailableRef = useRef(runtimeUnavailable);
146
+ runtimeUnavailableRef.current = runtimeUnavailable;
147
+ useEffect(() => mermaidRetryEpoch.subscribe(() => {
148
+ if (!runtimeUnavailableRef.current) return;
149
+ setError(null);
150
+ setRetryToken((token) => token + 1);
151
+ }), []);
136
152
 
137
153
  // All zoom/pan state as refs to avoid re-renders
138
154
  const zoomLevelRef = useRef(1);
@@ -208,6 +224,20 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
208
224
  return;
209
225
  }
210
226
  try {
227
+ // A `$$` label makes Mermaid render KaTeX. On a host that redirects
228
+ // Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
229
+ // typeset through the math slot, which must be filled by then: warm
230
+ // it with the registered loader first. A filled slot (Plannotator's
231
+ // eager entry) resolves at once; a load failure is left to the
232
+ // render, whose error panel names it with the source.
233
+ if (hasMermaidMath(block.content)) {
234
+ try {
235
+ await loadMathRenderer();
236
+ } catch {
237
+ // Reported by the render below.
238
+ }
239
+ if (cancelled) return;
240
+ }
211
241
  const id = `mermaid-${block.id}`;
212
242
  const { svg: renderedSvg } = await mermaid.render(id, block.content);
213
243
  if (!cancelled) {
@@ -443,10 +473,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
443
473
  {runtimeUnavailable && (
444
474
  <button
445
475
  type="button"
446
- onClick={() => {
447
- setError(null);
448
- setRetryToken((token) => token + 1);
449
- }}
476
+ onClick={() => mermaidRetryEpoch.bump()}
450
477
  className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
451
478
  title="Retry loading the diagram renderer"
452
479
  >
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
40
40
  }
41
41
 
42
42
  export interface CompactPlanAction {
43
- id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'annotations' | 'ai' | 'review';
43
+ id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review';
44
44
  label: string;
45
45
  subtitle?: string;
46
46
  onSelect: () => void;
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
3
3
  import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
4
4
  import type { Origin } from '@plannotator/core/agents';
5
5
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
6
- import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
6
+ import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
7
7
  import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
8
8
  import { loadDiffFont } from '../utils/diffFonts';
9
9
  import { TaterSpritePullup } from './TaterSpritePullup';
@@ -375,8 +375,23 @@ function ReviewAnalysisTab() {
375
375
  const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
376
376
  const defaultDiffType = useConfigValue('defaultDiffType');
377
377
  const reviewPanelView = useConfigValue('reviewPanelView');
378
+ const reviewAutoViewed = useConfigValue('reviewAutoViewed');
378
379
  return (
379
380
  <div className="space-y-5">
381
+ <div className="space-y-2">
382
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
383
+ Viewed files
384
+ </div>
385
+ {/* Never write `reviewAutoViewed` directly — setReviewAutoViewed also
386
+ consumes the first-time notice, since an explicit toggle is proof
387
+ the reviewer already found the switch. */}
388
+ <ToggleSwitch
389
+ checked={reviewAutoViewed}
390
+ onChange={(v) => setReviewAutoViewed(v)}
391
+ label="Auto-mark viewed"
392
+ description="Mark a file viewed when you scroll past it or move on to another file. Files you un-view stay un-viewed, and files that change on refresh become un-viewed."
393
+ />
394
+ </div>
380
395
  <div className="space-y-2">
381
396
  <div>
382
397
  <div className="text-sm font-medium">Default review view</div>
@@ -65,6 +65,8 @@ interface StickyHeaderLaneProps {
65
65
  mode: EditorMode;
66
66
  onModeChange: (mode: EditorMode) => void;
67
67
  taterMode?: boolean;
68
+ /** Omit the Quick Label tool in the compact toolstrip (mirrors AnnotationToolstripProps.hideQuickLabel). */
69
+ hideQuickLabel?: boolean;
68
70
 
69
71
  // Badge state
70
72
  repoInfo?: { display: string; branch?: string } | null;
@@ -95,6 +97,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
95
97
  mode,
96
98
  onModeChange,
97
99
  taterMode,
100
+ hideQuickLabel,
98
101
  repoInfo,
99
102
  planDiffStats,
100
103
  isPlanDiffActive,
@@ -252,6 +255,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
252
255
  mode={mode}
253
256
  onModeChange={onModeChange}
254
257
  taterMode={taterMode}
258
+ hideQuickLabel={hideQuickLabel}
255
259
  compact
256
260
  iconOnly={isNarrow || isToolstripIconOnly}
257
261
  />
@@ -281,9 +281,19 @@ export interface HtmlViewerProps {
281
281
  bridgeReadyTimeoutMs?: number;
282
282
  /** The bridge could not be established on the `bridgeScriptUrl` path (no
283
283
  * ready within the timeout, or a protocol version mismatch). The surface
284
- * shows its own banner as well; this lets the host react (telemetry, a
285
- * retry affordance). Never called on the inline path. */
284
+ * shows its own banner as well unless `bridgeErrorDisplay` is `'none'`;
285
+ * this lets the host react (telemetry, a retry affordance). Never called
286
+ * on the inline path. */
286
287
  onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
288
+ /**
289
+ * Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
290
+ * `'banner'` (default): the package renders its `[data-bridge-error]`
291
+ * strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
292
+ * the host owns the display through `onBridgeUnavailable`, which fires
293
+ * exactly as before (and a version mismatch still logs its one console
294
+ * warning). Meaningless on the inline path, which never shows a strip.
295
+ */
296
+ bridgeErrorDisplay?: "banner" | "none";
287
297
  }
288
298
 
289
299
  /**
@@ -329,6 +339,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
329
339
  bridgeScriptUrl,
330
340
  bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
331
341
  onBridgeUnavailable,
342
+ bridgeErrorDisplay = "banner",
332
343
  },
333
344
  ref,
334
345
  ) => {
@@ -1061,8 +1072,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1061
1072
  {/* bridgeScriptUrl path only: the bridge did not come up (no
1062
1073
  ready within the timeout, or a stale asset's version). Floated
1063
1074
  over the top of the iframe so it never changes the layout the
1064
- page renders in; the page itself stays visible. */}
1065
- {bridgeError && !bridgeErrorDismissed && (
1075
+ page renders in; the page itself stays visible. A host that
1076
+ renders its own notice from onBridgeUnavailable passes
1077
+ bridgeErrorDisplay="none" and no strip is rendered at all. */}
1078
+ {bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
1066
1079
  <div
1067
1080
  role="alert"
1068
1081
  data-print-hide
@@ -55,12 +55,12 @@ function nextHtmlAnnId(): string {
55
55
  return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
56
56
  }
57
57
 
58
- /** Ids minted by this module for locally created annotations (create-mark).
59
- * Module-scoped like the sequence above: a host that swaps a local id for
60
- * its own server id keeps the local mark until it removes it, and the
61
- * unanchored union needs to recognise such ids whichever viewer instance
62
- * minted them. Bounded: only ids from this page load, one entry per create. */
63
- const mintedHtmlAnnIds = new Set<string>();
58
+ // Ids minted for locally created annotations (create-mark) live per hook
59
+ // instance (mintedIdsRef below), not per module: the unanchored union only
60
+ // consults them against the bridge of the instance that minted them (a
61
+ // remounted viewer restores from the host's list and never reports an id it
62
+ // was not asked for), and a module-wide set leaked every id ever minted into
63
+ // unrelated later instances of a long-lived host page.
64
64
 
65
65
  function htmlCommentDraftKey(
66
66
  text: string,
@@ -463,6 +463,15 @@ export function useHtmlAnnotation({
463
463
  enabledRef.current = enabled;
464
464
  const modeRef = useRef(mode);
465
465
  modeRef.current = mode;
466
+ // Ids this instance minted (see the module comment above nextHtmlAnnId);
467
+ // released on unmount so nothing outlives the viewer that created it.
468
+ const mintedIdsRef = useRef<Set<string>>(new Set());
469
+ useEffect(() => {
470
+ const minted = mintedIdsRef.current;
471
+ return () => {
472
+ minted.clear();
473
+ };
474
+ }, []);
466
475
  // Mirror toolbar visibility into a ref so the (stable) message handler can gate
467
476
  // type-to-comment on "the markup toolbar is showing", like AnnotationToolbar does.
468
477
  const toolbarStateRef = useRef(toolbarState);
@@ -805,7 +814,7 @@ export function useHtmlAnnotation({
805
814
  if (!text || type !== AnnotationType.DELETION) return;
806
815
 
807
816
  const id = nextHtmlAnnId();
808
- mintedHtmlAnnIds.add(id);
817
+ mintedIdsRef.current.add(id);
809
818
  post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
810
819
  onAddRef.current?.({
811
820
  id,
@@ -864,7 +873,7 @@ export function useHtmlAnnotation({
864
873
  : undefined;
865
874
 
866
875
  const id = nextHtmlAnnId();
867
- mintedHtmlAnnIds.add(id);
876
+ mintedIdsRef.current.add(id);
868
877
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
869
878
  onAddRef.current?.({
870
879
  id,
@@ -910,7 +919,7 @@ export function useHtmlAnnotation({
910
919
  : undefined;
911
920
 
912
921
  const id = nextHtmlAnnId();
913
- mintedHtmlAnnIds.add(id);
922
+ mintedIdsRef.current.add(id);
914
923
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
915
924
  onAddRef.current?.({
916
925
  id,
@@ -970,7 +979,7 @@ export function useHtmlAnnotation({
970
979
  const text = pendingTextRef.current;
971
980
  if (!text) return;
972
981
  const id = nextHtmlAnnId();
973
- mintedHtmlAnnIds.add(id);
982
+ mintedIdsRef.current.add(id);
974
983
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
975
984
  onAddRef.current?.({
976
985
  id,
@@ -1067,6 +1076,6 @@ export function useHtmlAnnotation({
1067
1076
  removeDraftTarget,
1068
1077
  flashDraftTarget,
1069
1078
  composerFocusToken,
1070
- createdAnnotationIds: mintedHtmlAnnIds,
1079
+ createdAnnotationIds: mintedIdsRef.current,
1071
1080
  };
1072
1081
  }
package/config/index.ts CHANGED
@@ -5,5 +5,8 @@ export {
5
5
  setReviewPanelView,
6
6
  setReviewDefaultDiffType,
7
7
  getPersistedReviewPanelView,
8
+ setReviewAutoViewed,
9
+ needsAutoViewedNotice,
10
+ markAutoViewedNoticeSeen,
8
11
  type ReviewDefaultDiffType,
9
12
  } from './reviewView';
@@ -1,5 +1,6 @@
1
1
  import { configStore } from './configStore';
2
2
  import { SETTINGS } from './settings';
3
+ import { storage } from '../utils/storage';
3
4
 
4
5
  /**
5
6
  * The ONLY writers for the coupled setting pair (reviewPanelView,
@@ -73,3 +74,39 @@ export function setReviewDefaultDiffType(
73
74
  store.set('reviewPanelViewLastUsed', 'tree');
74
75
  }
75
76
  }
77
+
78
+
79
+ /**
80
+ * One-time gate for the auto-mark-viewed notice — the toast that fires the
81
+ * FIRST time auto-view actually marks a file, i.e. the moment the feature
82
+ * demonstrates itself. Cookie-based, mirroring the other review first-run
83
+ * gates, so it survives the random port each session runs on. Versioned so a
84
+ * meaningful revision can re-show it.
85
+ *
86
+ * It lives beside the setting rather than in the review app because BOTH
87
+ * writers of the setting (Settings > Git here, and the file-list gear in the
88
+ * review app) must consume the gate: someone who found the switch has
89
+ * demonstrably discovered the feature and must never be told about it.
90
+ */
91
+ const AUTO_VIEWED_NOTICE_SEEN_KEY = 'plannotator-auto-viewed-notice-seen';
92
+ const AUTO_VIEWED_NOTICE_VERSION = '1';
93
+
94
+ export function needsAutoViewedNotice(): boolean {
95
+ return storage.getItem(AUTO_VIEWED_NOTICE_SEEN_KEY) !== AUTO_VIEWED_NOTICE_VERSION;
96
+ }
97
+
98
+ export function markAutoViewedNoticeSeen(): void {
99
+ storage.setItem(AUTO_VIEWED_NOTICE_SEEN_KEY, AUTO_VIEWED_NOTICE_VERSION);
100
+ }
101
+
102
+ /**
103
+ * The only writer of `reviewAutoViewed` outside the notice's own "Turn off"
104
+ * action. Stamps the notice gate: an explicit toggle is proof of discovery.
105
+ */
106
+ export function setReviewAutoViewed(
107
+ value: boolean,
108
+ store: PanelViewConfigStore = configStore,
109
+ ): void {
110
+ markAutoViewedNoticeSeen();
111
+ store.set('reviewAutoViewed', value);
112
+ }
@@ -261,6 +261,21 @@ export const SETTINGS = {
261
261
  serverKey: undefined, fromServer: undefined, toServer: undefined,
262
262
  },
263
263
 
264
+ // Mark a file viewed when the reviewer scrolls past it or moves on to
265
+ // another file. Cookie-only like the other review-chrome preferences: it
266
+ // shapes how the local file list checks itself off and changes no review
267
+ // semantics (viewed gates nothing on submit).
268
+ reviewAutoViewed: {
269
+ defaultValue: true as boolean,
270
+ fromCookie: () => {
271
+ const value = storage.getItem('plannotator-review-auto-viewed');
272
+ return value === 'true' ? true : value === 'false' ? false : undefined;
273
+ },
274
+ toCookie: (value: boolean) =>
275
+ storage.setItem('plannotator-review-auto-viewed', String(value)),
276
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
277
+ },
278
+
264
279
  reviewShowStageControls: {
265
280
  defaultValue: true as boolean,
266
281
  fromCookie: () => {
@@ -16,6 +16,17 @@ interface DraftData {
16
16
  descriptionAnnotations?: Annotation[];
17
17
  commentAnnotations?: CommentAnnotation[];
18
18
  viewedFiles?: string[];
19
+ /**
20
+ * Files the reviewer manually un-viewed, which auto-mark-viewed must never
21
+ * re-check (the "come back to this" contract). Additive and optional: a
22
+ * draft written before this field restores fine, and a draft carrying it is
23
+ * ignored gracefully by an older build.
24
+ *
25
+ * Deliberately absent from `isEmpty` and from the engagement signal — a
26
+ * session whose only state is suppression is still an empty draft and is
27
+ * still cleared, keeping #948's clear-everything semantics untouched.
28
+ */
29
+ autoViewSuppressed?: string[];
19
30
  draftGeneration?: number;
20
31
  ts: number;
21
32
  }
@@ -40,13 +51,14 @@ interface UseCodeAnnotationDraftOptions {
40
51
  descriptionAnnotations?: Annotation[];
41
52
  commentAnnotations?: CommentAnnotation[];
42
53
  viewedFiles: Set<string>;
54
+ autoViewSuppressed?: Set<string>;
43
55
  isApiMode: boolean;
44
56
  submitted: boolean;
45
57
  }
46
58
 
47
59
  interface UseCodeAnnotationDraftResult {
48
60
  draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
49
- restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[] };
61
+ restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[] };
50
62
  getDraftGeneration: () => number;
51
63
  dismissDraft: () => void;
52
64
  }
@@ -56,6 +68,7 @@ export function useCodeAnnotationDraft({
56
68
  descriptionAnnotations = [],
57
69
  commentAnnotations = [],
58
70
  viewedFiles,
71
+ autoViewSuppressed,
59
72
  isApiMode,
60
73
  submitted,
61
74
  }: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
@@ -145,6 +158,9 @@ export function useCodeAnnotationDraft({
145
158
  descriptionAnnotations,
146
159
  commentAnnotations,
147
160
  viewedFiles: [...viewedFiles],
161
+ ...(autoViewSuppressed && autoViewSuppressed.size > 0
162
+ ? { autoViewSuppressed: [...autoViewSuppressed] }
163
+ : {}),
148
164
  draftGeneration,
149
165
  ts: Date.now(),
150
166
  };
@@ -155,7 +171,7 @@ export function useCodeAnnotationDraft({
155
171
  return () => {
156
172
  if (timerRef.current) clearTimeout(timerRef.current);
157
173
  };
158
- }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, isApiMode, submitted]);
174
+ }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted]);
159
175
 
160
176
  const restoreDraft = useCallback(() => {
161
177
  // Cancel any pending autosave so it can't fire with pre-restore state and
@@ -169,6 +185,7 @@ export function useCodeAnnotationDraft({
169
185
  descriptionAnnotations: data?.descriptionAnnotations ?? [],
170
186
  commentAnnotations: data?.commentAnnotations ?? [],
171
187
  viewedFiles: data?.viewedFiles ?? [],
188
+ autoViewSuppressed: data?.autoViewSuppressed ?? [],
172
189
  };
173
190
  }, []);
174
191