@plannotator/ui 0.36.0 → 0.38.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 (37) hide show
  1. package/HANDOFF.md +6 -4
  2. package/README.md +57 -1
  3. package/components/ActionMenu.tsx +22 -24
  4. package/components/AnnotationToolbar.tsx +3 -1
  5. package/components/ApproveDropdown.tsx +15 -18
  6. package/components/CommentPopover.tsx +9 -4
  7. package/components/DecisionControl.tsx +626 -0
  8. package/components/DocBadges.tsx +9 -6
  9. package/components/FloatingQuickLabelPicker.tsx +4 -1
  10. package/components/KeyboardShortcuts.tsx +1 -0
  11. package/components/PlanHeaderMenu.tsx +1 -1
  12. package/components/Settings.tsx +67 -6
  13. package/components/StickyHeaderLane.tsx +16 -40
  14. package/components/ToolbarButtons.tsx +31 -9
  15. package/components/Viewer.tsx +277 -95
  16. package/components/VimTargetReticle.tsx +1 -1
  17. package/components/blocks/AlertBlock.tsx +51 -4
  18. package/components/compactHeaderLayout.ts +52 -0
  19. package/config/reviewView.ts +1 -0
  20. package/config/settings.ts +48 -3
  21. package/configure.ts +9 -0
  22. package/hooks/useAnnotationHighlighter.ts +31 -19
  23. package/hooks/useDismissablePopover.ts +65 -0
  24. package/hooks/useVimDocumentFocus.ts +6 -0
  25. package/package.json +22 -22
  26. package/shortcuts/decisionControl.shortcuts.ts +31 -0
  27. package/shortcuts/index.ts +1 -0
  28. package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
  29. package/styles.css +1 -1
  30. package/theme.css +11 -0
  31. package/utils/alertTitle.ts +69 -0
  32. package/utils/decisionSpec.ts +430 -0
  33. package/utils/platform.ts +16 -0
  34. package/utils/vimScroll.ts +1 -1
  35. package/sprite_package_additional/index.html +0 -34
  36. package/sprite_package_new/index.html +0 -34
  37. package/sprite_package_pulluphang/index.html +0 -34
@@ -88,6 +88,7 @@ const documentViewShortcuts: ShortcutSection = {
88
88
  title: 'View',
89
89
  shortcuts: [
90
90
  { keys: [modKey, '.'], desc: 'Toggle focus mode', hint: 'Collapses the Contents sidebar and the right-hand panel together; press again to restore whatever was open before. Markdown documents only; HTML pages keep their own layout.' },
91
+ { keys: [modKey, 'E'], desc: 'Toggle edit mode', hint: 'Opens the markdown source editor in place, keeping your scroll position; press again to commit your edits and return to annotating. Markdown documents only; a source-backed document with unsaved changes keeps its two-step discard.' },
91
92
  ],
92
93
  };
93
94
 
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
40
40
  }
41
41
 
