From c5c85c00c9ddccd5f7544686fdcfede3051c685a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 1 Sep 2026 16:04:32 -0700 Subject: [PATCH 1/3] feat(review): send with additional feedback split control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-mode code review toolbar's Send Feedback becomes one joined split pill [Send Feedback | v]. The left segment is the incumbent button verbatim — same label, icon, labelBreakpoint="lg" spans, disabled/loading behavior and handleSendFeedback — with one addition: at zero annotations it opens the note panel instead of raising the "No Annotations" dialog. Nothing is removed or hidden; Close and Approve are untouched, and AgentReviewActions falls back to the plain FeedbackButton when no `note` prop is wired. The caret opens a right-anchored panel holding a multi-line auto-growing note field (rows=2, capped at 144px then scrolls) and a footer of submitHint plus a DISTINCT action button labeled "Send with additional feedback". The two actions are never the same button. Enter inserts a newline; Mod+Enter (meta or ctrl) fires the distinct action; Escape closes and KEEPS the typed text, with stopPropagation so it never reaches the review app's own Escape ladder; a pointerdown outside closes. The text lives in the control, so a keystroke never re-renders the review header. At submit the note is materialized as a scope:'general' CodeAnnotation (filePath '', lineStart/lineEnd 0 — the documented sentinels) and pushed into the annotation list, then the send waits one render because feedbackMarkdown and handleSendFeedback close over allAnnotations. That makes the sidebar's General group, renderGeneralComments's ## General export section, buildFileScopedBody and the draft pick it up for free, and it rides the existing /api/feedback annotations array with ZERO server change on either runtime. The note 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. Compact/touch shares the same commit path through an additive `note` row in the header ActionMenu opening ReviewNoteDialog; platform (PR) mode gets no caret, because ReviewSubmissionDialog already owns the general-comment field there. The control is a deliberate duplicate of the annotate twin (packages/editor/components/AnnotateSendControl.tsx) with an extraction tripwire in its module doc. LGTM-with-a-note is explicitly deferred. Tests: component DOM contract (split shape, caret, plain-Enter, Mod+Enter, distinct action, Escape-keeps-text, empty/whitespace guard, zero-annotation primary, outside click, frozen action label); the pure export trace through renderGeneralComments; a dual-runtime /api/feedback payload test asserting the note reaches waitForDecision unmodified and archives as a feedback decision; and a compact-menu toolbar-integrity test for the additive row. --- AGENTS.md | 30 ++ packages/review-editor/App.tsx | 75 ++++ .../components/AgentReviewActions.tsx | 27 +- .../ReviewHeaderMenu.mobile.test.tsx | 46 +++ .../components/ReviewHeaderMenu.tsx | 9 +- .../components/ReviewSendControl.test.tsx | 226 ++++++++++++ .../components/ReviewSendControl.tsx | 335 ++++++++++++++++++ packages/review-editor/shortcuts.ts | 2 + .../utils/exportFeedback.reviewNote.test.ts | 62 ++++ packages/server/review-note-payload.test.ts | 181 ++++++++++ .../code-review/reviewNote.shortcuts.ts | 27 ++ packages/ui/shortcuts/index.ts | 1 + 12 files changed, 1013 insertions(+), 8 deletions(-) create mode 100644 packages/review-editor/components/ReviewSendControl.test.tsx create mode 100644 packages/review-editor/components/ReviewSendControl.tsx create mode 100644 packages/review-editor/utils/exportFeedback.reviewNote.test.ts create mode 100644 packages/server/review-note-payload.test.ts create mode 100644 packages/ui/shortcuts/code-review/reviewNote.shortcuts.ts diff --git a/AGENTS.md b/AGENTS.md index 2006c8d1a..e1ba8a4d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,36 @@ 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. + +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..a48121e84 --- /dev/null +++ b/packages/review-editor/components/ReviewSendControl.test.tsx @@ -0,0 +1,226 @@ +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 and never carries the note', async () => { + const { onSend, onSubmit } = await mount({ hasFeedback: true }); + await openPanel(); + await type('rebase before merging'); + await act(async () => primary().click()); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + // 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. + test.skipIf(!hasDom)('the distinct action is disabled for empty and whitespace-only text', async () => { + const { onSubmit } = await mount(); + await openPanel(); + expect(noteSend().disabled).toBe(true); + await type(' \n '); + expect(noteSend().disabled).toBe(true); + await key({ key: 'Enter', metaKey: true }); + expect(onSubmit).not.toHaveBeenCalled(); + await type('real'); + expect(noteSend().disabled).toBe(false); + }); + + // 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..29dc20130 --- /dev/null +++ b/packages/review-editor/components/ReviewSendControl.tsx @@ -0,0 +1,335 @@ +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 ( +