import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; import { Check, ChevronDown, Loader2, Send } from 'lucide-react'; import { Button } from './ui/button'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog'; import { Tooltip } from './Tooltip'; import { ActionMenuDivider, ActionMenuItem } from './ActionMenu'; import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'; import { useDismissablePopover } from '../hooks/useDismissablePopover'; import { useCompactTouchLayout } from '../hooks/useIsMobile'; import { cn } from '../lib/utils'; import type { DecisionActionId, DecisionComposer, DecisionMenuItem, DecisionSpec, DecisionTone, } from '../utils/decisionSpec'; /** * The unified header decision control: one joined split pill — the incumbent * primary on the left, a caret on the right whose popover carries the * alternate decisions and the in-place note composer. Rendered entirely from a * `DecisionSpec` (see `utils/decisionSpec.ts`); the host owns the handlers. * * NOT host-supported surface (like ActionMenu/ConfirmDialog): session/transport * chrome for Plannotator's own decision endpoints, deliberately absent from * the README supported-import list. * * Interaction contract (the load-bearing rules): * - The left segment never opens the popover and never changes meaning within * a state; the caret owns the popover. * - Choosing a composer item MORPHS the same popover into the note composer — * same element, children swap, never a second popover. * - Esc ladder: composer → back to the menu keeping the draft; menu → close, * focus returns to the caret. The event is consumed (stopPropagation) on * exactly those two rungs; with nothing open the host app's own Escape * ladder still runs. * - Mod+Enter in the note field fires the composer's labelled action; plain * Enter is a newline, always. * - An empty note never submits and never grays the action: the click * refocuses the field instead. */ export type DecisionHandler = (note?: string) => void; export interface DecisionControlProps { spec: DecisionSpec; handlers: Record; /** isSubmitting || isExiting || isApproving — disables both segments and * closes an open popover. */ busy: boolean; /** Spinner on the primary only. */ isLoading: boolean; labelBreakpoint?: 'md' | 'lg'; /** The host says the surface is framed (raw-HTML srcdoc / live-app proxy): * clicks inside the iframe never reach the parent document, so iframe * focus dismisses the popover instead. */ dismissOnIframeFocus?: boolean; /** Confirm surface override; defaults to the ui ConfirmDialog. */ confirmDialog?: React.ComponentType; } const NOTE_MAX_HEIGHT_PX = 144; const ICONS: Record<'check' | 'send', React.ReactNode> = { check: , send: , }; function toneButtonVariant(tone: Exclude): 'success' | 'default' { return tone === 'success' ? 'success' : 'default'; } function itemToneClass(tone: DecisionTone): string | undefined { if (tone === 'destructive') return 'text-destructive'; if (tone === 'success') return 'text-success'; return undefined; } function useAutoGrow(text: string) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.min(el.scrollHeight, NOTE_MAX_HEIGHT_PX)}px`; }, [text]); return ref; } export interface DecisionNoteFieldProps { text: string; onTextChange: (value: string) => void; /** Mod+Enter / the labelled action. The owner enforces the empty-note * no-op-and-refocus rule, so this fires for any text. */ onSubmit: () => void; /** Escape. Consumes the event; the owner decides what "back" means. */ onCancel: () => void; placeholder?: string; disabled?: boolean; autoFocus?: boolean; } /** * The shared multi-line note field: two rows at rest, grows to 144px, then * scrolls. Controlled — the owner keeps the text so stepping back does not * discard a half-typed note. Exported separately because a third consumer * (the review sidebar's "+ General comment") composes it outside the control. */ export const DecisionNoteField: React.FC = ({ text, onTextChange, onSubmit, onCancel, placeholder = 'Add a note...', disabled = false, autoFocus = true, }) => { const ref = useAutoGrow(text); useEffect(() => { if (autoFocus) ref.current?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocus]); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === 'Escape') { // A consuming rung of the Esc ladder: the host apps run their own // Escape ladders and must not also see this event. event.preventDefault(); event.stopPropagation(); onCancel(); return; } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); if (!disabled) onSubmit(); } // Plain Enter falls through: the field is multi-line and Enter's job is // a newline. }, [disabled, onCancel, onSubmit], ); return (