42
42
  export interface CompactPlanAction {
43
- id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review';
43
+ id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish';
44
44
  label: string;
45
45
  subtitle?: string;
46
46
  onSelect: () => void;
@@ -3,6 +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 type { TokenHoverDelay } from '@plannotator/core/token-hover';
6
7
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
7
8
  import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
8
9
  import { loadDiffFont } from '../utils/diffFonts';
@@ -64,7 +65,7 @@ import { useAgents } from '../hooks/useAgents';
64
65
  import { KeyboardShortcuts } from './KeyboardShortcuts';
65
66
  import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
66
67
  import { ThemeTab } from './ThemeTab';
67
- import { isMac, modKey, altKey } from '../utils/platform';
68
+ import { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
68
69
  import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
69
70
  import { AISettingsTab } from './AISettingsTab';
70
71
  import { HooksTab } from './settings/HooksTab';
@@ -133,6 +134,23 @@ export const DIFF_STYLE_OPTIONS = [
133
134
  { value: 'split' as const, label: 'Split' },
134
135
  { value: 'unified' as const, label: 'Unified' },
135
136
  ];
137
+ /**
138
+ * The modifier gate is the platform's primary modifier: Cmd on macOS, Ctrl on
139
+ * Windows and Linux. Labeled through modKeyWord so the control names the key
140
+ * the reader actually has.
141
+ */
142
+ export const TOKEN_HOVER_TRIGGER_OPTIONS = [
143
+ { value: 'hover' as const, label: 'On hover' },
144
+ { value: 'modifier' as const, label: `Hold ${modKeyWord}` },
145
+ { value: 'off' as const, label: 'Off' },
146
+ ];
147
+ /** SegmentedControl keys on strings, so the ms values ride as their digits. */
148
+ export type TokenHoverDelayOption = '150' | '300' | '700';
149
+ export const TOKEN_HOVER_DELAY_OPTIONS = [
150
+ { value: '150' as const, label: 'Fast' },
151
+ { value: '300' as const, label: 'Default' },
152
+ { value: '700' as const, label: 'Relaxed' },
153
+ ];
136
154
  export const OVERFLOW_OPTIONS = [
137
155
  { value: 'scroll' as const, label: 'Scroll' },
138
156
  { value: 'wrap' as const, label: 'Wrap' },
@@ -157,6 +175,7 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
157
175
  // "All Changes" belongs to since-base (the flagship composite); uncommitted
158
176
  // reverts to its plain name so the two stay distinguishable side by side.
159
177
  { value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
178
+ { value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" },
160
179
  { value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
161
180
  { value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
162
181
  { value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
@@ -170,22 +189,27 @@ const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: st
170
189
  { value: 'hidden', label: 'Hidden' },
171
190
  ];
172
191
 
173
- function SegmentedControl<T extends string>({ options, value, onChange }: {
192
+ function SegmentedControl<T extends string>({ options, value, onChange, disabled = false }: {
174
193
  options: { value: T; label: string }[];
175
194
  value: T;
176
195
  onChange: (v: T) => void;
196
+ /** Visible but inert, for a control whose axis does not apply right now. */
197
+ disabled?: boolean;
177
198
  }) {
178
199
  return (
179
- <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
200
+ <div className={['flex items-center gap-1 bg-muted/50 rounded-lg p-0.5', disabled && 'opacity-50'].filter(Boolean).join(' ')}>
180
201
  {options.map((opt) => (
181
202
  <button
182
203
  key={opt.value}
204
+ {...(disabled ? { disabled: true } : {})}
183
205
  onClick={() => onChange(opt.value)}
184
- className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
206
+ className={[
207
+ 'flex-1 px-3 py-1.5 text-xs rounded-md transition-colors',
208
+ disabled && 'cursor-not-allowed',
185
209
  value === opt.value
186
210
  ? 'bg-background text-foreground shadow-sm font-medium'
187
- : 'text-muted-foreground hover:text-foreground'
188
- }`}
211
+ : 'text-muted-foreground hover:text-foreground',
212
+ ].filter(Boolean).join(' ')}
189
213
  >
190
214
  {opt.label}
191
215
  </button>
@@ -470,6 +494,8 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
470
494
  const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
471
495
  const diffFontFamily = useConfigValue('diffFontFamily');
472
496
  const diffFontSize = useConfigValue('diffFontSize');
497
+ const tokenHoverTrigger = useConfigValue('tokenHoverTrigger');
498
+ const tokenHoverDelay = useConfigValue('tokenHoverDelay');
473
499
 
474
500
  // Load font for the preview swatch
475
501
  useEffect(() => {
@@ -493,6 +519,41 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
493
519
 
494
520
  <div className="border-t border-border" />
495
521
 
522
+ {/* Hover cards (internally tokenHover*; the label is what changed, not
523
+ the ids). One trigger select rather than a toggle plus a mode: `Off`
524
+ is a value of the same question, so there is no unreachable
525
+ enabled-but-off state to reason about. The delay stays a separate
526
+ axis because "too eager" is a complaint neither the hold-modifier option
527
+ nor Off answers. */}
528
+ <div className="space-y-3">
529
+ <div>
530
+ <div className="text-sm font-medium">Hover cards</div>
531
+ <div className="text-xs text-muted-foreground">
532
+ Rest the pointer on a symbol in a diff to see where it is defined and who
533
+ references it. Needs ripgrep and a local checkout; nothing appears when the
534
+ search comes back empty. {modKeyWord}+click still opens the References panel either way.
535
+ </div>
536
+ </div>
537
+ <SegmentedControl
538
+ options={TOKEN_HOVER_TRIGGER_OPTIONS}
539
+ value={tokenHoverTrigger}
540
+ onChange={(v) => configStore.set('tokenHoverTrigger', v)}
541
+ />
542
+ <div className="space-y-2">
543
+ <div className="text-xs text-muted-foreground">
544
+ How long the pointer rests before a card is requested
545
+ </div>
546
+ <SegmentedControl
547
+ options={TOKEN_HOVER_DELAY_OPTIONS}
548
+ value={String(tokenHoverDelay) as TokenHoverDelayOption}
549
+ onChange={(v) => configStore.set('tokenHoverDelay', Number(v) as TokenHoverDelay)}
550
+ disabled={tokenHoverTrigger === 'off'}
551
+ />
552
+ </div>
553
+ </div>
554
+
555
+ <div className="border-t border-border" />
556
+
496
557
  {/* Font Family */}
497
558
  <div className="space-y-2">
498
559
  <div>
@@ -34,30 +34,14 @@ import {
34
34
  } from '../hooks/useScrollViewport';
35
35
  import type { EditorMode, InputMethod } from '../types';
36
36
  import type { PlanDiffStats } from '../utils/planDiffEngine';
37
+ import {
38
+ resolveCompactHeaderGeometry,
39
+ snapCompactHeaderWidth,
40
+ } from './compactHeaderLayout';
37
41
 
38
- // Snap a measured pixel width to a 16px grid. ResizeObserver fires every
39
- // frame during a drag; without quantization the sticky bar would
40
- // re-render on every pixel. Hoisted to module scope so the effects
41
- // (which use [] deps) can't accidentally close over a stale instance.
42
- // Floor (not round) so wrapper undershoots and actions overshoots — both
43
- // errors push toward a more cautious layout, avoiding a one-bucket overlap
44
- // flash right at the 300/460 thresholds during a slow drag.
45
- const snap = (n: number) => Math.floor(n / 16) * 16;
46
-
47
- // Layout geometry — static tuning constants, hoisted alongside `snap`.
48
- // LEFT_OFFSET: matches the bar's `md:left-5` (20px).
49
- // GAP: minimum breathing room between the bar's right edge and the
50
- // action button cluster's left edge when they share a lane.
51
- // Two-stage shared-lane shrinkage, mirroring the right side:
52
- // WIDE_BAR_WIDTH: full toolstrip (active labels Pinpoint/Markup ~300px)
53
- // + badges (~140px) on a single line.
54
- // MIN_BAR_WIDTH: icon-only toolstrip (~140px) + badges (~140px).
55
- // Below MIN, even the icon-only bar can't fit beside the (likely also
56
- // icon-only) action cluster — stack as the final fallback.
42
+ // Matches the bar's `md:left-5` inset. The remaining shared geometry lives
43
+ // in compactHeaderLayout so Viewer-owned and standalone lanes cannot drift.
57
44
  const LEFT_OFFSET = 20;
58
- const GAP = 16;
59
- const WIDE_BAR_WIDTH = 460;
60
- const MIN_BAR_WIDTH = 300;
61
45
 
62
46
  /** Controls when the compact header lane is visible and interactive. */
63
47
  export type StickyHeaderLaneVisibility = 'stuck' | 'always';
@@ -145,27 +129,19 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
145
129
  // removes chrome at rest for the supported chrome-free presentation.
146
130
  const showChrome = visibility === 'always' ? laneIsStuck : true;
147
131
 
148
- // Space available for the bar in the shared lane = wrapper width, minus
149
- // the bar's left offset, minus the action buttons' measured width, minus
150
- // the breathing gap.
151
- const availableForBar = wrapperWidth - LEFT_OFFSET - actionsWidth - GAP;
152
-
153
- // Narrow = not enough room in the shared lane for even the icon-only
154
- // bar. Falls back to a stacked row below the action buttons.
155
- // actionsWidth=0 before measurement is treated as "don't know yet"
156
- // (not narrow), so we don't flash the wrong layout on first paint.
157
- const measured = wrapperWidth > 0 && actionsWidth > 0;
158
- const isNarrow = measured && availableForBar < MIN_BAR_WIDTH;
159
- // Tight = shared lane still fits, but only if the toolstrip drops its
160
- // active labels and goes icon-only. Lets us stay horizontally aligned
161
- // for an extra ~160px of width before stacking.
162
- const isToolstripIconOnly =
163
- measured && !isNarrow && availableForBar < WIDE_BAR_WIDTH;
132
+ const headerGeometry = resolveCompactHeaderGeometry({
133
+ containerWidth: wrapperWidth,
134
+ trailingWidth: actionsWidth,
135
+ leadingInset: LEFT_OFFSET,
136
+ });
137
+ const availableForBar = headerGeometry.availableForLeading;
138
+ const isNarrow = headerGeometry.layout === 'narrow';
139
+ const isToolstripIconOnly = headerGeometry.layout === 'tight';
164
140
 
165
141
  useEffect(() => {
166
142
  if (!wrapperRef.current) return;
167
143
  const ro = new ResizeObserver(([entry]) => {
168
- const next = snap(entry.contentRect.width);
144
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
169
145
  setWrapperWidth((prev) => (prev === next ? prev : next));
170
146
  });
171
147
  ro.observe(wrapperRef.current);
@@ -188,7 +164,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
188
164
  const el = document.querySelector<HTMLElement>('[data-sticky-actions]');
189
165
  if (!el) return;
190
166
  const ro = new ResizeObserver(([entry]) => {
191
- const next = snap(entry.contentRect.width);
167
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
192
168
  setActionsWidth((prev) => (prev === next ? prev : next));
193
169
  });
194
170
  ro.observe(el);
@@ -14,7 +14,6 @@ interface FeedbackButtonProps {
14
14
  loadingLabel?: string;
15
15
  shortLoadingLabel?: string;
16
16
  title?: string;
17
- muted?: boolean;
18
17
  labelBreakpoint?: ToolbarLabelBreakpoint;
19
18
  }
20
19
 
@@ -27,7 +26,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
27
26
  loadingLabel = 'Sending...',
28
27
  shortLoadingLabel,
29
28
  title = 'Send Feedback',
30
- muted = false,
31
29
  labelBreakpoint = 'md',
32
30
  }) => (
33
31
  <Button
@@ -37,7 +35,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
37
35
  disabled={disabled}
38
36
  title={title}
39
37
  iconLeft={<Send className="size-3.5" />}
40
- className={cn(muted && 'opacity-50 cursor-not-allowed')}
41
38
  >
42
39
  {shortLabel ? (
43
40
  <>
@@ -65,8 +62,10 @@ export interface ApproveButtonProps {
65
62
  mobileLabel?: string;
66
63
  mobileLoadingLabel?: string;
67
64
  title?: string;
65
+ /** Kept for plan mode (packages/editor AppHeader) — the softened "you have
66
+ * annotations" treatment. The old platform-mode `muted` prop died with
67
+ * PR6 (DecisionControl owns the self-approval mute now). */
68
68
  dimmed?: boolean;
69
- muted?: boolean;
70
69
  labelBreakpoint?: ToolbarLabelBreakpoint;
71
70
  }
72
71
 
@@ -80,7 +79,6 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
80
79
  mobileLoadingLabel = '...',
81
80
  title,
82
81
  dimmed = false,
83
- muted = false,
84
82
  labelBreakpoint = 'md',
85
83
  }) => (
86
84
  <Button
@@ -91,9 +89,8 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
91
89
  title={title}
92
90
  iconLeft={<Check className="size-3.5" />}
93
91
  className={cn(
94
- muted && 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground hover:bg-muted',
95
- disabled && !muted && 'bg-muted text-muted-foreground hover:bg-muted',
96
- dimmed && !muted && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
92
+ disabled && 'bg-muted text-muted-foreground hover:bg-muted',
93
+ dimmed && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
97
94
  )}
98
95
  >
99
96
  <span className={labelBreakpoint === 'lg' ? 'lg:hidden' : 'md:hidden'}>
@@ -111,6 +108,10 @@ interface ExitButtonProps {
111
108
  isLoading?: boolean;
112
109
  title?: string;
113
110
  labelBreakpoint?: ToolbarLabelBreakpoint;
111
+ /** `'ghost'` renders the icon-only ghost-X form that sits beside the
112
+ * decision control. Default `'pill'` keeps every existing caller
113
+ * byte-identical. */
114
+ appearance?: 'pill' | 'ghost';
114
115
  }
115
116
 
116
117
  export const ExitButton: React.FC<ExitButtonProps> = ({
@@ -119,7 +120,28 @@ export const ExitButton: React.FC<ExitButtonProps> = ({
119
120
  isLoading = false,
120
121
  title = 'Close session without sending feedback',
121
122
  labelBreakpoint = 'md',
122
- }) => (
123
+ appearance = 'pill',
124
+ }) => appearance === 'ghost' ? (
125
+ <Button
126
+ variant="ghost"
127
+ size="xs"
128
+ onClick={onClick}
129
+ disabled={disabled || isLoading}
130
+ title={title}
131
+ aria-label={title}
132
+ // Maintainer ruling (post-demo): a bare X carries "close without
133
+ // sending" semantics an unlabeled icon should not carry alone — the
134
+ // word renders at every breakpoint.
135
+ className="px-2 text-muted-foreground gap-1.5"
136
+ >
137
+ {isLoading ? '…' : (
138
+ <>
139
+ <X className="size-3.5" aria-hidden="true" />
140
+ <span>Close</span>
141
+ </>
142
+ )}
143
+ </Button>
144
+ ) : (
123
145
  <Button
124
146
  variant="secondary"
125
147
  size="xs"