@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  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 +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -34,6 +34,17 @@ interface PlanHeaderMenuProps {
34
34
  obsidianConfigured: boolean;
35
35
  bearConfigured: boolean;
36
36
  octarineConfigured: boolean;
37
+ compactTouchLayout?: boolean;
38
+ compactSessionActions?: CompactPlanAction[];
39
+ compactDocumentActions?: CompactPlanAction[];
40
+ }
41
+
42
+ export interface CompactPlanAction {
43
+ id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'annotations' | 'ai' | 'review';
44
+ label: string;
45
+ subtitle?: string;
46
+ onSelect: () => void;
47
+ disabled?: boolean;
37
48
  }
38
49
 
39
50
  export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
@@ -57,6 +68,9 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
57
68
  obsidianConfigured,
58
69
  bearConfigured,
59
70
  octarineConfigured,
71
+ compactTouchLayout = false,
72
+ compactSessionActions = [],
73
+ compactDocumentActions = [],
60
74
  }) => {
61
75
  const { theme, setTheme } = useTheme();
62
76
 
@@ -68,13 +82,20 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
68
82
  return (
69
83
  <ActionMenu
70
84
  panelWidth="wide"
85
+ panelClassName={compactTouchLayout
86
+ ? 'absolute top-full right-0 mt-1 w-[min(18rem,calc(100vw-1rem))] max-h-[calc(var(--pn-viewport-height,100vh)-4.5rem-var(--pn-safe-top)-var(--pn-safe-bottom))] overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-xl z-[70]'
87
+ : undefined
88
+ }
71
89
  renderTrigger={({ isOpen, toggleMenu }) => (
72
90
  <button
91
+ id={compactTouchLayout ? 'pn-compact-plan-options-trigger' : undefined}
92
+ data-pn-touch-target={compactTouchLayout || undefined}
93
+ data-pn-touch-target-icon={compactTouchLayout || undefined}
73
94
  onClick={() => {
74
95
  if (!isOpen && showUpdateDot) updateInfo?.dismiss();
75
96
  toggleMenu();
76
97
  }}
77
- className={`relative flex items-center gap-1.5 p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-colors ${
98
+ className={`relative flex items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors ${compactTouchLayout ? 'h-11 w-11 p-0' : 'p-1.5 md:px-2.5 md:py-1'} ${
78
99
  isOpen
79
100
  ? 'bg-muted text-foreground'
80
101
  : 'text-muted-foreground hover:text-foreground hover:bg-muted'
@@ -99,11 +120,34 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
99
120
  >
100
121
  {({ closeMenu }) => (
101
122
  <>
123
+ {compactTouchLayout && compactSessionActions.length > 0 && (
124
+ <>
125
+ <CompactActionSection
126
+ label="Review"
127
+ actions={compactSessionActions}
128
+ closeMenu={closeMenu}
129
+ />
130
+ <ActionMenuDivider />
131
+ </>
132
+ )}
133
+
134
+ {compactTouchLayout && compactDocumentActions.length > 0 && (
135
+ <>
136
+ <CompactActionSection
137
+ label="Document"
138
+ actions={compactDocumentActions}
139
+ closeMenu={closeMenu}
140
+ />
141
+ <ActionMenuDivider />
142
+ </>
143
+ )}
144
+
102
145
  <div className="px-3 py-2 space-y-1.5">
103
146
  <ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
104
147
  <div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
105
148
  {THEME_MODES.map(({ id, label, Icon }) => (
106
149
  <button
150
+ data-pn-touch-target={compactTouchLayout || undefined}
107
151
  key={id}
108
152
  onClick={() => {
109
153
  closeMenu();
@@ -243,6 +287,101 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
243
287
  );
244
288
  };
245
289
 
290
+ const CompactActionSection = ({
291
+ label,
292
+ actions,
293
+ closeMenu,
294
+ }: {
295
+ label: string;
296
+ actions: CompactPlanAction[];
297
+ closeMenu: () => void;
298
+ }) => (
299
+ <div className="py-1">
300
+ <div className="px-3 py-1">
301
+ <ActionMenuSectionLabel>{label}</ActionMenuSectionLabel>
302
+ </div>
303
+ {actions.map((action) => (
304
+ <ActionMenuItem
305
+ key={action.id}
306
+ onClick={() => {
307
+ closeMenu();
308
+ action.onSelect();
309
+ }}
310
+ disabled={action.disabled}
311
+ icon={<CompactPlanActionIcon kind={action.id} />}
312
+ label={action.label}
313
+ subtitle={action.subtitle}
314
+ />
315
+ ))}
316
+ </div>
317
+ );
318
+
319
+ const CompactPlanActionIcon = ({ kind }: { kind: CompactPlanAction['id'] }) => {
320
+ if (kind === 'review') {
321
+ return (
322
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
323
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 11l2 2 4-4m6 3a9 9 0 11-18 0 9 9 0 0118 0z" />
324
+ </svg>
325
+ );
326
+ }
327
+ if (kind === 'annotations') {
328
+ return (
329
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
330
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
331
+ </svg>
332
+ );
333
+ }
334
+ if (kind === 'ai') {
335
+ return (
336
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
337
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 3l1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3zM18.5 13l.8 2.7L22 16.5l-2.7.8-.8 2.7-.8-2.7-2.7-.8 2.7-.8.8-2.7zM5 13l.7 2.3L8 16l-2.3.7L5 19l-.7-2.3L2 16l2.3-.7L5 13z" />
338
+ </svg>
339
+ );
340
+ }
341
+ if (kind === 'approve' || kind === 'done') {
342
+ return (
343
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
344
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
345
+ </svg>
346
+ );
347
+ }
348
+ if (kind === 'feedback') {
349
+ return (
350
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
351
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
352
+ </svg>
353
+ );
354
+ }
355
+ if (kind === 'copy') {
356
+ return (
357
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
358
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
359
+ </svg>
360
+ );
361
+ }
362
+ if (kind === 'edit' || kind === 'annotate') {
363
+ return (
364
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
365
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
366
+ </svg>
367
+ );
368
+ }
369
+ if (kind === 'tools') {
370
+ // Eye, matching the desktop header's show/hide-tools toggle.
371
+ return (
372
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
373
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
374
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
375
+ </svg>
376
+ );
377
+ }
378
+ return (
379
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
380
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
381
+ </svg>
382
+ );
383
+ };
384
+
246
385
  const MenuIcon = () => (
247
386
  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
248
387
  <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -107,6 +107,7 @@ export function SearchableSelect<T extends { id: string }>({
107
107
  />
108
108
  </svg>
109
109
  <input
110
+ data-pn-mobile-editable
110
111
  ref={inputRef}
111
112
  value={search}
112
113
  onChange={(e) => {
@@ -128,6 +129,7 @@ export function SearchableSelect<T extends { id: string }>({
128
129
  ) : (
129
130
  filtered.map((item, i) => (
130
131
  <button
132
+ data-pn-touch-target
131
133
  key={item.id}
132
134
  type="button"
133
135
  data-index={i}
@@ -1,8 +1,10 @@
1
1
  import React, { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
+ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
3
4
  import type { Origin } from '@plannotator/core/agents';
4
5
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
5
6
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
7
+ import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
6
8
  import { loadDiffFont } from '../utils/diffFonts';
7
9
  import { TaterSpritePullup } from './TaterSpritePullup';
8
10
  import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
@@ -73,8 +75,9 @@ import {
73
75
  type FileBrowserSettings,
74
76
  } from '../utils/fileBrowser';
75
77
  import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
78
+ import { AnalysisLayerToggle } from './AnalysisLayerToggle';
76
79
 
77
- type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
80
+ type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
78
81
 
79
82
  interface SettingsProps {
80
83
  taterMode: boolean;
@@ -94,8 +97,21 @@ interface SettingsProps {
94
97
  * (base ref unresolvable) — the Git tab shows a note that the Git-status
95
98
  * preference can't take effect in THIS repo. */
96
99
  sinceBaseUnavailable?: boolean;
100
+ /** The host is rendering its compact touch shell (review only). Display
101
+ * settings that the compact shell overrides for the session are hidden
102
+ * there instead of silently editing the desktop preference. */
103
+ isCompactTouchLayout?: boolean;
97
104
  /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */
98
105
  onDetectObsidianVaults?: () => Promise<string[]>;
106
+ /** This annotate session actually offers the Agent TUI, so its Position
107
+ * setting is worth showing. Default false: the setting is the only way back
108
+ * from a Hidden position, but offering it where no terminal can ever run
109
+ * would just be a dead control. */
110
+ agentTerminalAvailable?: boolean;
111
+ /** The browser exposes WebMCP (`document.modelContext`), so the "Agent
112
+ * tools" opt-out is worth showing. Default false: a browser without the
113
+ * API gets no row at all (the provider registers nothing there). */
114
+ webmcpAvailable?: boolean;
99
115
  }
100
116
 
101
117
  // --- Review-mode Display tab (diff display options) ---
@@ -148,6 +164,12 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
148
164
  { value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
149
165
  ];
150
166
 
167
+ const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [
168
+ { value: 'left', label: 'Left' },
169
+ { value: 'right', label: 'Right' },
170
+ { value: 'hidden', label: 'Hidden' },
171
+ ];
172
+
151
173
  function SegmentedControl<T extends string>({ options, value, onChange }: {
152
174
  options: { value: T; label: string }[];
153
175
  value: T;
@@ -192,12 +214,17 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
192
214
  aria-checked={checked}
193
215
  disabled={disabled}
194
216
  onClick={() => onChange(!checked)}
195
- className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${
196
- checked ? 'bg-primary' : 'bg-muted'
197
- }`}
217
+ className="relative inline-flex h-11 w-11 shrink-0 items-center disabled:cursor-not-allowed"
198
218
  >
199
219
  <span
200
- className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
220
+ aria-hidden="true"
221
+ className={`absolute inset-x-0 top-2.5 h-6 rounded-full transition-colors ${
222
+ checked ? 'bg-primary' : 'bg-muted'
223
+ }`}
224
+ />
225
+ <span
226
+ aria-hidden="true"
227
+ className={`absolute top-3.5 inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
201
228
  checked ? 'translate-x-6' : 'translate-x-1'
202
229
  }`}
203
230
  />
@@ -288,6 +315,63 @@ function VimSettingsTab({
288
315
  );
289
316
  }
290
317
 
318
+ function ReviewAnalysisTab() {
319
+ const semanticDiffEnabled = useConfigValue('semanticDiffEnabled');
320
+ const callFlowEnabled = useConfigValue('callFlowEnabled');
321
+ return (
322
+ <div className="space-y-5">
323
+ <div>
324
+ <div className="text-sm font-semibold">Analysis layers</div>
325
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
326
+ Add structural context to the ordinary code diff. Each layer is independent and does no work while disabled.
327
+ </p>
328
+ </div>
329
+
330
+ <div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
331
+ <AnalysisLayerToggle
332
+ checked={semanticDiffEnabled}
333
+ onChange={(enabled) => configStore.set('semanticDiffEnabled', enabled)}
334
+ label="Semantic changes"
335
+ description="Organize added, changed, moved, and removed functions, classes, and other named code. Enabled by default."
336
+ />
337
+ <div className="border-t border-border" />
338
+ <AnalysisLayerToggle
339
+ checked={callFlowEnabled}
340
+ onChange={(enabled) => configStore.set('callFlowEnabled', enabled)}
341
+ label="Call flow"
342
+ description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)."
343
+ />
344
+ </div>
345
+
346
+ <div className="grid gap-3 sm:grid-cols-2">
347
+ <div className="rounded-xl border border-border p-3.5">
348
+ <div className="flex items-center gap-2 text-xs font-semibold">
349
+ <span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">▣</span>
350
+ Dock
351
+ </div>
352
+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
353
+ Open the complete analysis from the file panel. Rows jump to the relevant code location.
354
+ </p>
355
+ </div>
356
+ <div className="rounded-xl border border-border p-3.5">
357
+ <div className="flex items-center gap-2 text-xs font-semibold">
358
+ <span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">⌁</span>
359
+ File lens
360
+ </div>
361
+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
362
+ File headers show compact counts and a focused popover for that file.
363
+ </p>
364
+ </div>
365
+ </div>
366
+
367
+ <div className="rounded-xl border border-warning/25 bg-warning/[0.05] p-4 text-xs leading-relaxed text-muted-foreground">
368
+ <span className="font-semibold text-foreground">Call flow is syntactic.</span>{' '}
369
+ It does not use type resolution or runtime traces, so treat it as navigation context—not proof that a path executes.
370
+ </div>
371
+ </div>
372
+ );
373
+ }
374
+
291
375
  const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
292
376
  const defaultDiffType = useConfigValue('defaultDiffType');
293
377
  const reviewPanelView = useConfigValue('reviewPanelView');
@@ -358,7 +442,7 @@ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavail
358
442
  );
359
443
  };
360
444
 
361
- const ReviewDisplayTab: React.FC = () => {
445
+ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => {
362
446
  const diffStyle = useConfigValue('diffStyle');
363
447
  const diffOverflow = useConfigValue('diffOverflow');
364
448
  const diffIndicators = useConfigValue('diffIndicators');
@@ -456,13 +540,23 @@ const ReviewDisplayTab: React.FC = () => {
456
540
 
457
541
  <div className="border-t border-border" />
458
542
 
459
- {/* Diff Style */}
543
+ {/* Diff Style. The compact touch shell renders a session-only unified
544
+ diff, so the control there would look dead while quietly rewriting
545
+ the DESKTOP preference. Say what the phone is doing instead. */}
460
546
  <div className="space-y-2">
461
547
  <div>
462
548
  <div className="text-sm font-medium">Diff Style</div>
463
549
  <div className="text-xs text-muted-foreground">Side-by-side or inline diff view</div>
550
+ {isCompactTouchLayout && (
551
+ <div className="text-xs text-muted-foreground mt-1">
552
+ This layout shows unified diffs for the session; your desktop
553
+ preference is unchanged.
554
+ </div>
555
+ )}
464
556
  </div>
465
- <SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
557
+ {!isCompactTouchLayout && (
558
+ <SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
559
+ )}
466
560
  </div>
467
561
 
468
562
  <div className="border-t border-border" />
@@ -759,7 +853,8 @@ const CommentsTab: React.FC = () => {
759
853
  );
760
854
  };
761
855
 
762
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => {
856
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
857
+ const webmcpTools = useWebMcpToolsEnabled();
763
858
  const [showDialog, setShowDialog] = useState(false);
764
859
  const settingsWasOpenRef = useRef(false);
765
860
  const [themePreview, setThemePreview] = useState(false);
@@ -791,6 +886,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
791
886
  const vimModeEnabled = useConfigValue('vimModeEnabled');
792
887
  const vimHudEnabled = useConfigValue('vimHudEnabled');
793
888
  const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
889
+ const agentTerminalSide = useConfigValue('agentTerminalSide');
794
890
  const [identity, setIdentity] = useState('');
795
891
  const [obsidian, setObsidian] = useState<ObsidianSettings>({
796
892
  enabled: false,
@@ -835,6 +931,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
835
931
  if (mode === 'review') {
836
932
  t.push({ id: 'git', label: 'Git' });
837
933
  t.push({ id: 'display', label: 'Editor' });
934
+ t.push({ id: 'analysis', label: 'Analysis' });
838
935
  t.push({ id: 'comments', label: 'Comments' });
839
936
  if (aiProviders.length > 0) {
840
937
  t.push({ id: 'ai', label: 'AI' });
@@ -1191,6 +1288,39 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1191
1288
  </div>
1192
1289
  </div>
1193
1290
 
1291
+ {/* Agent tools (WebMCP). Shown only when the browser
1292
+ exposes document.modelContext; off unregisters the
1293
+ tools for this browser. Cookie-only and idle at its
1294
+ default: no cookie exists until the user opts out. */}
1295
+ {webmcpAvailable && (
1296
+ <>
1297
+ <div className="border-t border-border" />
1298
+ <div className="flex items-center justify-between" data-webmcp-setting="true">
1299
+ <div>
1300
+ <div className="text-sm font-medium">Agent tools</div>
1301
+ <div className="text-xs text-muted-foreground">
1302
+ Let your browser's agent read this document and leave comments. Approving and sending feedback stay yours.
1303
+ </div>
1304
+ </div>
1305
+ <button
1306
+ role="switch"
1307
+ aria-checked={webmcpTools}
1308
+ aria-label="Agent tools"
1309
+ onClick={() => setWebMcpToolsEnabled(!webmcpTools)}
1310
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1311
+ webmcpTools ? 'bg-primary' : 'bg-muted'
1312
+ }`}
1313
+ >
1314
+ <span
1315
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1316
+ webmcpTools ? 'translate-x-6' : 'translate-x-1'
1317
+ }`}
1318
+ />
1319
+ </button>
1320
+ </div>
1321
+ </>
1322
+ )}
1323
+
1194
1324
  {/* Permission Mode (Claude Code only) */}
1195
1325
  {origin === 'claude-code' && mode === 'plan' && (
1196
1326
  <>
@@ -1306,6 +1436,37 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1306
1436
  </>
1307
1437
  )}
1308
1438
 
1439
+ {/* Agent TUI position. The same control lives in the
1440
+ terminal's own Display popover, but that one is
1441
+ unreachable once the position is Hidden — this is the
1442
+ way back. Both write the same config value. Gated on the
1443
+ terminal actually being available in this session, so a
1444
+ remote or runtime-less annotate never offers a dead
1445
+ control. */}
1446
+ {mode === 'annotate' && agentTerminalAvailable && (
1447
+ <>
1448
+ <div className="border-t border-border" />
1449
+ <div className="space-y-2">
1450
+ <div>
1451
+ <div className="text-sm font-medium">Agent TUI Position</div>
1452
+ <div className="text-xs text-muted-foreground">
1453
+ Which side the agent terminal docks on, or Hidden to keep it out of the layout
1454
+ </div>
1455
+ </div>
1456
+ <SegmentedControl
1457
+ options={AGENT_TERMINAL_SIDE_OPTIONS}
1458
+ value={agentTerminalSide}
1459
+ onChange={(v) => configStore.set('agentTerminalSide', v)}
1460
+ />
1461
+ {agentTerminalSide === 'hidden' && (
1462
+ <div className="text-[10px] text-muted-foreground/70">
1463
+ You can still open it for this session from the sidebar rail.
1464
+ </div>
1465
+ )}
1466
+ </div>
1467
+ </>
1468
+ )}
1469
+
1309
1470
  <div className="border-t border-border" />
1310
1471
 
1311
1472
  {/* Auto-close Tab */}
@@ -1343,7 +1504,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1343
1504
 
1344
1505
  {/* === DISPLAY TAB === */}
1345
1506
  {activeTab === 'display' && mode === 'review' && (
1346
- <ReviewDisplayTab />
1507
+ <ReviewDisplayTab isCompactTouchLayout={isCompactTouchLayout} />
1508
+ )}
1509
+
1510
+ {activeTab === 'analysis' && mode === 'review' && (
1511
+ <ReviewAnalysisTab />
1347
1512
  )}
1348
1513
 
1349
1514
  {activeTab === 'display' && mode !== 'review' && (
@@ -9,6 +9,8 @@ const ROOT_LABELS: Record<SkillCatalogEntry['root'], string> = {
9
9
  };
10
10
 
11
11
  interface SkillReferenceMenuProps {
12
+ /** Id referenced by the focused textarea's aria-controls attribute. */
13
+ id: string;
12
14
  items: SkillCatalogEntry[];
13
15
  /** Explicitly activated row, or null — the menu opens with nothing active. */
14
16
  activeIndex: number | null;
@@ -79,6 +81,7 @@ function computeMenuPlacement(
79
81
  * a purely visual affordance; a pointer click inserts directly.
80
82
  */
81
83
  export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
84
+ id,
82
85
  items,
83
86
  activeIndex,
84
87
  onSelect,
@@ -140,6 +143,8 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
140
143
  return (
141
144
  <div
142
145
  ref={menuRef}
146
+ id={id}
147
+ role="listbox"
143
148
  data-skill-menu="true"
144
149
  data-skill-menu-placement={placement.direction}
145
150
  data-popover-layer="true"
@@ -157,6 +162,10 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
157
162
  <button
158
163
  key={item.name}
159
164
  type="button"
165
+ id={`${id}-option-${index}`}
166
+ role="option"
167
+ aria-selected={index === activeIndex}
168
+ tabIndex={-1}
160
169
  data-skill-item={item.name}
161
170
  data-skill-item-active={index === activeIndex ? 'true' : undefined}
162
171
  // Insert on pointerdown so the textarea never loses focus. A
@@ -27,7 +27,10 @@
27
27
  import React, { useEffect, useRef, useState } from 'react';
28
28
  import { AnnotationToolstrip } from './AnnotationToolstrip';
29
29
  import { DocBadges } from './DocBadges';
30
- import { useScrollViewport } from '../hooks/useScrollViewport';
30
+ import {
31
+ getScrollViewportIntersectionRoot,
32
+ useScrollViewport,
33
+ } from '../hooks/useScrollViewport';
31
34
  import type { EditorMode, InputMethod } from '../types';
32
35
  import type { PlanDiffStats } from '../utils/planDiffEngine';
33
36
 
@@ -172,7 +175,11 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
172
175
  if (!sentinelRef.current || !scrollViewport) return;
173
176
  const observer = new IntersectionObserver(
174
177
  ([entry]) => setIsStuck(!entry.isIntersecting),
175
- { root: scrollViewport, rootMargin: '80px 0px 0px 0px', threshold: 0 }
178
+ {
179
+ root: getScrollViewportIntersectionRoot(scrollViewport),
180
+ rootMargin: '80px 0px 0px 0px',
181
+ threshold: 0,
182
+ }
176
183
  );
177
184
  observer.observe(sentinelRef.current);
178
185
  return () => observer.disconnect();
@@ -5,7 +5,12 @@ import {
5
5
  getAnnotationCountBySection,
6
6
  type TocItem,
7
7
  } from '../utils/annotationHelpers';
8
- import { useScrollViewport } from '../hooks/useScrollViewport';
8
+ import {
9
+ getScrollViewportRect,
10
+ getScrollViewportTop,
11
+ scrollViewportTo,
12
+ useScrollViewport,
13
+ } from '../hooks/useScrollViewport';
9
14
 
10
15
  interface TableOfContentsProps {
11
16
  blocks: Block[];
@@ -82,11 +87,11 @@ export function TableOfContents({
82
87
  if (target && scrollViewport) {
83
88
  const scrollContainer = scrollViewport;
84
89
  const headerOffset = 80; // sticky header (h-12) + breathing room
85
- const containerRect = scrollContainer.getBoundingClientRect();
90
+ const containerRect = getScrollViewportRect(scrollContainer);
86
91
  const targetRect = target.getBoundingClientRect();
87
92
  const offsetPosition =
88
- scrollContainer.scrollTop + (targetRect.top - containerRect.top) - headerOffset;
89
- scrollContainer.scrollTo({ top: offsetPosition, behavior: 'smooth' });
93
+ getScrollViewportTop(scrollContainer) + (targetRect.top - containerRect.top) - headerOffset;
94
+ scrollViewportTo(scrollContainer, { top: offsetPosition, behavior: 'smooth' });
90
95
  }
91
96
  },
92
97
  [onNavigate, scrollViewport]
@@ -1,5 +1,5 @@
1
1
  import React, { useMemo, type JSX } from 'react';
2
- import { motion } from 'motion/react';
2
+ import { motion, useReducedMotion } from 'motion/react';
3
3
 
4
4
  export interface TextShimmerProps {
5
5
  children: string;
@@ -16,6 +16,7 @@ function TextShimmerComponent({
16
16
  duration = 2,
17
17
  spread = 2,
18
18
  }: TextShimmerProps) {
19
+ const shouldReduceMotion = useReducedMotion();
19
20
  const MotionComponent = motion.create(
20
21
  Component as keyof JSX.IntrinsicElements
21
22
  );
@@ -35,9 +36,9 @@ function TextShimmerComponent({
35
36
  ]
36
37
  .filter(Boolean)
37
38
  .join(' ')}
38
- initial={{ backgroundPosition: '100% center' }}
39
- animate={{ backgroundPosition: '0% center' }}
40
- transition={{
39
+ initial={shouldReduceMotion ? false : { backgroundPosition: '100% center' }}
40
+ animate={shouldReduceMotion ? undefined : { backgroundPosition: '0% center' }}
41
+ transition={shouldReduceMotion ? undefined : {
41
42
  repeat: Infinity,
42
43
  duration,
43
44
  ease: 'linear',
@@ -45,7 +46,9 @@ function TextShimmerComponent({
45
46
  style={
46
47
  {
47
48
  '--spread': `${dynamicSpread}px`,
48
- backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
49
+ backgroundImage: shouldReduceMotion
50
+ ? 'linear-gradient(var(--base-color), var(--base-color))'
51
+ : 'var(--bg), linear-gradient(var(--base-color), var(--base-color))',
49
52
  } as React.CSSProperties
50
53
  }
51
54
  >