diff --git a/AGENTS.md b/AGENTS.md index 2006c8d1a..5377b7d8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,41 @@ Send Feedback → feedback sent to agent session Approve → "LGTM" sent to agent session ``` +### Send with additional feedback + +The agent-mode review toolbar's Send Feedback is a joined split pill +`[Send Feedback | v]` (`packages/review-editor/components/ReviewSendControl.tsx`, +wired through `AgentReviewActions`'s optional `note` prop — omit it and the +incumbent `FeedbackButton` renders unchanged). The LEFT segment never changes +meaning: same label, icon, `labelBreakpoint="lg"` spans and `handleSendFeedback` +as before, except that with nothing to send it opens the panel rather than +raising the "No Annotations" dialog. The caret opens a right-anchored panel with +a multi-line note field (Enter is a newline, `Mod+Enter` submits, `Escape` closes +and KEEPS the text, outside pointerdown closes) whose own distinct action reads +**Send with additional feedback**. The two actions are always different buttons. +While the panel (or the compact dialog) is open, the header's primary Send +Feedback fades to 40% and is disabled so the panel's action is unmistakably the +submit; every close path restores it. The panel action itself always renders +full-strength — an empty-note click is a no-op that refocuses the field (on +touch, that raises the keyboard). + +The note is materialized at submit time by `commitReviewNote` in +`packages/review-editor/App.tsx` as a `scope: 'general'` `CodeAnnotation` +(`filePath: ''`, `lineStart/lineEnd: 0` — the documented sentinels), so it rides +the sidebar's General group, `renderGeneralComments`'s `## General` export +section, `buildFileScopedBody`, the draft, and the `/api/feedback` annotations +array with **zero server change** (`annotations` is `unknown[]` on both runtimes +and is only counted and forwarded). It is deliberately NOT recorded in review +undo history (it lives for one submit) and NOT stamped with PR context, so it +survives an in-place PR switch or a layer/full-stack toggle. Submission waits one +render, because `feedbackMarkdown` and `handleSendFeedback` close over +`allAnnotations`. Compact/touch gets the same commit path through an additive +`note` row in the header `ActionMenu` opening `ReviewNoteDialog`; platform (PR) +mode deliberately has no caret, since `ReviewSubmissionDialog` already owns the +general-comment field there. LGTM-with-a-note is a separate, coupled phase (four +consumer call sites discard `result.feedback` on the approved path) and is not +built. + ### Since-main default review view The default code-review diff is **`since-base`** — a composite of `merge-base(base, HEAD)` vs the working tree plus untracked files ("everything a PR would show if you committed and pushed now"). It can render as a three-section **git status** panel (Committed / Changes / Untracked) via `SectionsPanel`, with a `Tree | Git status | Commits` toggle (`PanelViewToggle`). The Commits segment (git-local sessions only) is a linear `--first-parent` history rail (`CommitsPanel`): clicking a commit opens its own diff (`commit:`, vs its first parent) as the all-files view headed by the commit message rendered as markdown. The Commits view is a self-contained detour: entering it memoizes the previously active diff, exiting to Tree restores that diff verbatim (exiting to Git status resets to `since-base` as always), the memo clears whenever any non-commit diff is applied, and a reload that serves a commit-family diff with a non-Commits panel view snaps once to the session default so the commit diff cannot outlive the visit. The toggle never writes the persisted `reviewPanelView`/`defaultDiffType` pair (no server writes from a toggle click), but it does record a cookie-only last-used memo (`reviewPanelViewLastUsed`, `sections` | `tree` — never `commits`; the Commits view is session-only). A review OPENS on session choice ?? last-used memo ?? persisted `reviewPanelView` (cookie-only, written only by Settings and `ReviewSetupDialog` through `setReviewPanelView()`, which also syncs the memo so an explicit choice is never shadowed by a stale one — except the App self-heal, which passes `recordLastUsed: false` to repair the diff half of a conflicted pair without touching the memo). The first-run initializer marks review-setup-seen when it seeds the cookie-only Tree choice, not only on dismiss, so it is genuinely one-time per browser and cannot overwrite a returning reviewer's persisted or last-used view; it inherits the resolved `defaultDiffType` without a server config write. The persisted pair is coupled: the Sections view only renders `since-base`, so choosing a classic diff default snaps the persisted view to Tree and vice-versa (enforced in `ReviewSetupDialog`, the Settings Git tab, and the App first-run initializer). diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 74f887150..94f1d35d0 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -14,6 +14,7 @@ import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Settings } from '@plannotator/ui/components/Settings'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; import { AgentReviewActions } from './components/AgentReviewActions'; +import { ReviewNoteDialog, type ReviewSubmitNoteControl } from './components/ReviewSendControl'; import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; @@ -3563,6 +3564,61 @@ const ReviewApp: React.FC = () => { } }, [getDraftGeneration]); + // --- Review-level note ("Send with additional feedback") --------------- + // The note is materialized at submit time as a scope:'general' annotation so + // it rides the existing export (## General) and the /api/feedback annotations + // array with no server change. Deliberately NOT recorded in review history + // (it lives for one submit) and deliberately NOT stamped with PR context, so + // it survives an in-place PR switch or a layer/full-stack toggle. + const [compactNoteOpen, setCompactNoteOpen] = useState(false); + const [pendingNoteId, setPendingNoteId] = useState(null); + + const commitReviewNote = useCallback((text: string): string | null => { + const trimmed = text.trim(); + if (!trimmed) return null; + const note: CodeAnnotation = { + id: `review-note-${Date.now()}`, + type: 'comment', + scope: 'general', + filePath: '', + lineStart: 0, + lineEnd: 0, + side: 'new', + text: trimmed, + createdAt: Date.now(), + ...(identity ? { author: identity } : {}), + }; + annotationsRef.current = [...annotationsRef.current, note]; + setAnnotations(annotationsRef.current); + return note.id; + }, [identity]); + + const handleSubmitReviewNote = useCallback((text: string) => { + if (isSendingFeedback || isApproving || isExiting || submitted) return; + const id = commitReviewNote(text); + if (!id) { + // Nothing typed: fall back to the incumbent send when there is something + // to send, and otherwise do nothing (an empty note is not a submission). + if (totalAnnotationCount > 0) void handleSendFeedback(); + return; + } + setPendingNoteId(id); + }, [commitReviewNote, handleSendFeedback, isApproving, isExiting, isSendingFeedback, submitted, totalAnnotationCount]); + + // feedbackMarkdown and handleSendFeedback close over allAnnotations, so the + // send has to wait for the render that carries the note. + useEffect(() => { + if (!pendingNoteId) return; + if (!allAnnotations.some(a => a.id === pendingNoteId)) return; + setPendingNoteId(null); + void handleSendFeedback(); + }, [allAnnotations, handleSendFeedback, pendingNoteId]); + + const reviewNoteControl = useMemo( + () => ({ onSubmit: handleSubmitReviewNote }), + [handleSubmitReviewNote], + ); + // Submit reviews to one or more PRs via /api/pr-action const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', plan: ReviewSubmission, generalComment?: string) => { setIsPlatformActioning(true); @@ -3917,6 +3973,15 @@ const ReviewApp: React.FC = () => { onSelect: () => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit(), disabled: compactActionBusy, }, + ...(!platformMode + ? [{ + id: 'note' as const, + label: 'Add a note', + ...(totalAnnotationCount > 0 ? { subtitle: 'Sent with your annotations' } : {}), + onSelect: () => setCompactNoteOpen(true), + disabled: compactActionBusy, + }] + : []), ...(totalAnnotationCount > 0 ? [{ id: 'feedback' as const, @@ -4289,6 +4354,7 @@ const ReviewApp: React.FC = () => { onSendFeedback={handleSendFeedback} onApprove={() => totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + note={submitted ? undefined : reviewNoteControl} /> ) : ( <> @@ -4967,6 +5033,15 @@ const ReviewApp: React.FC = () => { /> )} + {/* Compact/touch review-level note composer */} + setCompactNoteOpen(false)} + note={reviewNoteControl} + disabled={compactActionBusy || !!submitted} + annotationCount={totalAnnotationCount} + /> + {/* No annotations dialog */} void; onApprove: () => void; onExit: () => void; + /** Enables the note half of the split Send control. Omitted (a host that + * does not wire a note) falls back to the incumbent FeedbackButton. */ + note?: ReviewSubmitNoteControl; } /** * Toolbar actions for agent review mode (all non-platform origins). * - * The left button flips based on whether there are annotations: - * No annotations → [Close] [Approve] - * Has annotations → [Send Feedback] [Approve] - * * - Close (Exit): closes the session without sending feedback - * - Send Feedback: primary action when annotations exist + * - Send Feedback: the incumbent send. With a `note` it is the left segment of + * a split pill whose caret opens a review-level note composer; the segment's + * label, icon, breakpoints and handler are unchanged either way, and with no + * note wired it is the plain FeedbackButton shown only when annotations + * exist. * - Approve: LGTM; dimmed when annotations exist (they won't be sent) */ export const AgentReviewActions: React.FC = ({ @@ -30,6 +34,7 @@ export const AgentReviewActions: React.FC = ({ onSendFeedback, onApprove, onExit, + note, }) => { const busy = isSendingFeedback || isApproving || isExiting; const hasAnnotations = totalAnnotationCount > 0; @@ -43,7 +48,15 @@ export const AgentReviewActions: React.FC = ({ labelBreakpoint="lg" /> - {hasAnnotations && ( + {note ? ( + + ) : hasAnnotations ? ( = ({ title="Send feedback" labelBreakpoint="lg" /> - )} + ) : null}
{ expect(onFeedback).toHaveBeenCalledTimes(1); expect(host?.textContent).not.toContain('Post comments'); }); + + // Standing toolbar-integrity rule: the additive 'note' row must not remove, + // reorder, or disable any incumbent compact action. The closed-union id edit + // is where that actually risks breaking. + test.skipIf(!hasDom)('the additive note row joins the incumbent rows without displacing them', async () => { + const onNote = mock(() => {}); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + + await act(async () => root?.render( + + {}} + onOpenExport={() => {}} + onCopyAgentInstructions={() => {}} + onToggleFileTree={() => {}} + onToggleSidebar={() => {}} + isFileTreeOpen={false} + isSidebarOpen={false} + compactTouchLayout + compactActions={[ + { id: 'exit', label: 'Exit review', onSelect: () => {} }, + { id: 'note', label: 'Add a note', subtitle: 'Sent with your annotations', onSelect: onNote }, + { id: 'feedback', label: 'Send feedback', subtitle: '2 annotations', onSelect: () => {} }, + { id: 'approve', label: 'Approve', onSelect: () => {} }, + ]} + agentInstructionsEnabled={false} + appVersion="test" + /> + , + )); + + await act(async () => host?.querySelector('button[aria-label="Options"]')?.click()); + + const labels = ['Exit review', 'Add a note', 'Send feedback', 'Approve']; + const rows = Array.from(host?.querySelectorAll('button') ?? []) + .filter((button) => labels.some((label) => button.textContent?.includes(label))); + expect(rows.length).toBe(4); + expect(rows.map((button) => labels.find((label) => button.textContent?.includes(label)))).toEqual(labels); + expect(rows.every((button) => !button.disabled)).toBe(true); + + const noteRow = rows[1]; + await act(async () => noteRow.click()); + expect(onNote).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/review-editor/components/ReviewHeaderMenu.tsx b/packages/review-editor/components/ReviewHeaderMenu.tsx index 64899ab0a..dfc263f9e 100644 --- a/packages/review-editor/components/ReviewHeaderMenu.tsx +++ b/packages/review-editor/components/ReviewHeaderMenu.tsx @@ -25,7 +25,7 @@ export interface CompactReviewDestination { } export interface CompactReviewAction { - id: 'exit' | 'feedback' | 'approve' | 'copy'; + id: 'exit' | 'note' | 'feedback' | 'approve' | 'copy'; label: string; subtitle?: string; onSelect: () => void; @@ -403,6 +403,13 @@ const CompactReviewActionIcon: React.FC<{ kind: CompactReviewAction['id'] }> = ( ); } + if (kind === 'note') { + return ( + + + + ); + } if (kind === 'copy') return ; return ; }; diff --git a/packages/review-editor/components/ReviewSendControl.test.tsx b/packages/review-editor/components/ReviewSendControl.test.tsx new file mode 100644 index 000000000..8120979e5 --- /dev/null +++ b/packages/review-editor/components/ReviewSendControl.test.tsx @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { ReviewSendControl, REVIEW_NOTE_SEND_LABEL } from './ReviewSendControl'; + +/** + * The split Send control's interaction contract (DOM-gated). + * + * Each test names the regression it guards; the load-bearing invariant is that + * "Send Feedback" and "Send with additional feedback" are DIFFERENT buttons — + * the incumbent left segment never acquires the note, and the note never + * arrives without an explicit distinct action. + */ + +const hasDom = typeof document !== 'undefined'; +let root: Root | null = null; +let host: HTMLElement | null = null; + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + host?.remove(); + host = null; + if (hasDom) document.body.replaceChildren(); +}); + +interface Handlers { + onSend: ReturnType; + onSubmit: ReturnType; +} + +async function mount(options: { hasFeedback?: boolean } = {}): Promise { + const onSend = mock(() => {}); + const onSubmit = mock((_text: string) => {}); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render( + , + ); + }); + return { onSend, onSubmit }; +} + +function primary(): HTMLButtonElement { + const button = host?.querySelector( + 'button:not([data-review-note-toggle]):not([data-review-note-send])', + ); + if (!button) throw new Error('primary send segment did not render'); + return button; +} + +function caret(): HTMLButtonElement { + const button = host?.querySelector('[data-review-note-toggle]'); + if (!button) throw new Error('caret segment did not render'); + return button; +} + +function panel(): HTMLElement | null { + return host?.querySelector('[data-review-note-composer="anchored"]') ?? null; +} + +function field(): HTMLTextAreaElement { + const input = host?.querySelector('[data-review-note-input]'); + if (!input) throw new Error('note field did not render'); + return input; +} + +function noteSend(): HTMLButtonElement { + const button = host?.querySelector('[data-review-note-send]'); + if (!button) throw new Error('note send button did not render'); + return button; +} + +async function openPanel() { + await act(async () => caret().click()); +} + +async function type(value: string) { + const input = field(); + // React tracks the node's value, so the prototype setter is what makes the + // native input event read as a real change. + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value')?.set; + await act(async () => { + if (setter) setter.call(input, value); + else input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +async function key(init: KeyboardEventInit) { + await act(async () => { + field().dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })); + }); +} + +describe('ReviewSendControl', () => { + test.skipIf(!hasDom)('renders both segments of one pill and starts closed', async () => { + await mount(); + expect(primary()).toBeTruthy(); + expect(caret().getAttribute('aria-expanded')).toBe('false'); + expect(panel()).toBeNull(); + }); + + test.skipIf(!hasDom)('the caret opens the note panel', async () => { + await mount(); + await openPanel(); + expect(panel()).toBeTruthy(); + expect(caret().getAttribute('aria-expanded')).toBe('true'); + }); + + // Guards the most likely "helpful" regression: merging the two actions into + // one button, at which point a reviewer who typed a note and clicked Send + // loses it silently. + test.skipIf(!hasDom)('the left segment sends plainly when the panel is closed', async () => { + const { onSend, onSubmit } = await mount({ hasFeedback: true }); + await act(async () => primary().click()); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + // Guards the maintainer-directed disambiguation: an open note panel fades + // and disables the header's primary send, so a typed note can never be + // silently dropped by a muscle-memory click on Send Feedback. Closing the + // panel restores it. + test.skipIf(!hasDom)('an open panel fades and disables the primary; closing restores it', async () => { + const { onSend } = await mount({ hasFeedback: true }); + expect(primary().disabled).toBe(false); + expect(primary().className).not.toContain('opacity-40'); + + await openPanel(); + await type('do not lose me'); + expect(primary().disabled).toBe(true); + expect(primary().className).toContain('opacity-40'); + await act(async () => primary().click()); + expect(onSend).not.toHaveBeenCalled(); + + await key({ key: 'Escape' }); + expect(primary().disabled).toBe(false); + expect(primary().className).not.toContain('opacity-40'); + await act(async () => primary().click()); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + // Guards a revert to a one-line field, which would truncate a multi-line note + // at the first newline. + test.skipIf(!hasDom)('Enter is a newline; Mod+Enter submits the note', async () => { + const { onSubmit } = await mount(); + await openPanel(); + await type('first line'); + await key({ key: 'Enter' }); + expect(onSubmit).not.toHaveBeenCalled(); + + await key({ key: 'Enter', metaKey: true }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('first line'); + + // ctrlKey is the same chord off macOS. + await openPanel(); + await type('ctrl line'); + await key({ key: 'Enter', ctrlKey: true }); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit.mock.calls[1][0]).toBe('ctrl line'); + }); + + test.skipIf(!hasDom)('the distinct action submits exactly the typed note', async () => { + const { onSend, onSubmit } = await mount(); + await openPanel(); + await type('split the migration first'); + await act(async () => noteSend().click()); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toBe('split the migration first'); + expect(onSend).not.toHaveBeenCalled(); + expect(panel()).toBeNull(); + }); + + // Guards clearing-on-close (throws away a half-typed note) and the + // stopPropagation that keeps Escape out of the review app's own ladder. + test.skipIf(!hasDom)('Escape closes, keeps the text, and does not submit', async () => { + const { onSubmit } = await mount(); + let escapeReachedApp = false; + const listener = () => { escapeReachedApp = true; }; + document.addEventListener('keydown', listener); + try { + await openPanel(); + await type('half typed'); + await key({ key: 'Escape' }); + } finally { + document.removeEventListener('keydown', listener); + } + expect(panel()).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(escapeReachedApp).toBe(false); + + await openPanel(); + expect(field().value).toBe('half typed'); + }); + + // Guards submitting an empty scope:'general' annotation, which would export + // as a blank bullet under ## General. + // Guards the maintainer-directed visual rule: the panel's action must stay + // full-strength (never grayed) while the panel is open — only the header + // primary fades. An empty or whitespace-only note is a click no-op. + test.skipIf(!hasDom)('the distinct action stays enabled-looking; empty text is a no-op', async () => { + const { onSubmit } = await mount(); + await openPanel(); + expect(noteSend().disabled).toBe(false); + await act(async () => noteSend().click()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(panel()).not.toBeNull(); + + await type(' '); + await act(async () => noteSend().click()); + expect(onSubmit).not.toHaveBeenCalled(); + + await type('real note'); + await act(async () => noteSend().click()); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + // Guards a regression that raises the "No Annotations" dialog from a button + // whose whole purpose at zero annotations is the note. + test.skipIf(!hasDom)('with nothing to send the primary opens the panel instead of sending', async () => { + const { onSend } = await mount({ hasFeedback: false }); + await act(async () => primary().click()); + expect(onSend).not.toHaveBeenCalled(); + expect(panel()).toBeTruthy(); + }); + + test.skipIf(!hasDom)('a pointerdown outside the control closes the panel', async () => { + await mount(); + await openPanel(); + const outside = document.createElement('div'); + document.body.appendChild(outside); + await act(async () => { + outside.dispatchEvent(new Event('pointerdown', { bubbles: true })); + }); + outside.remove(); + expect(panel()).toBeNull(); + }); + + // Deliberately frozen, maintainer-approved label — the note action must stay + // distinguishable from the incumbent "Send Feedback". Not a prose snapshot. + test.skipIf(!hasDom)('the panel action reads "Send with additional feedback"', async () => { + await mount(); + await openPanel(); + expect(noteSend().textContent).toBe('Send with additional feedback'); + expect(REVIEW_NOTE_SEND_LABEL).toBe('Send with additional feedback'); + }); +}); diff --git a/packages/review-editor/components/ReviewSendControl.tsx b/packages/review-editor/components/ReviewSendControl.tsx new file mode 100644 index 000000000..8ccf2daca --- /dev/null +++ b/packages/review-editor/components/ReviewSendControl.tsx @@ -0,0 +1,369 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Send } from 'lucide-react'; +import { Button } from '@plannotator/ui/components/ui/button'; +import { submitHint } from '@plannotator/ui/utils/platform'; +import { useCompactTouchLayout } from '@plannotator/ui/hooks/useIsMobile'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@plannotator/ui/components/ui/dialog'; + +/** + * One-step "send with a note" for the code review surface. + * + * The note itself is not owned here: App materializes it at submit time as a + * `scope: 'general'` CodeAnnotation so it rides `exportReviewFeedback`'s + * `## General` section and the `/api/feedback` annotations array with no server + * change. This component only owns the affordance and the typed text + * (deliberately local, so a keystroke never re-renders the review header). + * + * Interaction contract: + * - The control is ONE joined split button: [Send Feedback | v]. The left + * segment is always the incumbent plain send, unchanged; the caret is a + * separate segment of the same pill. + * - The caret opens a panel BELOW the pill with a multi-line note field and its + * own distinct action, "Send with additional feedback", which sends the note + * together with everything already in the session. The two actions never + * share a button. + * - The note field: Enter inserts a newline, Mod+Enter submits with feedback, + * Esc closes the panel and keeps the half-typed text. + * + * DUPLICATE, ON PURPOSE. Its annotate twin is + * `packages/editor/components/AnnotateSendControl.tsx`; the two share the field + * contract but not their props (this one carries responsive toolbar labels, a + * three-way busy expression and a compact dialog sibling). Tripwire: if a THIRD + * surface ever needs this field, extract the textarea + auto-grow + key + * handling as `packages/ui/components/SubmitNoteField.tsx` and have both + * controls compose it — as its own PR, never mixed into a feature. + */ +export interface ReviewSubmitNoteControl { + /** Submit this note together with any annotations already in the session. */ + onSubmit: (text: string) => void; +} + +export const REVIEW_NOTE_PLACEHOLDER = 'Add a note...'; + +/** Deliberately frozen, maintainer-approved label for the distinct action. It + * must never collapse into the incumbent Send Feedback button's label. */ +export const REVIEW_NOTE_SEND_LABEL = 'Send with additional feedback'; + +/** Two lines tall at rest, grows with content, then scrolls. */ +const NOTE_MAX_HEIGHT_PX = 144; + +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; +} + +interface ReviewNoteFieldProps { + text: string; + onTextChange: (value: string) => void; + onSubmit: (text: string) => void; + onClose: () => void; + disabled?: boolean; + autoFocus?: boolean; +} + +/** The multi-line note field. Controlled: the owner keeps the text so closing + * and reopening does not discard a half-typed note. */ +const ReviewNoteField: React.FC = ({ + text, + onTextChange, + onSubmit, + onClose, + disabled = false, + autoFocus = true, +}) => { + const ref = useAutoGrow(text); + + useEffect(() => { + if (autoFocus) ref.current?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFocus]); + + const canSend = text.trim().length > 0; + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + // Stop here: the review app runs its own Escape ladder (file tree, + // sidebar, dialogs), and a bare Escape in this field is ours. + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + event.stopPropagation(); + if (!disabled && canSend) onSubmit(text); + } + // Plain Enter falls through: the field is multi-line and Enter's job is + // a newline. + }, + [canSend, disabled, onClose, onSubmit, text], + ); + + return ( +