@plannotator/ui 0.29.1 → 0.31.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.
Files changed (74) hide show
  1. package/README.md +1 -0
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +68 -35
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +215 -39
  34. package/components/html-viewer/bridge-script.ts +384 -66
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +179 -58
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
@@ -20,6 +20,9 @@ import { OverlayScrollArea } from "../OverlayScrollArea";
20
20
  import { ReviewAgentsIcon } from "../ReviewAgentsIcon";
21
21
 
22
22
  interface SidebarContainerProps {
23
+ /** Desktop preserves the incumbent sticky rail. Compact Plan uses the same
24
+ * content as a safe, visible-viewport-bounded foreground surface. */
25
+ presentation?: "desktop" | "overlay";
23
26
  activeTab: SidebarTab;
24
27
  onTabChange: (tab: SidebarTab) => void;
25
28
  onClose: () => void;
@@ -29,6 +32,7 @@ interface SidebarContainerProps {
29
32
  isAgentTerminalRunning?: boolean;
30
33
  onToggleAgentTerminal?: () => void;
31
34
  // TOC props
35
+ showContentsTab?: boolean;
32
36
  blocks: Block[];
33
37
  annotations: Annotation[];
34
38
  activeSection: string | null;
@@ -45,6 +49,8 @@ interface SidebarContainerProps {
45
49
  onFilesSelectFile?: (absolutePath: string, dirPath: string) => void;
46
50
  onFilesFetchAll?: () => void;
47
51
  onFilesRetryVaultDir?: (vaultPath: string) => void;
52
+ /** Compact-only file activation feedback; desktop does not pass this. */
53
+ pendingFileLabel?: string | null;
48
54
  // Version Browser props
49
55
  showVersionsTab?: boolean;
50
56
  versionInfo: VersionInfo | null;
@@ -74,6 +80,7 @@ interface SidebarContainerProps {
74
80
  }
75
81
 
76
82
  export const SidebarContainer: React.FC<SidebarContainerProps> = ({
83
+ presentation = "desktop",
77
84
  activeTab,
78
85
  onTabChange,
79
86
  onClose,
@@ -82,6 +89,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
82
89
  isAgentTerminalOpen,
83
90
  isAgentTerminalRunning,
84
91
  onToggleAgentTerminal,
92
+ showContentsTab = true,
85
93
  blocks,
86
94
  annotations,
87
95
  activeSection,
@@ -97,6 +105,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
97
105
  onFilesSelectFile,
98
106
  onFilesFetchAll,
99
107
  onFilesRetryVaultDir,
108
+ pendingFileLabel,
100
109
  showVersionsTab,
101
110
  versionInfo,
102
111
  versions,
@@ -121,14 +130,87 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
121
130
  onSelectMessage,
122
131
  messageAnnotationCounts,
123
132
  }) => {
133
+ const compact = presentation === "overlay";
134
+ const closeButtonRef = React.useRef<HTMLButtonElement>(null);
135
+
136
+ React.useEffect(() => {
137
+ if (compact) closeButtonRef.current?.focus({ preventScroll: true });
138
+ }, [compact]);
139
+
140
+ const handleCompactKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
141
+ if (!compact) return;
142
+ if (event.key === "Escape") {
143
+ event.preventDefault();
144
+ event.stopPropagation();
145
+ onClose();
146
+ return;
147
+ }
148
+ if (event.key !== "Tab") return;
149
+ const focusable = Array.from(
150
+ event.currentTarget.querySelectorAll<HTMLElement>(
151
+ 'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
152
+ ),
153
+ );
154
+ if (focusable.length === 0) return;
155
+ const first = focusable[0];
156
+ const last = focusable[focusable.length - 1];
157
+ if (event.shiftKey && document.activeElement === first) {
158
+ event.preventDefault();
159
+ last?.focus();
160
+ } else if (!event.shiftKey && document.activeElement === last) {
161
+ event.preventDefault();
162
+ first?.focus();
163
+ }
164
+ };
165
+
124
166
  return (
125
167
  <aside
126
- className="hidden lg:flex flex-col sticky top-12 h-[calc(100vh-3rem)] flex-shrink-0 bg-card border-r border-border"
127
- style={{ width }}
168
+ id={compact ? "pn-compact-plan-navigator" : undefined}
169
+ data-pn-plan-navigator={compact ? "true" : undefined}
170
+ // The compact navigator (Contents / Versions / Archive) is the same kind
171
+ // of full-viewport transient surface as CompactPlanStage, so it must not
172
+ // print over the document either. Desktop rail printing is unchanged.
173
+ data-print-hide={compact ? true : undefined}
174
+ role={compact ? "dialog" : undefined}
175
+ aria-modal={compact ? true : undefined}
176
+ aria-label={compact ? "Plan navigator" : undefined}
177
+ onKeyDown={compact ? handleCompactKeyDown : undefined}
178
+ className={compact
179
+ ? "pn-visible-viewport-stage z-[90] flex flex-col overflow-hidden bg-card text-foreground"
180
+ : "hidden lg:flex flex-col sticky top-12 h-[calc(100vh-3rem)] flex-shrink-0 bg-card border-r border-border"}
181
+ style={{ width: compact ? undefined : width }}
128
182
  >
183
+ {compact && (
184
+ <div className="flex h-[52px] flex-shrink-0 items-center justify-between border-b border-border/50 px-3">
185
+ <div className="min-w-0">
186
+ <p className="text-sm font-semibold tracking-tight">Navigate</p>
187
+ <p className="truncate text-[11px] text-muted-foreground" aria-live="polite">
188
+ {pendingFileLabel ? `Opening ${pendingFileLabel}…` : 'Choose what to review'}
189
+ </p>
190
+ </div>
191
+ <button
192
+ ref={closeButtonRef}
193
+ type="button"
194
+ onClick={onClose}
195
+ data-pn-touch-target="true"
196
+ data-pn-touch-target-icon="true"
197
+ className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
198
+ aria-label="Close navigator"
199
+ title="Close navigator"
200
+ >
201
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
202
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
203
+ </svg>
204
+ </button>
205
+ </div>
206
+ )}
207
+
129
208
  {/* Tab bar */}
130
- <div className="flex h-10 items-center border-b border-border/50 px-2 gap-0.5 flex-shrink-0 overflow-hidden min-w-0">
131
- {showAgentTerminalButton && onToggleAgentTerminal && (
209
+ <div className={compact
210
+ ? "flex min-h-11 items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-1"
211
+ : "flex h-10 items-center border-b border-border/50 px-2 gap-0.5 flex-shrink-0 overflow-hidden min-w-0"}
212
+ >
213
+ {!compact && showAgentTerminalButton && onToggleAgentTerminal && (
132
214
  <ActionButton
133
215
  active={!!isAgentTerminalOpen}
134
216
  running={!!isAgentTerminalRunning}
@@ -137,26 +219,29 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
137
219
  label="Agent"
138
220
  />
139
221
  )}
140
- <TabButton
141
- active={activeTab === "toc"}
142
- onClick={() => onTabChange("toc")}
143
- icon={
144
- <svg
145
- className="w-3 h-3"
146
- fill="none"
147
- viewBox="0 0 24 24"
148
- stroke="currentColor"
149
- strokeWidth={2}
150
- >
151
- <path
152
- strokeLinecap="round"
153
- strokeLinejoin="round"
154
- d="M4 6h16M4 10h16M4 14h10M4 18h10"
155
- />
156
- </svg>
157
- }
158
- label="Contents"
159
- />
222
+ {showContentsTab && (
223
+ <TabButton
224
+ active={activeTab === "toc"}
225
+ onClick={() => onTabChange("toc")}
226
+ icon={
227
+ <svg
228
+ className="w-3 h-3"
229
+ fill="none"
230
+ viewBox="0 0 24 24"
231
+ stroke="currentColor"
232
+ strokeWidth={2}
233
+ >
234
+ <path
235
+ strokeLinecap="round"
236
+ strokeLinejoin="round"
237
+ d="M4 6h16M4 10h16M4 14h10M4 18h10"
238
+ />
239
+ </svg>
240
+ }
241
+ label="Contents"
242
+ touch={compact}
243
+ />
244
+ )}
160
245
  {showVersionsTab && (
161
246
  <TabButton
162
247
  active={activeTab === "versions"}
@@ -177,6 +262,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
177
262
  </svg>
178
263
  }
179
264
  label="Versions"
265
+ touch={compact}
180
266
  />
181
267
  )}
182
268
  {showMessagesTab && (
@@ -186,6 +272,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
186
272
  icon={<MessagesIcon className="w-3 h-3" />}
187
273
  label="Messages"
188
274
  badge={messageAnnotationCounts !== undefined && messageAnnotationCounts.size > 0}
275
+ touch={compact}
189
276
  />
190
277
  )}
191
278
  {showFilesTab && (
@@ -209,6 +296,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
209
296
  }
210
297
  label="Files"
211
298
  badge={hasFileAnnotations}
299
+ touch={compact}
212
300
  />
213
301
  )}
214
302
  {showArchiveTab && (
@@ -231,6 +319,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
231
319
  </svg>
232
320
  }
233
321
  label="Archive"
322
+ touch={compact}
234
323
  />
235
324
  )}
236
325
  {/* No header close button — the sidebar collapses via the resize-handle
@@ -238,8 +327,11 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
238
327
  </div>
239
328
 
240
329
  {/* Content area */}
241
- <OverlayScrollArea className="flex-1 min-h-0">
242
- {activeTab === "toc" && (
330
+ <OverlayScrollArea className={compact
331
+ ? "flex-1 min-h-0 overscroll-contain [-webkit-overflow-scrolling:touch]"
332
+ : "flex-1 min-h-0"}
333
+ >
334
+ {activeTab === "toc" && showContentsTab && (
243
335
  <TableOfContents
244
336
  blocks={blocks}
245
337
  annotations={annotations}
@@ -280,6 +372,7 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
280
372
  annotationCounts={fileAnnotationCounts}
281
373
  highlightedFiles={highlightedFiles}
282
374
  editStatuses={fileEditStatuses}
375
+ selectionPending={compact && !!pendingFileLabel}
283
376
  />
284
377
  )}
285
378
  {activeTab === "archive" && showArchiveTab && (
@@ -309,10 +402,13 @@ const TabButton: React.FC<{
309
402
  icon: React.ReactNode;
310
403
  label: string;
311
404
  badge?: boolean;
312
- }> = ({ active, onClick, icon, label, badge }) => (
405
+ touch?: boolean;
406
+ }> = ({ active, onClick, icon, label, badge, touch = false }) => (
313
407
  <button
408
+ type="button"
314
409
  onClick={onClick}
315
- className={`relative flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors min-w-0 shrink-0 ${
410
+ data-pn-touch-target={touch ? "true" : undefined}
411
+ className={`relative flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors min-w-0 shrink-0${touch ? " h-9" : ""} ${
316
412
  active
317
413
  ? "bg-primary/10 text-primary"
318
414
  : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
@@ -18,15 +18,15 @@ const buttonVariants = cva(
18
18
  {
19
19
  variants: {
20
20
  variant: {
21
- default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
21
+ default: "bg-primary text-primary-foreground shadow-xs [@media(hover:hover)_and_(pointer:fine)]:hover:bg-primary/90",
22
22
  destructive:
23
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
24
- success: "bg-success text-success-foreground shadow-xs hover:bg-success/90",
23
+ "bg-destructive text-white shadow-xs [@media(hover:hover)_and_(pointer:fine)]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
24
+ success: "bg-success text-success-foreground shadow-xs [@media(hover:hover)_and_(pointer:fine)]:hover:bg-success/90",
25
25
  outline:
26
- "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
27
- secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
28
- ghost: "hover:bg-accent hover:text-accent-foreground",
29
- link: "text-primary underline-offset-4 hover:underline",
26
+ "border border-input bg-background shadow-xs [@media(hover:hover)_and_(pointer:fine)]:hover:bg-accent [@media(hover:hover)_and_(pointer:fine)]:hover:text-accent-foreground",
27
+ secondary: "bg-secondary text-secondary-foreground shadow-xs [@media(hover:hover)_and_(pointer:fine)]:hover:bg-secondary/80",
28
+ ghost: "[@media(hover:hover)_and_(pointer:fine)]:hover:bg-accent [@media(hover:hover)_and_(pointer:fine)]:hover:text-accent-foreground",
29
+ link: "text-primary underline-offset-4 [@media(hover:hover)_and_(pointer:fine)]:hover:underline",
30
30
  },
31
31
  size: {
32
32
  default: "h-9 px-4 py-2",
@@ -61,9 +61,11 @@ function Button({
61
61
  }) {
62
62
  return (
63
63
  <ButtonPrimitive
64
+ {...props}
64
65
  data-slot="button"
66
+ data-pn-touch-target="true"
67
+ data-pn-touch-target-icon={size === "icon" ? "true" : undefined}
65
68
  className={cn(buttonVariants({ variant, size, className }))}
66
- {...props}
67
69
  >
68
70
  {iconLeft ? (
69
71
  <span data-slot="button-icon" data-side="left" aria-hidden="true" className="shrink-0">
@@ -14,6 +14,7 @@ const DialogOverlay = React.forwardRef<
14
14
  >(({ className, ...props }, ref) => (
15
15
  <DialogPrimitive.Backdrop
16
16
  ref={ref}
17
+ data-slot="dialog-overlay"
17
18
  className={cn(
18
19
  "fixed inset-0 z-[110] bg-black/55 backdrop-blur-[2px]",
19
20
  "transition-opacity duration-200",
@@ -28,34 +29,43 @@ DialogOverlay.displayName = "DialogOverlay";
28
29
  const DialogContent = React.forwardRef<
29
30
  React.ComponentRef<typeof DialogPrimitive.Popup>,
30
31
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Popup> & {
32
+ /** Optional visual override for the backdrop rendered with this content. */
33
+ backdropClassName?: string;
34
+ /** Removes the default top-right close control. */
31
35
  hideClose?: boolean;
32
36
  }
33
- >(({ className, children, hideClose, ...props }, ref) => (
37
+ >(({ backdropClassName, className, children, hideClose, ...props }, ref) => (
34
38
  <DialogPortal>
35
- <DialogOverlay />
36
- <DialogPrimitive.Popup
37
- ref={ref}
38
- className={cn(
39
- "fixed top-[50%] left-[50%] z-[110] w-full translate-x-[-50%] translate-y-[-50%]",
40
- "max-w-4xl max-h-[min(640px,85vh)]",
41
- "flex flex-col overflow-hidden",
42
- "rounded-2xl border border-border bg-popover text-popover-foreground",
43
- "shadow-[0_24px_80px_-36px_rgba(15,23,42,0.5)]",
44
- "transition-[opacity,scale] duration-200",
45
- "data-starting-style:opacity-0 data-starting-style:scale-95",
46
- "data-ending-style:opacity-0 data-ending-style:scale-95",
47
- className,
48
- )}
49
- {...props}
50
- >
51
- {children}
52
- {!hideClose && (
53
- <DialogPrimitive.Close className="absolute top-3.5 right-3.5 rounded-md p-1.5 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring/50">
54
- <X className="size-4" />
55
- <span className="sr-only">Close</span>
56
- </DialogPrimitive.Close>
57
- )}
58
- </DialogPrimitive.Popup>
39
+ <DialogOverlay className={backdropClassName} />
40
+ <div className="pn-visible-viewport-overlay z-[110] pointer-events-none flex items-center justify-center">
41
+ <DialogPrimitive.Popup
42
+ ref={ref}
43
+ className={cn(
44
+ "relative pointer-events-auto z-[110] w-full min-h-0",
45
+ "max-w-4xl max-h-[min(640px,85vh,100%)]",
46
+ "flex flex-col overflow-hidden",
47
+ "rounded-2xl border border-border bg-popover text-popover-foreground",
48
+ "shadow-[0_24px_80px_-36px_rgba(15,23,42,0.5)]",
49
+ "transition-[opacity,scale] duration-200",
50
+ "data-starting-style:opacity-0 data-starting-style:scale-95",
51
+ "data-ending-style:opacity-0 data-ending-style:scale-95",
52
+ className,
53
+ )}
54
+ {...props}
55
+ >
56
+ {children}
57
+ {!hideClose && (
58
+ <DialogPrimitive.Close
59
+ data-pn-touch-target="true"
60
+ data-pn-touch-target-icon="true"
61
+ className="absolute top-3.5 right-3.5 inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring/50"
62
+ >
63
+ <X className="size-4" />
64
+ <span className="sr-only">Close</span>
65
+ </DialogPrimitive.Close>
66
+ )}
67
+ </DialogPrimitive.Popup>
68
+ </div>
59
69
  </DialogPortal>
60
70
  ));
61
71
  DialogContent.displayName = "DialogContent";
package/config/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export { configStore } from './configStore';
2
2
  export type { ServerSyncFn } from './configStore';
3
3
  export { useConfigValue } from './useConfig';
4
- export { setReviewPanelView, setReviewDefaultDiffType, type ReviewDefaultDiffType } from './reviewView';
4
+ export {
5
+ setReviewPanelView,
6
+ setReviewDefaultDiffType,
7
+ getPersistedReviewPanelView,
8
+ type ReviewDefaultDiffType,
9
+ } from './reviewView';
@@ -1,4 +1,5 @@
1
1
  import { configStore } from './configStore';
2
+ import { SETTINGS } from './settings';
2
3
 
3
4
  /**
4
5
  * The ONLY writers for the coupled setting pair (reviewPanelView,
@@ -17,13 +18,39 @@ import { configStore } from './configStore';
17
18
  * setting directly — always go through these setters. configStore.init()
18
19
  * remains the one non-writer that can produce a conflicted pair from a
19
20
  * stale config.json; the App-level load reconciler heals that case by
20
- * calling setReviewPanelView('sections').
21
+ * calling setReviewPanelView('sections', { recordLastUsed: false }).
21
22
  */
22
- export function setReviewPanelView(view: 'sections' | 'tree'): void {
23
- configStore.set('reviewPanelView', view);
24
- if (view === 'sections' && configStore.get('defaultDiffType') !== 'since-base') {
25
- configStore.set('defaultDiffType', 'since-base');
23
+
24
+ /** Store seam for tests (fresh ConfigStoreForTest); production always uses the singleton. */
25
+ type PanelViewConfigStore = typeof configStore;
26
+
27
+ export function setReviewPanelView(
28
+ view: 'sections' | 'tree',
29
+ options?: { recordLastUsed?: boolean },
30
+ store: PanelViewConfigStore = configStore,
31
+ ): void {
32
+ store.set('reviewPanelView', view);
33
+ // An explicit persisted choice also becomes the last-used view — otherwise
34
+ // a stale last-used cookie would immediately shadow what the user just
35
+ // picked in Settings / the setup dialog. recordLastUsed: false is for
36
+ // NON-choices: the App self-heal repairs a conflicted persisted pair
37
+ // without any user action, so it must not overwrite the user's memo.
38
+ if (options?.recordLastUsed !== false) {
39
+ store.set('reviewPanelViewLastUsed', view);
26
40
  }
41
+ if (view === 'sections' && store.get('defaultDiffType') !== 'since-base') {
42
+ store.set('defaultDiffType', 'since-base');
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The panel view the reviewer has actually persisted, or `undefined` when they
48
+ * never chose one. Distinct from `configStore.get('reviewPanelView')`, which
49
+ * cannot tell a stored choice apart from the built-in default, which is the
50
+ * difference first-run seeding has to respect before it writes over anything.
51
+ */
52
+ export function getPersistedReviewPanelView(): 'sections' | 'tree' | undefined {
53
+ return SETTINGS.reviewPanelView.fromCookie();
27
54
  }
28
55
 
29
56
  export type ReviewDefaultDiffType =
@@ -34,9 +61,15 @@ export type ReviewDefaultDiffType =
34
61
  | 'merge-base'
35
62
  | 'all';
36
63
 
37
- export function setReviewDefaultDiffType(value: ReviewDefaultDiffType): void {
38
- configStore.set('defaultDiffType', value);
39
- if (value !== 'since-base' && configStore.get('reviewPanelView') !== 'tree') {
40
- configStore.set('reviewPanelView', 'tree');
64
+ export function setReviewDefaultDiffType(
65
+ value: ReviewDefaultDiffType,
66
+ store: PanelViewConfigStore = configStore,
67
+ ): void {
68
+ store.set('defaultDiffType', value);
69
+ if (value !== 'since-base' && store.get('reviewPanelView') !== 'tree') {
70
+ store.set('reviewPanelView', 'tree');
71
+ // The snap is an explicit-choice consequence (the user picked a classic
72
+ // diff default), so it syncs the memo like any explicit view write.
73
+ store.set('reviewPanelViewLastUsed', 'tree');
41
74
  }
42
75
  }
@@ -9,7 +9,12 @@
9
9
  * Add new settings here. Cookie-only settings omit serverKey.
10
10
  */
11
11
 
12
+ import {
13
+ isAnnotateAgentTerminalSide,
14
+ type AnnotateAgentTerminalSide,
15
+ } from '@plannotator/core/agent-terminal';
12
16
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
17
+ import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon';
13
18
  import { storage } from '../utils/storage';
14
19
  import { generateIdentity } from '../utils/generateIdentity';
15
20
  import {
@@ -131,6 +136,20 @@ export const SETTINGS = {
131
136
  },
132
137
  toServer: (v: ThemePair) => ({ theme: { mode: v.mode, light: v.light, dark: v.dark } }),
133
138
  },
139
+ faviconStyle: {
140
+ defaultValue: 'totman' as FaviconStyle,
141
+ fromCookie: () => {
142
+ const v = storage.getItem('plannotator-favicon');
143
+ return isFaviconStyle(v) ? v : undefined;
144
+ },
145
+ toCookie: (v: FaviconStyle) => storage.setItem('plannotator-favicon', v),
146
+ serverKey: 'favicon',
147
+ fromServer: (sc: Record<string, unknown>) => {
148
+ const v = sc.favicon;
149
+ return isFaviconStyle(v) ? v : undefined;
150
+ },
151
+ toServer: (v: FaviconStyle) => ({ favicon: v }),
152
+ },
134
153
 
135
154
  gridEnabled: {
136
155
  // Default ON: plans open in the classic grid / floating-card look. The UI 2.0
@@ -205,6 +224,54 @@ export const SETTINGS = {
205
224
  serverKey: undefined, fromServer: undefined, toServer: undefined,
206
225
  },
207
226
 
227
+ // The view the user last SELECTED via the in-review header toggle. Layered
228
+ // between the session state and the persisted reviewPanelView default, so a
229
+ // new session opens on what the user was actually using. Cookie-only.
230
+ // null = no last-used recorded (fall through to reviewPanelView).
231
+ //
232
+ // 'commits' is never recorded here for the same reason reviewPanelView
233
+ // rejects it: the Commits view is session-only and never an opening view.
234
+ reviewPanelViewLastUsed: {
235
+ defaultValue: null as 'sections' | 'tree' | null,
236
+ fromCookie: () => {
237
+ const v = storage.getItem('plannotator-review-panel-view-last-used');
238
+ return v === 'tree' || v === 'sections' ? v : undefined;
239
+ },
240
+ toCookie: (v: 'sections' | 'tree' | null) => {
241
+ // The null default seeds through here on first load — "unrecorded" has
242
+ // no cookie representation, so write nothing.
243
+ if (v === 'sections' || v === 'tree') {
244
+ storage.setItem('plannotator-review-panel-view-last-used', v);
245
+ }
246
+ },
247
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
248
+ },
249
+
250
+ // Compact left-panel preferences. These are deliberately cookie-only: they
251
+ // shape the local file-list chrome without changing review semantics or the
252
+ // repository state, and should follow the reviewer across review sessions.
253
+ reviewShowViewedControls: {
254
+ defaultValue: true as boolean,
255
+ fromCookie: () => {
256
+ const value = storage.getItem('plannotator-review-show-viewed-controls');
257
+ return value === 'true' ? true : value === 'false' ? false : undefined;
258
+ },
259
+ toCookie: (value: boolean) =>
260
+ storage.setItem('plannotator-review-show-viewed-controls', String(value)),
261
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
262
+ },
263
+
264
+ reviewShowStageControls: {
265
+ defaultValue: true as boolean,
266
+ fromCookie: () => {
267
+ const value = storage.getItem('plannotator-review-show-stage-controls');
268
+ return value === 'true' ? true : value === 'false' ? false : undefined;
269
+ },
270
+ toCookie: (value: boolean) =>
271
+ storage.setItem('plannotator-review-show-stage-controls', String(value)),
272
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
273
+ },
274
+
208
275
  defaultDiffType: {
209
276
  defaultValue: 'since-base' as 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
210
277
  fromCookie: () => {
@@ -414,6 +481,36 @@ export const SETTINGS = {
414
481
  fromServer: undefined,
415
482
  toServer: undefined,
416
483
  },
484
+ semanticDiffEnabled: {
485
+ defaultValue: true as boolean,
486
+ fromCookie: () => {
487
+ const value = storage.getItem('plannotator-semantic-diff-enabled');
488
+ return value === 'true' ? true : value === 'false' ? false : undefined;
489
+ },
490
+ toCookie: (value: boolean) =>
491
+ storage.setItem('plannotator-semantic-diff-enabled', String(value)),
492
+ serverKey: 'reviewAnalysis',
493
+ fromServer: (serverConfig: Record<string, unknown>) => {
494
+ const value = (serverConfig.reviewAnalysis as Record<string, unknown> | undefined)?.semanticDiff;
495
+ return typeof value === 'boolean' ? value : undefined;
496
+ },
497
+ toServer: (value: boolean) => ({ reviewAnalysis: { semanticDiff: value } }),
498
+ },
499
+ callFlowEnabled: {
500
+ defaultValue: false as boolean,
501
+ fromCookie: () => {
502
+ const value = storage.getItem('plannotator-call-flow-enabled');
503
+ return value === 'true' ? true : value === 'false' ? false : undefined;
504
+ },
505
+ toCookie: (value: boolean) =>
506
+ storage.setItem('plannotator-call-flow-enabled', String(value)),
507
+ serverKey: 'reviewAnalysis',
508
+ fromServer: (serverConfig: Record<string, unknown>) => {
509
+ const value = (serverConfig.reviewAnalysis as Record<string, unknown> | undefined)?.callFlow;
510
+ return typeof value === 'boolean' ? value : undefined;
511
+ },
512
+ toServer: (value: boolean) => ({ reviewAnalysis: { callFlow: value } }),
513
+ },
417
514
  conventionalComments: {
418
515
  defaultValue: false as boolean,
419
516
  fromCookie: () => {
@@ -453,6 +550,50 @@ export const SETTINGS = {
453
550
  }
454
551
  },
455
552
  },
553
+ /**
554
+ * Where the annotate-mode Agent TUI docks: 'left' (where it always docked),
555
+ * 'right', or 'hidden' (no slot until the user opens it for the session).
556
+ *
557
+ * Server-synced so the placement survives the random port every annotate
558
+ * session runs on — a cookie alone is per-origin, and each invocation is a
559
+ * new origin, so a cookie-only preference is effectively per-session.
560
+ *
561
+ * The cookie key is the pre-registry one, so a user who already picked a
562
+ * side keeps it across the upgrade.
563
+ */
564
+ agentTerminalSide: {
565
+ defaultValue: 'left' as AnnotateAgentTerminalSide,
566
+ fromCookie: () => {
567
+ const v = storage.getItem('plannotator-annotate-agent-terminal-side');
568
+ return isAnnotateAgentTerminalSide(v) ? v : undefined;
569
+ },
570
+ toCookie: (v: AnnotateAgentTerminalSide) =>
571
+ storage.setItem('plannotator-annotate-agent-terminal-side', v),
572
+ serverKey: 'agentTerminalSide',
573
+ fromServer: (sc: Record<string, unknown>) =>
574
+ isAnnotateAgentTerminalSide(sc.agentTerminalSide) ? sc.agentTerminalSide : undefined,
575
+ toServer: (v: AnnotateAgentTerminalSide) => ({ agentTerminalSide: v }),
576
+ },
577
+ /**
578
+ * Which agent the annotate-mode Agent TUI preselects. Empty string = no
579
+ * choice recorded yet, in which case the first available agent wins.
580
+ * Server-synced for the same reason as the placement above.
581
+ */
582
+ agentTerminalDefaultAgent: {
583
+ defaultValue: '' as string,
584
+ fromCookie: () =>
585
+ storage.getItem('plannotator-annotate-agent-terminal-default') || undefined,
586
+ toCookie: (v: string) => {
587
+ if (v) storage.setItem('plannotator-annotate-agent-terminal-default', v);
588
+ else storage.removeItem('plannotator-annotate-agent-terminal-default');
589
+ },
590
+ serverKey: 'agentTerminalDefaultAgent',
591
+ fromServer: (sc: Record<string, unknown>) =>
592
+ typeof sc.agentTerminalDefaultAgent === 'string' && sc.agentTerminalDefaultAgent
593
+ ? sc.agentTerminalDefaultAgent
594
+ : undefined,
595
+ toServer: (v: string) => ({ agentTerminalDefaultAgent: v }),
596
+ },
456
597
  /* SettingDef<any>, not <unknown>: consumers compile this shipped source under
457
598
  their own strictFunctionTypes, where a narrow `toCookie: (v: string) => void`
458
599
  is contravariantly incompatible with `(value: unknown) => void`. */
@@ -59,14 +59,15 @@ export function useAIProviderConfig({
59
59
  // Auto-resolve provider/model once capabilities are known.
60
60
  useEffect(() => {
61
61
  if (!available || providers.length === 0) return;
62
+ const saved = getAIProviderSettings();
63
+ const selection = resolveAIProviderSelection({
64
+ providers,
65
+ origin,
66
+ settings: saved,
67
+ serverDefaultProvider: defaultProvider,
68
+ });
69
+
62
70
  setAiConfig(prev => {
63
- const saved = getAIProviderSettings();
64
- const selection = resolveAIProviderSelection({
65
- providers,
66
- origin,
67
- settings: saved,
68
- serverDefaultProvider: defaultProvider,
69
- });
70
71
  if (prev.providerId === selection.providerId && prev.model === selection.model) return prev;
71
72
  return {
72
73
  ...prev,