@plannotator/ui 0.34.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.
@@ -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
  />
@@ -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
 
@@ -1,15 +1,22 @@
1
1
  import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
2
 
3
+ /**
4
+ * A successful snapshot. `Extra` lets a host carry document metadata read
5
+ * alongside the bytes (Plannotator: the root document's version diff) through
6
+ * to `onSnapshot`; the hook never reads anything but `rawHtml`.
7
+ */
8
+ export type HtmlRefreshOkSnapshot<Extra extends object = object> = { status: 'ok'; rawHtml: string } & Extra;
9
+
3
10
  /** What a host's `fetchSnapshot` resolves to. */
4
- export type HtmlRefreshSnapshot =
5
- | { status: 'ok'; rawHtml: string }
11
+ export type HtmlRefreshSnapshot<Extra extends object = object> =
12
+ | HtmlRefreshOkSnapshot<Extra>
6
13
  | { status: 'missing' }
7
14
  | { status: 'unavailable' };
8
15
 
9
16
  /** The outcome of one `refresh()` call, for host notifications (toasts). */
10
17
  export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
11
18
 
12
- export interface UseHtmlRefreshOptions {
19
+ export interface UseHtmlRefreshOptions<Extra extends object = object> {
13
20
  /** Whether refresh is offered at all. Default true. */
14
21
  enabled?: boolean;
15
22
  /**
@@ -22,9 +29,11 @@ export interface UseHtmlRefreshOptions {
22
29
  documentKey?: string | null;
23
30
  /** Fetch the current bytes of the document. Called with `documentKey`.
24
31
  * A rejection is treated as `{ status: 'unavailable' }`. */
25
- fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot>;
26
- /** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). */
27
- onSnapshot: (rawHtml: string) => void;
32
+ fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot<Extra>>;
33
+ /** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). The
34
+ * whole successful snapshot is the second argument, for hosts whose
35
+ * `fetchSnapshot` reads metadata alongside the bytes. */
36
+ onSnapshot: (rawHtml: string, snapshot: HtmlRefreshOkSnapshot<Extra>) => void;
28
37
  /**
29
38
  * Once per refresh: the ids the remounted viewer could not re-anchor,
30
39
  * possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
@@ -57,14 +66,14 @@ export interface UseHtmlRefreshReturn {
57
66
  * restore acknowledgement is armed per reload generation and consumed by
58
67
  * the first viewer report for that generation.
59
68
  */
60
- export function useHtmlRefresh({
69
+ export function useHtmlRefresh<Extra extends object = object>({
61
70
  enabled = true,
62
71
  documentKey,
63
72
  fetchSnapshot,
64
73
  onSnapshot,
65
74
  onUnanchored,
66
75
  onResult,
67
- }: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
76
+ }: UseHtmlRefreshOptions<Extra>): UseHtmlRefreshReturn {
68
77
  const [isRefreshing, setIsRefreshing] = useState(false);
69
78
  const [reloadGeneration, setReloadGeneration] = useState(0);
70
79
  const keyed = documentKey !== undefined;
@@ -98,7 +107,7 @@ export function useHtmlRefresh({
98
107
  // A rejecting fetch is an unavailable snapshot: the host hears it
99
108
  // through onResult like any other outcome, never as an unhandled
100
109
  // rejection out of refresh().
101
- let result: HtmlRefreshSnapshot;
110
+ let result: HtmlRefreshSnapshot<Extra>;
102
111
  try {
103
112
  result = await fetchSnapshot(requestKey);
104
113
  } catch {
@@ -111,7 +120,7 @@ export function useHtmlRefresh({
111
120
  return;
112
121
  }
113
122
 
114
- onSnapshot(result.rawHtml);
123
+ onSnapshot(result.rawHtml, result);
115
124
  const nextGeneration = reloadGenerationRef.current + 1;
116
125
  reloadGenerationRef.current = nextGeneration;
117
126
  // Armed until the remounted viewer's bridge reports its restore. The
@@ -76,6 +76,12 @@ interface UseSharingResult {
76
76
  clearShareLoadError: () => void;
77
77
  }
78
78
 
79
+ type ShortShareUrlLifecycle =
80
+ | { readonly _tag: 'none' }
81
+ | { readonly _tag: 'incoming-hydration' }
82
+ | { readonly _tag: 'generating'; readonly requestContext: object }
83
+ | { readonly _tag: 'associated'; readonly requestContext: object }
84
+ | { readonly _tag: 'failed'; readonly requestContext: object };
79
85
 
80
86
  // Share payloads are base64url-encoded deflate output: charset [A-Za-z0-9_-],
81
87
  // realistically >=30 chars, and virtually always mixed-case because deflate
@@ -133,6 +139,7 @@ export function useSharing(
133
139
  ]);
134
140
  const latestShareRequestContextRef = useRef(shareRequestContext);
135
141
  latestShareRequestContextRef.current = shareRequestContext;
142
+ const shortShareUrlLifecycleRef = useRef<ShortShareUrlLifecycle>({ _tag: 'none' });
136
143
 
137
144
  const clearPendingSharedAnnotations = useCallback(() => {
138
145
  setPendingSharedAnnotations(null);
@@ -148,6 +155,11 @@ export function useSharing(
148
155
  const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/);
149
156
  if (pathMatch) {
150
157
  const pasteId = pathMatch[1];
158
+ // Capture before the async fetch. Concurrent loads (including the
159
+ // development Strict Mode replay) can complete after an earlier load
160
+ // removes /p/<id> from history; every completion must preserve the
161
+ // original short URL.
162
+ const incomingShortUrl = window.location.href;
151
163
 
152
164
  // Extract key and optional paste origin from fragment: #key=<k>&paste=<base64url>
153
165
  const fragment = window.location.hash.slice(1);
@@ -180,7 +192,8 @@ export function useSharing(
180
192
 
181
193
  setPendingSharedAnnotations(restoredAnnotations);
182
194
  setIsSharedSession(true);
183
- setShortShareUrl(window.location.href);
195
+ shortShareUrlLifecycleRef.current = { _tag: 'incoming-hydration' };
196
+ setShortShareUrl(incomingShortUrl);
184
197
  onSharedLoad?.();
185
198
 
186
199
  // Remove the /p/<id> path from browser history so a refresh doesn't
@@ -294,17 +307,43 @@ export function useSharing(
294
307
  refreshShareUrl();
295
308
  }, [refreshShareUrl]);
296
309
 
297
- // Clear stale short URL when content changes (does NOT auto-regenerate —
298
- // the user must explicitly click "Create short link" again).
299
- // Skip on shared session load — the incoming short URL must survive.
300
- const isSharedRef = useRef(false);
310
+ // An incoming short URL becomes associated with the fully hydrated share
311
+ // context on its first committed render. From then on it follows the same
312
+ // lifecycle as a locally generated URL: any shareable-content change makes
313
+ // the immutable paste stale, so discard the URL without auto-uploading a
314
+ // replacement. Markdown users can still use the fresh hash URL; creating a
315
+ // new short link remains an explicit action.
301
316
  useEffect(() => {
302
- if (isSharedSession) { isSharedRef.current = true; return; }
303
- if (isSharedRef.current) { isSharedRef.current = false; return; }
317
+ const lifecycle = shortShareUrlLifecycleRef.current;
318
+ if (lifecycle._tag === 'incoming-hydration') {
319
+ if (!shortShareUrl) return;
320
+ // Hydration writes fresh annotation and attachment arrays, so this first
321
+ // committed request context represents the loaded snapshot. If those
322
+ // setters ever preserve identity, replace this consume-on-next-effect
323
+ // handoff with an explicit post-hydration signal.
324
+ shortShareUrlLifecycleRef.current = {
325
+ _tag: 'associated',
326
+ requestContext: shareRequestContext,
327
+ };
328
+ return;
329
+ }
330
+ if (
331
+ (
332
+ lifecycle._tag === 'generating'
333
+ || lifecycle._tag === 'associated'
334
+ || lifecycle._tag === 'failed'
335
+ )
336
+ && lifecycle.requestContext === shareRequestContext
337
+ ) {
338
+ return;
339
+ }
340
+ if (lifecycle._tag === 'none' && !shortShareUrl) return;
341
+
342
+ shortShareUrlLifecycleRef.current = { _tag: 'none' };
304
343
  setIsGeneratingShortUrl(false);
305
344
  setShortShareUrl('');
306
345
  setShortUrlError('');
307
- }, [markdown, annotations, globalAttachments, rawHtml, isSharedSession, contentRevision]);
346
+ }, [shareRequestContext, shortShareUrl]);
308
347
 
309
348
  /**
310
349
  * Generate a short URL via the paste service.
@@ -318,6 +357,10 @@ export function useSharing(
318
357
  setIsGeneratingShortUrl(true);
319
358
  setShortUrlError('');
320
359
  const requestContext = shareRequestContext;
360
+ shortShareUrlLifecycleRef.current = {
361
+ _tag: 'generating',
362
+ requestContext,
363
+ };
321
364
 
322
365
  try {
323
366
  const htmlForShare = rawHtml
@@ -334,15 +377,21 @@ export function useSharing(
334
377
  if (latestShareRequestContextRef.current !== requestContext) return null;
335
378
 
336
379
  if (result) {
380
+ shortShareUrlLifecycleRef.current = {
381
+ _tag: 'associated',
382
+ requestContext,
383
+ };
337
384
  setShortShareUrl(result.shortUrl);
338
385
  return result.shortUrl;
339
386
  } else {
387
+ shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
340
388
  setShortShareUrl('');
341
389
  setShortUrlError('Short URL service unavailable');
342
390
  return null;
343
391
  }
344
392
  } catch (e) {
345
393
  if (latestShareRequestContextRef.current !== requestContext) return null;
394
+ shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
346
395
  setShortShareUrl('');
347
396
  setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL');
348
397
  return null;
@@ -0,0 +1,83 @@
1
+ import { useRef } from 'react';
2
+ import {
3
+ createUndoHistoryState,
4
+ recordUndoAction,
5
+ takeRedoAction,
6
+ takeUndoAction,
7
+ type HistoryDirection,
8
+ type UndoHistoryState,
9
+ } from '../utils/undoHistory';
10
+
11
+ /** Imperative bounded history API used by surface-specific command adapters. */
12
+ export interface UndoHistoryApi<TAction> {
13
+ readonly canUndo: boolean;
14
+ readonly canRedo: boolean;
15
+ record: (action: TAction) => void;
16
+ undo: () => boolean;
17
+ redo: () => boolean;
18
+ clear: () => void;
19
+ }
20
+
21
+ interface UndoHistoryOptions<TAction> {
22
+ context: string;
23
+ apply: (action: TAction, direction: HistoryDirection) => void;
24
+ capacity?: number;
25
+ }
26
+
27
+ /**
28
+ * Keep one bounded stack for the active surface context while replaying
29
+ * actions through the latest adapter callbacks. A context change starts a
30
+ * fresh baseline synchronously, before any shortcut can reach the new view.
31
+ */
32
+ export function useUndoHistory<TAction>({
33
+ context,
34
+ apply,
35
+ capacity = 50,
36
+ }: UndoHistoryOptions<TAction>): UndoHistoryApi<TAction> {
37
+ const optionsRef = useRef({ apply, capacity });
38
+ optionsRef.current = { apply, capacity };
39
+ const historyRef = useRef<{
40
+ context: string;
41
+ state: UndoHistoryState<TAction>;
42
+ }>({ context, state: createUndoHistoryState<TAction>() });
43
+ if (historyRef.current.context !== context) {
44
+ historyRef.current = { context, state: createUndoHistoryState<TAction>() };
45
+ }
46
+ const apiRef = useRef<UndoHistoryApi<TAction> | null>(null);
47
+
48
+ if (!apiRef.current) {
49
+ apiRef.current = {
50
+ get canUndo() {
51
+ return historyRef.current.state.past.length > 0;
52
+ },
53
+ get canRedo() {
54
+ return historyRef.current.state.future.length > 0;
55
+ },
56
+ record(action) {
57
+ const history = historyRef.current;
58
+ history.state = recordUndoAction(history.state, action, optionsRef.current.capacity);
59
+ },
60
+ undo() {
61
+ const history = historyRef.current;
62
+ const step = takeUndoAction(history.state);
63
+ if (step.action === null) return false;
64
+ history.state = step.state;
65
+ optionsRef.current.apply(step.action, 'undo');
66
+ return true;
67
+ },
68
+ redo() {
69
+ const history = historyRef.current;
70
+ const step = takeRedoAction(history.state, optionsRef.current.capacity);
71
+ if (step.action === null) return false;
72
+ history.state = step.state;
73
+ optionsRef.current.apply(step.action, 'redo');
74
+ return true;
75
+ },
76
+ clear() {
77
+ historyRef.current.state = createUndoHistoryState<TAction>();
78
+ },
79
+ };
80
+ }
81
+
82
+ return apiRef.current;
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -74,7 +74,7 @@
74
74
  "@lezer/highlight": "^1.2.3",
75
75
  "@pierre/diffs": "1.3.2",
76
76
  "@plannotator/atomic-editor": "^0.8.0",
77
- "@plannotator/core": "0.25.0",
77
+ "@plannotator/core": "workspace:*",
78
78
  "@plannotator/markdown-editor": "^0.4.0",
79
79
  "@plannotator/web-highlighter": "^0.8.1",
80
80
  "@tanstack/react-table": "^8.21.3",
@@ -0,0 +1,25 @@
1
+ import { defineShortcutScope } from './core';
2
+ import { createShortcutScopeHook } from './runtime';
3
+
4
+ export const historyShortcuts = defineShortcutScope({
5
+ id: 'history',
6
+ title: 'History',
7
+ shortcuts: {
8
+ undo: {
9
+ description: 'Undo annotation change',
10
+ bindings: ['Mod+Z'],
11
+ section: 'History',
12
+ preventDefault: true,
13
+ displayOrder: 10,
14
+ },
15
+ redo: {
16
+ description: 'Redo annotation change',
17
+ bindings: ['Mod+Shift+Z', 'Mod+Y'],
18
+ section: 'History',
19
+ preventDefault: true,
20
+ displayOrder: 20,
21
+ },
22
+ },
23
+ });
24
+
25
+ export const useHistoryShortcuts = createShortcutScopeHook(historyShortcuts);
@@ -1,5 +1,6 @@
1
1
  export * from './core';
2
2
  export * from './runtime';
3
+ export { historyShortcuts, useHistoryShortcuts } from './history.shortcuts';
3
4
 
4
5
  // plan-review scopes
5
6
  export { annotationModeShortcuts, useAnnotationModeShortcuts } from './plan-review/annotationMode.shortcuts';
@@ -27,14 +27,23 @@ export const imageAnnotatorShortcuts = defineShortcutScope({
27
27
  description: 'Undo',
28
28
  bindings: ['Mod+Z'],
29
29
  section: 'Image Annotator',
30
+ preventDefault: true,
30
31
  displayOrder: 40,
31
32
  },
33
+ redo: {
34
+ description: 'Redo',
35
+ bindings: ['Mod+Shift+Z', 'Mod+Y'],
36
+ section: 'Image Annotator',
37
+ preventDefault: true,
38
+ displayOrder: 50,
39
+ },
32
40
  save: {
33
41
  description: 'Save and close annotator',
34
42
  bindings: ['Enter', 'Escape'],
35
43
  section: 'Image Annotator',
44
+ preventDefault: true,
36
45
  hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.',
37
- displayOrder: 50,
46
+ displayOrder: 60,
38
47
  },
39
48
  },
40
49
  });