@plannotator/ui 0.37.0 → 0.38.1

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.
@@ -0,0 +1,626 @@
1
+ import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+ import { Check, ChevronDown, Loader2, Send } from 'lucide-react';
3
+ import { Button } from './ui/button';
4
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
5
+ import { Tooltip } from './Tooltip';
6
+ import { ActionMenuDivider, ActionMenuItem } from './ActionMenu';
7
+ import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
8
+ import { useDismissablePopover } from '../hooks/useDismissablePopover';
9
+ import { useCompactTouchLayout } from '../hooks/useIsMobile';
10
+ import { submitHint } from '../utils/platform';
11
+ import { cn } from '../lib/utils';
12
+ import type {
13
+ DecisionActionId,
14
+ DecisionComposer,
15
+ DecisionMenuItem,
16
+ DecisionSpec,
17
+ DecisionTone,
18
+ } from '../utils/decisionSpec';
19
+
20
+ /**
21
+ * The unified header decision control: one joined split pill — the incumbent
22
+ * primary on the left, a caret on the right whose popover carries the
23
+ * alternate decisions and the in-place note composer. Rendered entirely from a
24
+ * `DecisionSpec` (see `utils/decisionSpec.ts`); the host owns the handlers.
25
+ *
26
+ * NOT host-supported surface (like ActionMenu/ConfirmDialog): session/transport
27
+ * chrome for Plannotator's own decision endpoints, deliberately absent from
28
+ * the README supported-import list.
29
+ *
30
+ * Interaction contract (the load-bearing rules):
31
+ * - The left segment never opens the popover and never changes meaning within
32
+ * a state; the caret owns the popover.
33
+ * - Choosing a composer item MORPHS the same popover into the note composer —
34
+ * same element, children swap, never a second popover.
35
+ * - Esc ladder: composer → back to the menu keeping the draft; menu → close,
36
+ * focus returns to the caret. The event is consumed (stopPropagation) on
37
+ * exactly those two rungs; with nothing open the host app's own Escape
38
+ * ladder still runs.
39
+ * - Mod+Enter in the note field fires the composer's labelled action; plain
40
+ * Enter is a newline, always.
41
+ * - An empty note never submits and never grays the action: the click
42
+ * refocuses the field instead.
43
+ */
44
+ export type DecisionHandler = (note?: string) => void;
45
+
46
+ export interface DecisionControlProps {
47
+ spec: DecisionSpec;
48
+ handlers: Record<DecisionActionId, DecisionHandler>;
49
+ /** isSubmitting || isExiting || isApproving — disables both segments and
50
+ * closes an open popover. */
51
+ busy: boolean;
52
+ /** Spinner on the primary only. */
53
+ isLoading: boolean;
54
+ labelBreakpoint?: 'md' | 'lg';
55
+ /** The host says the surface is framed (raw-HTML srcdoc / live-app proxy):
56
+ * clicks inside the iframe never reach the parent document, so iframe
57
+ * focus dismisses the popover instead. */
58
+ dismissOnIframeFocus?: boolean;
59
+ /** Confirm surface override; defaults to the ui ConfirmDialog. */
60
+ confirmDialog?: React.ComponentType<ConfirmDialogProps>;
61
+ }
62
+
63
+ const NOTE_MAX_HEIGHT_PX = 144;
64
+
65
+ const ICONS: Record<'check' | 'send', React.ReactNode> = {
66
+ check: <Check className="size-3.5" />,
67
+ send: <Send className="size-3.5" />,
68
+ };
69
+
70
+ function toneButtonVariant(tone: Exclude<DecisionTone, 'destructive'>): 'success' | 'default' {
71
+ return tone === 'success' ? 'success' : 'default';
72
+ }
73
+
74
+ function itemToneClass(tone: DecisionTone): string | undefined {
75
+ if (tone === 'destructive') return 'text-destructive';
76
+ if (tone === 'success') return 'text-success';
77
+ return undefined;
78
+ }
79
+
80
+ function useAutoGrow(text: string) {
81
+ const ref = useRef<HTMLTextAreaElement>(null);
82
+ useEffect(() => {
83
+ const el = ref.current;
84
+ if (!el) return;
85
+ el.style.height = 'auto';
86
+ el.style.height = `${Math.min(el.scrollHeight, NOTE_MAX_HEIGHT_PX)}px`;
87
+ }, [text]);
88
+ return ref;
89
+ }
90
+
91
+ export interface DecisionNoteFieldProps {
92
+ text: string;
93
+ onTextChange: (value: string) => void;
94
+ /** Mod+Enter / the labelled action. The owner enforces the empty-note
95
+ * no-op-and-refocus rule, so this fires for any text. */
96
+ onSubmit: () => void;
97
+ /** Escape. Consumes the event; the owner decides what "back" means. */
98
+ onCancel: () => void;
99
+ placeholder?: string;
100
+ disabled?: boolean;
101
+ autoFocus?: boolean;
102
+ }
103
+
104
+ /**
105
+ * The shared multi-line note field: two rows at rest, grows to 144px, then
106
+ * scrolls. Controlled — the owner keeps the text so stepping back does not
107
+ * discard a half-typed note. Exported separately because a third consumer
108
+ * (the review sidebar's "+ General comment") composes it outside the control.
109
+ */
110
+ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
111
+ text,
112
+ onTextChange,
113
+ onSubmit,
114
+ onCancel,
115
+ placeholder = 'Add a note...',
116
+ disabled = false,
117
+ autoFocus = true,
118
+ }) => {
119
+ const ref = useAutoGrow(text);
120
+
121
+ useEffect(() => {
122
+ if (autoFocus) ref.current?.focus();
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ }, [autoFocus]);
125
+
126
+ const handleKeyDown = useCallback(
127
+ (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
128
+ if (event.key === 'Escape') {
129
+ // A consuming rung of the Esc ladder: the host apps run their own
130
+ // Escape ladders and must not also see this event.
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+ onCancel();
134
+ return;
135
+ }
136
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
137
+ event.preventDefault();
138
+ event.stopPropagation();
139
+ if (!disabled) onSubmit();
140
+ }
141
+ // Plain Enter falls through: the field is multi-line and Enter's job is
142
+ // a newline.
143
+ },
144
+ [disabled, onCancel, onSubmit],
145
+ );
146
+
147
+ return (
148
+ <textarea
149
+ ref={ref}
150
+ rows={2}
151
+ value={text}
152
+ onChange={(event) => onTextChange(event.target.value)}
153
+ onKeyDown={handleKeyDown}
154
+ placeholder={placeholder}
155
+ aria-label={placeholder}
156
+ data-decision-note-input="true"
157
+ data-pn-mobile-editable
158
+ disabled={disabled}
159
+ className="block w-full resize-none overflow-y-auto rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-snug text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
160
+ style={{ maxHeight: NOTE_MAX_HEIGHT_PX }}
161
+ />
162
+ );
163
+ };
164
+
165
+ /**
166
+ * The compact/touch note composer. The desktop composer morphs inside the
167
+ * caret popover, which is the wrong shape on touch (the header menu popup
168
+ * closes on outside pointerdown and its max-height fights the soft keyboard),
169
+ * so compact rows open this dialog instead.
170
+ */
171
+ export const DecisionNoteDialog: React.FC<{
172
+ isOpen: boolean;
173
+ onClose: () => void;
174
+ composer: DecisionComposer;
175
+ onSubmit: (note: string) => void;
176
+ disabled?: boolean;
177
+ /** Free prose under the title, e.g. what the note rides along with. */
178
+ subtitle?: string;
179
+ }> = ({ isOpen, onClose, composer, onSubmit, disabled = false, subtitle }) => {
180
+ const isCompactTouchLayout = useCompactTouchLayout();
181
+ const [text, setText] = useState('');
182
+ const contentRef = useRef<HTMLDivElement>(null);
183
+
184
+ // If the surface goes busy while the dialog is open, close it rather than
185
+ // leaving an editable field whose action can no longer do anything.
186
+ useEffect(() => {
187
+ if (disabled && isOpen) onClose();
188
+ }, [disabled, isOpen, onClose]);
189
+
190
+ const submit = useCallback(() => {
191
+ if (disabled) return;
192
+ const trimmed = text.trim();
193
+ if (trimmed.length === 0) {
194
+ // Same contract as the popover composer: the action stays full-strength
195
+ // and an empty-note tap refocuses the field — on touch this also raises
196
+ // the keyboard, which is what the tap was asking for.
197
+ contentRef.current
198
+ ?.querySelector<HTMLTextAreaElement>('[data-decision-note-input]')
199
+ ?.focus();
200
+ return;
201
+ }
202
+ onSubmit(trimmed);
203
+ setText('');
204
+ onClose();
205
+ }, [disabled, onClose, onSubmit, text]);
206
+
207
+ return (
208
+ <Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
209
+ <DialogContent
210
+ ref={contentRef}
211
+ data-decision-note-composer="dialog"
212
+ // Opening the sheet must not raise the touch keyboard on its own.
213
+ initialFocus={isCompactTouchLayout ? false : undefined}
214
+ className="max-w-md rounded-xl bg-card p-4 text-foreground"
215
+ >
216
+ <DialogTitle className="font-semibold mb-1">{composer.title}</DialogTitle>
217
+ {subtitle ? (
218
+ <DialogDescription className="text-sm text-muted-foreground mb-3">
219
+ {subtitle}
220
+ </DialogDescription>
221
+ ) : null}
222
+ <DecisionNoteField
223
+ text={text}
224
+ onTextChange={setText}
225
+ onSubmit={submit}
226
+ onCancel={onClose}
227
+ placeholder={composer.placeholder}
228
+ disabled={disabled}
229
+ autoFocus={!isCompactTouchLayout}
230
+ />
231
+ <div className="mt-3 flex items-center justify-between gap-2">
232
+ <span className="text-[11px] leading-snug text-muted-foreground">{submitHint}</span>
233
+ <Button
234
+ variant={toneButtonVariant(composer.tone)}
235
+ size="xs"
236
+ data-decision-composer-send="true"
237
+ onClick={submit}
238
+ disabled={disabled}
239
+ title={composer.actionLabel}
240
+ iconLeft={composer.icon ? ICONS[composer.icon] : undefined}
241
+ >
242
+ {composer.actionLabel}
243
+ </Button>
244
+ </div>
245
+ </DialogContent>
246
+ </Dialog>
247
+ );
248
+ };
249
+
250
+ type PopoverState = null | 'menu' | 'composer';
251
+
252
+ export const DecisionControl: React.FC<DecisionControlProps> = ({
253
+ spec,
254
+ handlers,
255
+ busy,
256
+ isLoading,
257
+ labelBreakpoint = 'md',
258
+ dismissOnIframeFocus = false,
259
+ confirmDialog,
260
+ }) => {
261
+ const rootRef = useRef<HTMLDivElement>(null);
262
+ const popoverRef = useRef<HTMLDivElement>(null);
263
+ const caretRef = useRef<HTMLButtonElement>(null);
264
+ const [popover, setPopover] = useState<PopoverState>(null);
265
+ const [activeItemId, setActiveItemId] = useState<DecisionMenuItem['id'] | null>(null);
266
+ // Drafts are keyed per item so stepping back from a composer (Esc, back
267
+ // button, even closing the popover) keeps the half-typed note.
268
+ const [drafts, setDrafts] = useState<Record<string, string>>({});
269
+ // L2: only the ID is state — the confirm's title/message/handler resolve
270
+ // from the LIVE spec at render, so a spec update while the dialog is up
271
+ // can never confirm stale copy (e.g. an outdated count).
272
+ const [confirmItemId, setConfirmItemId] = useState<DecisionMenuItem['id'] | null>(null);
273
+ const confirmItem =
274
+ confirmItemId !== null
275
+ ? spec.items.find((item) => item.id === confirmItemId && item.confirm) ?? null
276
+ : null;
277
+
278
+ const activeItem =
279
+ popover === 'composer' && activeItemId
280
+ ? spec.items.find((item) => item.id === activeItemId) ?? null
281
+ : null;
282
+
283
+ const closePopover = useCallback((options?: { focusCaret?: boolean }) => {
284
+ setPopover(null);
285
+ setActiveItemId(null);
286
+ if (options?.focusCaret) caretRef.current?.focus();
287
+ }, []);
288
+
289
+ // Outside-dismissal (outside pointerdown, iframe focus, or an Escape whose
290
+ // focus has already left the control) closes the WHOLE popover, composer
291
+ // included: the user's attention has left the control, so restoring the
292
+ // intermediate menu rung would fight where they are — and the draft is kept
293
+ // in `drafts` either way. The three-rung Esc ladder applies only while
294
+ // focus is inside the popover (handlePopoverKeyDown / the note field).
295
+ const dismiss = useCallback(() => closePopover(), [closePopover]);
296
+
297
+ useDismissablePopover({
298
+ enabled: popover !== null,
299
+ ref: rootRef,
300
+ onDismiss: dismiss,
301
+ dismissOnIframeFocus,
302
+ });
303
+
304
+ // Busy = a decision is in flight: nothing in the popover can do anything
305
+ // anymore, so it must not hang over a disabled pill.
306
+ useEffect(() => {
307
+ if (busy) {
308
+ setPopover(null);
309
+ setActiveItemId(null);
310
+ setConfirmItemId(null);
311
+ }
312
+ }, [busy]);
313
+
314
+ // F6: the spec is live — an annotation delete (or an external write) can
315
+ // remove the item the open composer or confirm belongs to. Morph gracefully
316
+ // instead of rendering a dead surface: both step back to the menu (the
317
+ // composer draft is kept, keyed by item id, in case the item returns).
318
+ // buildDecisionSpec guarantees at least one item, so the menu is always a
319
+ // valid fallback (L4 ruling: no empty-menu branch for a spec shape that
320
+ // cannot occur). Lives in the control rather than the adopting apps so the
321
+ // annotate and review wirings cannot diverge on it.
322
+ useEffect(() => {
323
+ if (popover !== 'composer') return;
324
+ const stillPresent =
325
+ activeItemId !== null &&
326
+ spec.items.some((item) => item.id === activeItemId && item.composer);
327
+ if (stillPresent) return;
328
+ setPopover('menu');
329
+ setActiveItemId(null);
330
+ }, [activeItemId, popover, spec]);
331
+
332
+ useEffect(() => {
333
+ if (confirmItemId === null) return;
334
+ if (spec.items.some((item) => item.id === confirmItemId && item.confirm)) return;
335
+ setConfirmItemId(null);
336
+ setPopover('menu');
337
+ }, [confirmItemId, spec]);
338
+
339
+ // Roving focus entry point: whenever the menu (re)appears — caret click,
340
+ // back from the composer, confirm cancel — focus its first row.
341
+ useEffect(() => {
342
+ if (popover !== 'menu') return;
343
+ // :not(:disabled) — a muted (platform self-approval) row cannot take
344
+ // focus, so land on the first live row instead.
345
+ popoverRef.current
346
+ ?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')
347
+ ?.focus();
348
+ }, [popover]);
349
+
350
+ const selectItem = useCallback((item: DecisionMenuItem) => {
351
+ if (item.composer) {
352
+ setActiveItemId(item.id);
353
+ setPopover('composer');
354
+ return;
355
+ }
356
+ if (item.confirm) {
357
+ // The confirm replaces the popover; cancel reopens the menu so one
358
+ // decision stays one click away.
359
+ setConfirmItemId(item.id);
360
+ setPopover(null);
361
+ return;
362
+ }
363
+ closePopover();
364
+ handlers[item.id]?.();
365
+ }, [closePopover, handlers]);
366
+
367
+ const submitComposer = useCallback(() => {
368
+ if (!activeItem) return;
369
+ const trimmed = (drafts[activeItem.id] ?? '').trim();
370
+ if (trimmed.length === 0) {
371
+ // Empty note: the action stays visually enabled and the click puts the
372
+ // cursor back in the field — never a disabled-gray submit.
373
+ popoverRef.current
374
+ ?.querySelector<HTMLTextAreaElement>('[data-decision-note-input]')
375
+ ?.focus();
376
+ return;
377
+ }
378
+ setDrafts((prev) => {
379
+ const next = { ...prev };
380
+ delete next[activeItem.id];
381
+ return next;
382
+ });
383
+ closePopover();
384
+ handlers[activeItem.id]?.(trimmed);
385
+ }, [activeItem, closePopover, drafts, handlers]);
386
+
387
+ const backToMenu = useCallback(() => {
388
+ setPopover('menu');
389
+ setActiveItemId(null);
390
+ }, []);
391
+
392
+ const handlePopoverKeyDown = useCallback(
393
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
394
+ if (event.key === 'Escape') {
395
+ // The two consuming rungs of the Esc ladder. (The note field consumes
396
+ // its own Escape before it reaches here — same first rung.)
397
+ event.preventDefault();
398
+ event.stopPropagation();
399
+ if (popover === 'composer') backToMenu();
400
+ else closePopover({ focusCaret: true });
401
+ return;
402
+ }
403
+ if (popover !== 'menu') return;
404
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
405
+ if (!keys.includes(event.key)) return;
406
+ const items = Array.from(
407
+ popoverRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
408
+ );
409
+ if (items.length === 0) return;
410
+ event.preventDefault();
411
+ event.stopPropagation();
412
+ const current = items.indexOf(document.activeElement as HTMLButtonElement);
413
+ let next = 0;
414
+ if (event.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length;
415
+ else if (event.key === 'ArrowUp') next = current <= 0 ? items.length - 1 : current - 1;
416
+ else if (event.key === 'Home') next = 0;
417
+ else next = items.length - 1;
418
+ items[next]?.focus();
419
+ },
420
+ [backToMenu, closePopover, popover],
421
+ );
422
+
423
+ const primaryVariant = toneButtonVariant(spec.primary.tone);
424
+ const Confirm = confirmDialog ?? ConfirmDialog;
425
+ const mutedReasonId = useId();
426
+
427
+ // Left segment — the incumbent primary. It never opens the popover and,
428
+ // deliberately, never fades or disables while the popover is open: the
429
+ // popover holds only ALTERNATE decisions, so the primary keeps its
430
+ // meaning and stays clickable (reverses the held branches' fade, which
431
+ // existed because their panel duplicated the primary's own action).
432
+ const primaryButton = (
433
+ <Button
434
+ variant={primaryVariant}
435
+ size="xs"
436
+ onClick={() => {
437
+ // Muted (platform self-approval, PR6 §3.4): the click is a no-op.
438
+ // Deliberately NOT `disabled` — a disabled button would also lose
439
+ // hover/focus, and the Tooltip below carries the reason.
440
+ if (spec.primary.muted) return;
441
+ handlers.primary?.();
442
+ }}
443
+ disabled={busy}
444
+ aria-disabled={spec.primary.muted || undefined}
445
+ // Muted: the reason renders as a real Tooltip (hover + keyboard
446
+ // focus) and as an aria-describedby description — a native title on
447
+ // top of that would double the tooltip.
448
+ title={spec.primary.muted ? undefined : spec.primary.title}
449
+ aria-describedby={spec.primary.muted ? mutedReasonId : undefined}
450
+ data-decision-primary="true"
451
+ iconLeft={
452
+ isLoading
453
+ ? <Loader2 className="size-3.5 animate-spin" />
454
+ : spec.primary.icon ? ICONS[spec.primary.icon] : undefined
455
+ }
456
+ className={cn(
457
+ 'rounded-r-none border-r-0',
458
+ // Same mute treatment the old platform ApproveButton wore.
459
+ spec.primary.muted && 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground hover:bg-muted',
460
+ )}
461
+ >
462
+ {spec.primary.shortLabel ? (
463
+ <>
464
+ {/* Responsive spans copied from FeedbackButton so the toolbar
465
+ width below xl is unchanged in review. */}
466
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline xl:hidden' : 'hidden md:inline lg:hidden'}>
467
+ {spec.primary.shortLabel}
468
+ </span>
469
+ <span className={labelBreakpoint === 'lg' ? 'hidden xl:inline' : 'hidden lg:inline'}>
470
+ {spec.primary.label}
471
+ </span>
472
+ </>
473
+ ) : (
474
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline' : 'hidden md:inline'}>
475
+ {spec.primary.label}
476
+ </span>
477
+ )}
478
+ {typeof spec.primary.count === 'number' && (
479
+ // The count is the state indicator, not decoration: present at every
480
+ // breakpoint, unlike the labels.
481
+ <span
482
+ data-decision-count="true"
483
+ className="rounded-full bg-white/25 px-1.5 text-[10px] font-bold leading-4"
484
+ >
485
+ {spec.primary.count}
486
+ </span>
487
+ )}
488
+ </Button>
489
+ );
490
+
491
+ return (
492
+ <div ref={rootRef} className="relative inline-flex">
493
+ {spec.primary.muted ? (
494
+ <>
495
+ {/* The self-approval reason must be reachable by keyboard and AT,
496
+ not just mouse hover: the Tooltip opens on hover AND
497
+ focus-visible (Base UI wires floating-ui's useFocus on the
498
+ trigger), and the hidden span makes the same sentence the
499
+ button's persistent accessible description. */}
500
+ <Tooltip content={spec.primary.title} side="bottom" wide>
501
+ {primaryButton}
502
+ </Tooltip>
503
+ <span id={mutedReasonId} hidden>
504
+ {spec.primary.title}
505
+ </span>
506
+ </>
507
+ ) : (
508
+ primaryButton
509
+ )}
510
+
511
+ {/* Right segment — the caret. */}
512
+ <Button
513
+ ref={caretRef}
514
+ variant={primaryVariant}
515
+ size="xs"
516
+ onClick={() => {
517
+ if (popover) closePopover();
518
+ else setPopover('menu');
519
+ }}
520
+ disabled={busy}
521
+ aria-haspopup="menu"
522
+ aria-expanded={popover !== null}
523
+ aria-label="More decisions"
524
+ data-decision-caret="true"
525
+ className="rounded-l-none px-1.5"
526
+ >
527
+ <ChevronDown
528
+ className={cn('size-3.5 transition-transform duration-150', popover && 'rotate-180')}
529
+ aria-hidden="true"
530
+ />
531
+ </Button>
532
+
533
+ {popover !== null && (
534
+ // One popover element for both states: choosing a composer item swaps
535
+ // the children in place (morph), never mounts a second popover.
536
+ <div
537
+ ref={popoverRef}
538
+ role={popover === 'menu' ? 'menu' : undefined}
539
+ data-decision-popover={popover}
540
+ data-pn-dismissable-popover="true"
541
+ onKeyDown={handlePopoverKeyDown}
542
+ className="absolute right-0 top-full z-[70] mt-1.5 w-[22rem] max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-popover shadow-xl"
543
+ >
544
+ {popover === 'menu' ? (
545
+ <div className="py-1">
546
+ {spec.items.map((item) => (
547
+ <React.Fragment key={item.id}>
548
+ {item.dividerBefore ? <ActionMenuDivider /> : null}
549
+ <ActionMenuItem
550
+ role="menuitem"
551
+ icon={item.icon ? ICONS[item.icon] : undefined}
552
+ label={item.label}
553
+ subtitle={item.subtitle}
554
+ // Muted (platform self-approval): the row disables with
555
+ // the reason already in its subtitle; sibling rows stay
556
+ // live so the menu is never a dead end.
557
+ disabled={item.muted}
558
+ className={itemToneClass(item.tone)}
559
+ onClick={() => selectItem(item)}
560
+ />
561
+ </React.Fragment>
562
+ ))}
563
+ </div>
564
+ ) : activeItem?.composer ? (
565
+ <div className="p-2">
566
+ <button
567
+ type="button"
568
+ data-decision-composer-back="true"
569
+ onClick={backToMenu}
570
+ className="mb-1.5 flex items-center gap-1 rounded px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
571
+ >
572
+ <ChevronDown className="size-3 rotate-90" aria-hidden="true" />
573
+ {activeItem.composer.title}
574
+ </button>
575
+ <DecisionNoteField
576
+ text={drafts[activeItem.id] ?? ''}
577
+ onTextChange={(value) =>
578
+ setDrafts((prev) => ({ ...prev, [activeItem.id]: value }))
579
+ }
580
+ onSubmit={submitComposer}
581
+ onCancel={backToMenu}
582
+ placeholder={activeItem.composer.placeholder}
583
+ />
584
+ <div className="mt-2 flex items-center justify-between gap-2 px-0.5">
585
+ <span className="text-[11px] leading-snug text-muted-foreground">
586
+ {submitHint} send · Esc back, note kept
587
+ </span>
588
+ <Button
589
+ variant={toneButtonVariant(activeItem.composer.tone)}
590
+ size="xs"
591
+ data-decision-composer-send="true"
592
+ onClick={submitComposer}
593
+ title={activeItem.composer.actionLabel}
594
+ iconLeft={activeItem.composer.icon ? ICONS[activeItem.composer.icon] : undefined}
595
+ >
596
+ {activeItem.composer.actionLabel}
597
+ </Button>
598
+ </div>
599
+ </div>
600
+ ) : null}
601
+ </div>
602
+ )}
603
+
604
+ {confirmItem?.confirm && (
605
+ <Confirm
606
+ isOpen
607
+ onClose={() => {
608
+ setConfirmItemId(null);
609
+ setPopover('menu');
610
+ }}
611
+ onConfirm={() => {
612
+ const id = confirmItem.id;
613
+ setConfirmItemId(null);
614
+ handlers[id]?.();
615
+ }}
616
+ title={confirmItem.confirm.title}
617
+ message={confirmItem.confirm.message}
618
+ confirmText={confirmItem.confirm.confirmText}
619
+ cancelText="Cancel"
620
+ variant="warning"
621
+ showCancel
622
+ />
623
+ )}
624
+ </div>
625
+ );
626
+ };
@@ -88,7 +88,10 @@ export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> =
88
88
  return () => window.removeEventListener('keydown', handleKeyDown);
89
89
  }, [onDismiss, onSelect, quickLabels]);
90
90
 
91
- // Click outside to dismiss
91
+ // Click outside to dismiss. Deliberately NOT useDismissablePopover: this
92
+ // picker needs deferred capture-phase registration (the selection gesture
93
+ // that opens it must not instantly dismiss it), and Escape is handled in
94
+ // the digit-key listener above.
92
95
  useEffect(() => {
93
96
  const handlePointerDown = (e: PointerEvent) => {
94
97
  if (ref.current && !ref.current.contains(e.target as Node)) {
@@ -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;