diff --git a/packages/annotation/src/App.stories.tsx b/packages/annotation/src/App.stories.tsx index 71a30f1..7f2ab50 100644 --- a/packages/annotation/src/App.stories.tsx +++ b/packages/annotation/src/App.stories.tsx @@ -164,6 +164,28 @@ export const Default: Story = { decorators: [withAppContext({ submitAnnotation: delayedSubmit })], }; +export const ApprovalModeSelector: Story = { + args: { + source: 'hook_claude', + initialPayload: { + contentKind: 'plan', + content: samplePlan, + title: 'Refactor auth middleware', + metadata: { entrypoint: 'hook_claude' }, + }, + }, + render: Default.render, + decorators: [withAppContext({ submitAnnotation: delayedSubmit })], + parameters: { + docs: { + description: { + story: + 'Zero-feedback review from a Claude Code hook: the compound submit control (primary Approve Plan button + chevron trigger opening the approval-mode radio menu) is visible for visual review.', + }, + }, + }, +}; + export const SeededReview: Story = { args: { initialPayload: { diff --git a/packages/annotation/src/App.test.tsx b/packages/annotation/src/App.test.tsx index c9d1a06..a83cc55 100644 --- a/packages/annotation/src/App.test.tsx +++ b/packages/annotation/src/App.test.tsx @@ -18,6 +18,7 @@ import { submitBarTestIds } from './SubmitBar.tsx'; import { drag, pressSubmitShortcut, renderApp } from './testHelpers/index.tsx'; import { themePickerTestIds } from './ThemePicker.tsx'; import { updateNoticeCardTestIds } from './UpdateNoticeCard.tsx'; +import { approvalModeCopy, closeReviewDialogCopy, submitBarCopy, withApprovalMode } from './useAnnotationState.ts'; describe('App', () => { afterEach(() => { @@ -977,6 +978,186 @@ Run \`${longCode}\` now. }); }); + describe('approval mode selector', () => { + it('shows the mode trigger for hook_claude with zero feedback before submitting', () => { + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + expect(screen.getByTestId(submitBarTestIds.modeTrigger)).toBeInTheDocument(); + }); + + it('hides the mode trigger for other entrypoints', () => { + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_codex' } }, + }); + + expect(screen.queryByTestId(submitBarTestIds.modeTrigger)).not.toBeInTheDocument(); + }); + + it('hides the mode trigger once feedback exists', async () => { + const user = userEvent.setup(); + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.type(screen.getByTestId(globalCommentComposerTestIds.textarea), 'Needs work'); + + expect(screen.getByTestId(submitBarTestIds.button)).toHaveTextContent(submitBarCopy.feedback); + expect(screen.queryByTestId(submitBarTestIds.modeTrigger)).not.toBeInTheDocument(); + }); + + it('hides the mode trigger after submit', async () => { + const user = userEvent.setup(); + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.button)); + + await screen.findByTestId(submitBarTestIds.countdown); + expect(screen.queryByTestId(submitBarTestIds.modeTrigger)).not.toBeInTheDocument(); + }); + + it('submits approvalMode "auto" when Auto is selected before approving', async () => { + const user = userEvent.setup(); + const { submitAnnotation } = renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + await user.click(await screen.findByTestId(submitBarTestIds.modeOption('auto'))); + await user.click(screen.getByTestId(submitBarTestIds.button)); + + await waitFor(() => { + expect(submitAnnotation).toHaveBeenCalledTimes(1); + }); + expect(submitAnnotation.mock.calls[0]?.[0]).toMatchObject({ status: 'approved', approvalMode: 'auto' }); + }); + + it('reflects the selected non-default mode in the submit button label', async () => { + const user = userEvent.setup(); + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + await user.click(await screen.findByTestId(submitBarTestIds.modeOption('auto'))); + + expect(screen.getByTestId(submitBarTestIds.button)).toHaveTextContent( + withApprovalMode(submitBarCopy.approve, 'auto'), + ); + }); + + it('keeps the default label when acceptEdits is selected', () => { + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + expect(screen.getByTestId(submitBarTestIds.button)).toHaveTextContent(submitBarCopy.approve); + expect(screen.getByTestId(submitBarTestIds.button)).not.toHaveTextContent('('); + }); + + it('disables the mode trigger while submitting', async () => { + const user = userEvent.setup(); + const pendingSubmission = createDeferred(); + const submitAnnotation = vi + .fn<(submission: AnnotationSubmission) => Promise>() + .mockReturnValue(pendingSubmission.promise); + renderApp( + { initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } } }, + { submitAnnotation }, + ); + + await user.click(screen.getByTestId(submitBarTestIds.button)); + + await waitFor(() => { + expect(screen.getByTestId(submitBarTestIds.modeTrigger)).toBeDisabled(); + }); + + pendingSubmission.resolve(); + await screen.findByTestId(submitBarTestIds.countdown); + }); + + it('reflects the controlled approvalMode in the radio group checked state', async () => { + const user = userEvent.setup(); + renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + + expect(await screen.findByTestId(submitBarTestIds.modeOption('acceptEdits'))).toHaveAttribute( + 'aria-checked', + 'true', + ); + expect(screen.getByTestId(submitBarTestIds.modeOption('auto'))).toHaveAttribute('aria-checked', 'false'); + + await user.click(screen.getByTestId(submitBarTestIds.modeOption('auto'))); + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + + expect(await screen.findByTestId(submitBarTestIds.modeOption('auto'))).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId(submitBarTestIds.modeOption('acceptEdits'))).toHaveAttribute('aria-checked', 'false'); + }); + + it('suffixes the close-review dialog primary action with the selected mode for hook_claude with no feedback', async () => { + const user = userEvent.setup(); + const { browser } = renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + await user.click(await screen.findByTestId(submitBarTestIds.modeOption('auto'))); + + await act(async () => { + browser.triggerBeforeUnload(); + await Promise.resolve(); + }); + + expect(await screen.findByTestId(appTestIds.closeReviewDialogActionButton)).toHaveTextContent( + withApprovalMode(submitBarCopy.approve, 'auto'), + ); + }); + + it('does not suffix the close-review dialog primary action once feedback exists', async () => { + const user = userEvent.setup(); + const { browser } = renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await user.click(screen.getByTestId(submitBarTestIds.modeTrigger)); + await user.click(await screen.findByTestId(submitBarTestIds.modeOption('auto'))); + + await user.type(screen.getByTestId(globalCommentComposerTestIds.textarea), 'Needs work'); + + await act(async () => { + browser.triggerBeforeUnload(); + await Promise.resolve(); + }); + + expect(await screen.findByTestId(appTestIds.closeReviewDialogActionButton)).toHaveTextContent( + closeReviewDialogCopy.feedback.primaryActionLabel, + ); + expect(screen.getByTestId(appTestIds.closeReviewDialogActionButton)).not.toHaveTextContent(approvalModeCopy.auto); + }); + + it('does not suffix the close-review dialog primary action for the default state', async () => { + const { browser } = renderApp({ + initialPayload: { contentKind: 'plan', content: '# Ship it', metadata: { entrypoint: 'hook_claude' } }, + }); + + await act(async () => { + browser.triggerBeforeUnload(); + await Promise.resolve(); + }); + + expect(await screen.findByTestId(appTestIds.closeReviewDialogActionButton)).toHaveTextContent( + closeReviewDialogCopy.empty.primaryActionLabel, + ); + expect(screen.getByTestId(appTestIds.closeReviewDialogActionButton)).not.toHaveTextContent('('); + }); + }); + describe('header help menu', () => { it('renders documentation, GitHub, and Slack items pointing at the shared link constants', async () => { renderApp({ initialPayload: { contentKind: 'plan', content: '# Ready' } }); diff --git a/packages/annotation/src/App.tsx b/packages/annotation/src/App.tsx index 5906bdf..91356eb 100644 --- a/packages/annotation/src/App.tsx +++ b/packages/annotation/src/App.tsx @@ -19,10 +19,11 @@ import './codeHighlightStyles.css'; import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx'; import { getAnnotationHighlightWarning } from './annotationHighlights.ts'; import { CommentsSidebar } from './CommentsSidebar.tsx'; +import { shouldSuffixApprovalMode } from './SubmitBar.tsx'; import { ThemePicker } from './ThemePicker.tsx'; import { UpdateNoticeCard } from './UpdateNoticeCard.tsx'; import { useAnnotationInteractions } from './useAnnotationInteractions.ts'; -import { useAnnotationState } from './useAnnotationState.ts'; +import { useAnnotationState, withApprovalMode } from './useAnnotationState.ts'; import { useAnnotationAppContext } from './useAppContext.ts'; import { useTheme } from './useTheme.ts'; @@ -195,7 +196,11 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap } }} open={reviewState.closeReview.dialogOpen} - primaryActionLabel={reviewState.closeReview.primaryActionLabel} + primaryActionLabel={ + shouldSuffixApprovalMode(payload.metadata?.entrypoint, reviewState.submission) + ? withApprovalMode(reviewState.closeReview.primaryActionLabel, reviewState.submission.approvalMode) + : reviewState.closeReview.primaryActionLabel + } title={reviewState.closeReview.title} /> diff --git a/packages/annotation/src/SubmitBar.tsx b/packages/annotation/src/SubmitBar.tsx index 378d101..bbee4c3 100644 --- a/packages/annotation/src/SubmitBar.tsx +++ b/packages/annotation/src/SubmitBar.tsx @@ -1,11 +1,22 @@ -import type { AnnotationEntrypoint } from '@contextbridge/shared/annotationSchema'; +import type { AnnotationEntrypoint, ApprovalMode } from '@contextbridge/shared/annotationSchema'; import { Button } from '@contextbridge/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@contextbridge/ui/components/ui/dropdown-menu'; +import { ChevronDownIcon } from 'lucide-react'; +import { approvalModeCopy, submitBarCopy, withApprovalMode } from './useAnnotationState.ts'; export const submitBarTestIds = { countdown: 'plan-review-submit-countdown', codexHandoffNotice: 'plan-review-submit-codex-handoff', error: 'plan-review-submit-error', button: 'plan-review-submit-button', + modeTrigger: 'plan-review-submit-mode-trigger', + modeOption: (mode: ApprovalMode): string => `plan-review-submit-mode-option-${mode}`, }; export interface SubmissionState { @@ -16,6 +27,8 @@ export interface SubmissionState { error: string | null; label: string; feedbackCount: number; + approvalMode: ApprovalMode; + setApprovalMode: (mode: ApprovalMode) => void; } export interface SubmitBarProps { @@ -25,6 +38,7 @@ export interface SubmitBarProps { export function SubmitBar({ submission, source }: SubmitBarProps) { const isCodexApproval = source === 'hook_codex' && submission.feedbackCount === 0; + const showModeTrigger = canChooseApprovalMode(source, submission); return ( <> @@ -57,18 +71,72 @@ export function SubmitBar({ submission, source }: SubmitBarProps) { ) : null} - +
+ + + {showModeTrigger ? ( + + + + + + submission.setApprovalMode(value as ApprovalMode)} + value={submission.approvalMode} + > + + {approvalModeCopy.acceptEdits} + + + {approvalModeCopy.auto} + + + + + ) : null} +
); } +export function shouldSuffixApprovalMode( + source: AnnotationEntrypoint | undefined, + submission: SubmissionState, +): boolean { + return canChooseApprovalMode(source, submission) && submission.approvalMode !== 'acceptEdits'; +} + +function canChooseApprovalMode(source: AnnotationEntrypoint | undefined, submission: SubmissionState): boolean { + return source === 'hook_claude' && submission.feedbackCount === 0 && !submission.submitted; +} + function formatCountdownLabel(remainingSeconds: number): string { return `${remainingSeconds} second${remainingSeconds === 1 ? '' : 's'}`; } + +function getSubmitButtonLabel(source: AnnotationEntrypoint | undefined, submission: SubmissionState): string { + if (submission.submitting) { + return submitBarCopy.submitting; + } + + if (shouldSuffixApprovalMode(source, submission)) { + return withApprovalMode(submission.label, submission.approvalMode); + } + + return submission.label; +} diff --git a/packages/annotation/src/useAnnotationState.test.ts b/packages/annotation/src/useAnnotationState.test.ts index 846f980..37116af 100644 --- a/packages/annotation/src/useAnnotationState.test.ts +++ b/packages/annotation/src/useAnnotationState.test.ts @@ -221,6 +221,45 @@ describe('useAnnotationState', () => { expect(submitAnnotation.mock.calls[0]?.[0]?.status).toBe('changes_requested'); }); + it('defaults approvalMode to acceptEdits', () => { + const { result } = renderAnnotationHook(() => useAnnotationState({})); + + expect(result.current.submission.approvalMode).toBe('acceptEdits'); + }); + + it('sends the selected approvalMode when submitting with no feedback', async () => { + const { result, submitAnnotation } = renderAnnotationHook(() => useAnnotationState({})); + + act(() => { + result.current.submission.setApprovalMode('auto'); + }); + + await act(async () => { + await result.current.submission.submit(); + }); + + expect(submitAnnotation.mock.calls[0]?.[0]).toMatchObject({ status: 'approved', approvalMode: 'auto' }); + }); + + it('forces approvalMode back to acceptEdits when submitting with feedback', async () => { + const { result, submitAnnotation } = renderAnnotationHook(() => + useAnnotationState({ initialThreads: [annotationThread.build({ id: 'thr_02' })] }), + ); + + act(() => { + result.current.submission.setApprovalMode('auto'); + }); + + await act(async () => { + await result.current.submission.submit(); + }); + + expect(submitAnnotation.mock.calls[0]?.[0]).toMatchObject({ + status: 'changes_requested', + approvalMode: 'acceptEdits', + }); + }); + it('bundles a non-empty global comment into the submission as a global thread', async () => { const { result, submitAnnotation } = renderAnnotationHook(() => useAnnotationState({})); diff --git a/packages/annotation/src/useAnnotationState.ts b/packages/annotation/src/useAnnotationState.ts index bae9183..f6192bb 100644 --- a/packages/annotation/src/useAnnotationState.ts +++ b/packages/annotation/src/useAnnotationState.ts @@ -1,6 +1,7 @@ import type { AnnotationStatus, AnnotationSubmission, + ApprovalMode, CommentThread, StoredAnnotationAnchor, } from '@contextbridge/shared/annotationSchema'; @@ -42,6 +43,7 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [globalCommentBody, setGlobalCommentBody] = useState(initialGlobalComment ?? ''); + const [approvalMode, setApprovalMode] = useState('acceptEdits'); const [pendingRemoveId, setPendingRemoveId] = useState(null); const [closeCountdownSeconds, setCloseCountdownSeconds] = useState(null); const [closeReviewDialogOpen, setCloseReviewDialogOpen] = useState(false); @@ -49,7 +51,11 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use const trimmedGlobal = globalCommentBody.trim(); const feedbackCount = getFeedbackCount(threads, trimmedGlobal); const closeReviewDialogContent = getCloseReviewDialogContent(feedbackCount); - const submitLabel = submitted ? 'Submitted' : feedbackCount === 0 ? 'Approve Plan' : 'Submit Feedback'; + const submitLabel = submitted + ? submitBarCopy.submitted + : feedbackCount === 0 + ? submitBarCopy.approve + : submitBarCopy.feedback; const [pendingDraftAction, setPendingDraftAction] = useState(null); @@ -138,9 +144,11 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use submissionThreads.push(createGlobalCommentThread(trimmedGlobal)); } + const status = getSubmitStatus(nextThreads, trimmedGlobal); const submission: AnnotationSubmission = { - status: getSubmitStatus(nextThreads, trimmedGlobal), + status, threads: submissionThreads, + approvalMode: status === 'approved' ? approvalMode : 'acceptEdits', }; setSubmitError(null); @@ -261,6 +269,8 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use closeCountdownSeconds, label: submitLabel, feedbackCount, + approvalMode, + setApprovalMode, }, closeReview: { dialogOpen: closeReviewDialogOpen, @@ -305,19 +315,35 @@ function getFeedbackCount(threads: CommentThread[], trimmedGlobal: string): numb return threads.length + (trimmedGlobal.length > 0 ? 1 : 0); } +export const approvalModeCopy = { + acceptEdits: 'Accept Edits', + auto: 'Auto', +} as const; + +export function withApprovalMode(label: string, mode: ApprovalMode): string { + return `${label} (${approvalModeCopy[mode]})`; +} + +export const submitBarCopy = { + approve: 'Approve Plan', + feedback: 'Submit Feedback', + submitted: 'Submitted', + submitting: 'Submitting…', +} as const; + export const closeReviewDialogCopy = { cancelLabel: 'Keep Reviewing', empty: { title: 'Approve plan before closing?', description: 'No comments have been added. Select Approve Plan to tell the agent to proceed with the plan as written.', - primaryActionLabel: 'Approve Plan', + primaryActionLabel: submitBarCopy.approve, }, feedback: { title: 'Submit feedback before closing?', description: 'You have unsent feedback. Select Submit Feedback before closing, otherwise your comments will be lost.', - primaryActionLabel: 'Submit Feedback', + primaryActionLabel: submitBarCopy.feedback, }, } as const; diff --git a/packages/cli/src/formatters/annotation/markdown.test.ts b/packages/cli/src/formatters/annotation/markdown.test.ts index 913dcdf..b1c5659 100644 --- a/packages/cli/src/formatters/annotation/markdown.test.ts +++ b/packages/cli/src/formatters/annotation/markdown.test.ts @@ -1,6 +1,6 @@ -import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; import { annotationAnchor, + annotationSubmission, annotationThread, commentMessage, elementAnnotationAnchor, @@ -14,19 +14,19 @@ import type { AnnotationTemplates } from './templates.ts'; describe('formatAgentResponse (annotation engine)', () => { it('renders the approved template when status is approved', () => { - const submission: AnnotationSubmission = { status: 'approved', threads: [] }; + const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const result = formatAgentResponse(buildFakeTemplates(), submission, '# anything'); expect(result).toBe('APPROVED-MARKER'); }); it('uses the changes-requested template when status is changes_requested', () => { - const submission: AnnotationSubmission = { status: 'changes_requested', threads: [] }; + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [] }); const result = formatAgentResponse(buildFakeTemplates(), submission, '# anything'); expect(result).toContain('CHANGES-MARKER'); }); it('reads kind-specific copy entirely from the templates argument (no hardcoded plan strings)', () => { - const submission: AnnotationSubmission = { status: 'approved', threads: [] }; + const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const result = formatAgentResponse(buildFakeTemplates(), submission, '# anything'); expect(result).not.toContain('Plan'); expect(result).not.toContain('plan'); @@ -34,7 +34,7 @@ describe('formatAgentResponse (annotation engine)', () => { it('renders mixed global and annotation threads using their respective section templates', () => { const content = ['line 1', 'line 2', 'line 3', 'line 4'].join('\n'); - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ globalThread.build({ @@ -67,7 +67,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildFakeTemplates(), submission, content); @@ -81,7 +81,7 @@ describe('formatAgentResponse (annotation engine)', () => { it('slices the source by 1-indexed line range for multi-line annotations', () => { const content = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'].join('\n'); - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -103,7 +103,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildSourceSliceTemplates(), submission, content); @@ -115,7 +115,7 @@ describe('formatAgentResponse (annotation engine)', () => { it('slices a single source line for single-line annotations', () => { const content = ['alpha', 'beta', 'gamma'].join('\n'); - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -137,7 +137,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildSourceSliceTemplates(), submission, content); @@ -147,7 +147,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('formats single-line ranges as "line N"', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -169,7 +169,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildRangeTemplates(), submission, padToLine('only', 7)); @@ -178,7 +178,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('formats multi-line ranges as "lines N–M" using an en-dash', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -200,7 +200,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildRangeTemplates(), submission, padToLine('content', 9)); @@ -211,7 +211,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('describes a single-line text selection as "the highlighted text: "', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -233,7 +233,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildHighlightedTemplates(), submission, 'highlight-me here'); @@ -242,7 +242,7 @@ describe('formatAgentResponse (annotation engine)', () => { it('omits the focus call-out (empty value) when the exact selection spans multiple lines', () => { const content = ['first line', 'second line'].join('\n'); - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -264,7 +264,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildHighlightedTemplates(), submission, content); @@ -273,7 +273,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('describes an element-anchored node by its descriptor and label', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -281,7 +281,7 @@ describe('formatAgentResponse (annotation engine)', () => { subject: { kind: 'annotation', anchor: elementAnnotationAnchor.build() }, }), ], - }; + }); expect(formatAgentResponse(buildHighlightedTemplates(), submission, 'unused')).toContain( 'HIGHLIGHTED', @@ -289,7 +289,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('describes an element-anchored edge by its descriptor and label', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -302,7 +302,7 @@ describe('formatAgentResponse (annotation engine)', () => { }, }), ], - }; + }); expect(formatAgentResponse(buildHighlightedTemplates(), submission, 'unused')).toContain( 'HIGHLIGHTED', @@ -310,7 +310,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('describes a whole-block element annotation by descriptor alone (no label)', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -327,7 +327,7 @@ describe('formatAgentResponse (annotation engine)', () => { }, }), ], - }; + }); const result = formatAgentResponse(buildHighlightedTemplates(), submission, 'unused'); expect(result).toContain('HIGHLIGHTED'); @@ -350,7 +350,9 @@ describe('formatAgentResponse (annotation engine)', () => { generalFeedbackSection: recordingTemplate('generalFeedbackSection'), }; - formatAgentResponse(stubTemplates, { status: 'approved', threads: [] }, '# doc', { sourcePath: '/abs/doc.md' }); + formatAgentResponse(stubTemplates, annotationSubmission.build({ status: 'approved', threads: [] }), '# doc', { + sourcePath: '/abs/doc.md', + }); expect(captured).toHaveLength(1); expect(captured[0]?.name).toBe('approved'); @@ -358,7 +360,7 @@ describe('formatAgentResponse (annotation engine)', () => { }); it('renders thread messages in chronological order regardless of input order', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ globalThread.build({ @@ -379,7 +381,7 @@ describe('formatAgentResponse (annotation engine)', () => { ], }), ], - }; + }); const result = formatAgentResponse(buildThreadEchoTemplates(), submission, 'unused'); diff --git a/packages/cli/src/formatters/document/markdown.test.ts b/packages/cli/src/formatters/document/markdown.test.ts index 24f2d54..c6f24d6 100644 --- a/packages/cli/src/formatters/document/markdown.test.ts +++ b/packages/cli/src/formatters/document/markdown.test.ts @@ -1,5 +1,10 @@ -import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; -import { annotationAnchor, annotationThread, commentMessage, globalThread } from '@contextbridge/shared/testFactories'; +import { + annotationAnchor, + annotationSubmission, + annotationThread, + commentMessage, + globalThread, +} from '@contextbridge/shared/testFactories'; import { describe, expect, it } from 'bun:test'; import { formatAgentResponse } from '#src/formatters/annotation/markdown.ts'; import { DOCUMENT_TEMPLATES } from './templates.ts'; @@ -13,7 +18,7 @@ Another paragraph. `; it('renders approved with no annotations and no sourcePath', () => { - const submission: AnnotationSubmission = { status: 'approved', threads: [] }; + const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content); expect(output).toContain('# Review submitted'); expect(output).toContain('the content'); @@ -22,17 +27,17 @@ Another paragraph. }); it('renders approved with sourcePath', () => { - const submission: AnnotationSubmission = { status: 'approved', threads: [] }; + const submission = annotationSubmission.build({ status: 'approved', threads: [] }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content, { sourcePath: '/abs/doc.md' }); expect(output).toContain('`/abs/doc.md`'); expect(output).toContain('no annotations'); }); it('renders changes_requested with a general feedback thread', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [globalThread.build({ messages: [commentMessage.build({ body: 'Please clarify section 2.' })] })], - }; + }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content); expect(output).toContain('# Review submitted'); expect(output).toContain('the following comments'); @@ -41,7 +46,7 @@ Another paragraph. }); it('renders changes_requested with an annotation thread (single line)', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -55,7 +60,7 @@ Another paragraph. messages: [commentMessage.build({ body: 'Title is too generic.' })], }), ], - }; + }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content); expect(output).toContain('## Comment (line 1)'); expect(output).toContain('Within that section, the reviewer commented on the highlighted text: `Heading`'); @@ -63,7 +68,7 @@ Another paragraph. }); it('renders changes_requested with a multi-line annotation (no highlight)', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -77,7 +82,7 @@ Another paragraph. messages: [commentMessage.build({ body: 'Tighten these two paragraphs.' })], }), ], - }; + }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content); expect(output).toContain('## Comment (lines 3–5)'); expect(output).not.toContain('commented on'); // multi-line skips the inline-code call-out @@ -85,7 +90,7 @@ Another paragraph. }); it('renders changes_requested with both general feedback and annotations', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [ globalThread.build({ messages: [commentMessage.build({ body: 'Overall tone is off.' })] }), @@ -100,7 +105,7 @@ Another paragraph. messages: [commentMessage.build({ body: 'Rewrite this line.' })], }), ], - }; + }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content); expect(output).toContain('## General feedback'); expect(output).toContain('Overall tone is off.'); @@ -109,10 +114,10 @@ Another paragraph. }); it('renders changes_requested with sourcePath in the header', () => { - const submission: AnnotationSubmission = { + const submission = annotationSubmission.build({ status: 'changes_requested', threads: [globalThread.build({ messages: [commentMessage.build({ body: 'A note.' })] })], - }; + }); const output = formatAgentResponse(DOCUMENT_TEMPLATES, submission, content, { sourcePath: '/abs/draft.md' }); expect(output).toContain('`/abs/draft.md`'); }); diff --git a/packages/cli/src/formatters/plan/claudeHookResponse.test.ts b/packages/cli/src/formatters/plan/claudeHookResponse.test.ts index 425b780..592218e 100644 --- a/packages/cli/src/formatters/plan/claudeHookResponse.test.ts +++ b/packages/cli/src/formatters/plan/claudeHookResponse.test.ts @@ -33,6 +33,27 @@ describe('claudeHookResponse', () => { }); }); + it('switches the session to auto when the submission requests auto approval mode', () => { + const submission = annotationSubmission.build({ status: 'approved', threads: [], approvalMode: 'auto' }); + const toolInput = { + plan: '# ignored by approved template\n', + planFilePath: '/home/user/.claude/plans/sample.md', + }; + + const response = claudeHookResponse(submission, toolInput); + + expect(response).toEqual({ + hookSpecificOutput: { + hookEventName: 'PermissionRequest', + decision: { + behavior: 'allow', + updatedInput: toolInput, + updatedPermissions: [{ type: 'setMode', mode: 'auto', destination: 'session' }], + }, + }, + }); + }); + it('returns a deny envelope whose message matches formatAgentResponse for changes-requested submissions', () => { const planContent = ['# Plan', '', '## Step one', '', '- do the thing', '- then the next thing'].join('\n'); const submission = annotationSubmission.build({ diff --git a/packages/cli/src/formatters/plan/claudeHookResponse.ts b/packages/cli/src/formatters/plan/claudeHookResponse.ts index 42a86d9..8088f80 100644 --- a/packages/cli/src/formatters/plan/claudeHookResponse.ts +++ b/packages/cli/src/formatters/plan/claudeHookResponse.ts @@ -20,16 +20,17 @@ export function claudeHookResponse( // Claude Code >=2.1.199 discards an ExitPlanMode allow without updatedInput, so echo the // input verbatim: https://github.com/anthropics/claude-code/issues/74256 // - // setMode → acceptEdits is what actually exits plan mode for the session. - // Without it, the allow only grants this ExitPlanMode call — the session - // stays in `plan` and the agent can't touch the filesystem on its next turn. + // setMode is what actually exits plan mode for the session. Without it, the allow only + // grants this ExitPlanMode call — the session stays in `plan` and the agent can't touch + // the filesystem on its next turn. The mode itself comes from the reviewer's choice in + // the browser (acceptEdits by default, or auto). return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: toolInput, - updatedPermissions: [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }], + updatedPermissions: [{ type: 'setMode', mode: submission.approvalMode, destination: 'session' }], }, }, }; diff --git a/packages/cli/src/formatters/plan/templates.test.ts b/packages/cli/src/formatters/plan/templates.test.ts index 82b3574..06ecc1d 100644 --- a/packages/cli/src/formatters/plan/templates.test.ts +++ b/packages/cli/src/formatters/plan/templates.test.ts @@ -1,5 +1,6 @@ import { annotationAnchor, + annotationSubmission, annotationThread, commentMessage, globalThread, @@ -11,7 +12,11 @@ import { PLAN_TEMPLATES } from './templates.ts'; describe('PLAN_TEMPLATES (plan-flavored formatAgentResponse output)', () => { it('renders approved submissions as a short markdown confirmation', () => { - const output = formatAgentResponse(PLAN_TEMPLATES, { status: 'approved', threads: [] }, 'unused plan source'); + const output = formatAgentResponse( + PLAN_TEMPLATES, + annotationSubmission.build({ status: 'approved', threads: [] }), + 'unused plan source', + ); expect(output).toMatchInlineSnapshot(` "# Plan review: approved @@ -39,7 +44,7 @@ The user reviewed this plan and approved it with no changes. Proceed to implemen const output = formatAgentResponse( PLAN_TEMPLATES, - { + annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -89,7 +94,7 @@ The user reviewed this plan and approved it with no changes. Proceed to implemen ], }), ], - }, + }), planContent, ); @@ -138,7 +143,7 @@ The comment below applies to the following section of the plan: const output = formatAgentResponse( PLAN_TEMPLATES, - { + annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -160,7 +165,7 @@ The comment below applies to the following section of the plan: ], }), ], - }, + }), planContent, ); @@ -176,7 +181,7 @@ The comment below applies to the following section of the plan: const output = formatAgentResponse( PLAN_TEMPLATES, - { + annotationSubmission.build({ status: 'changes_requested', threads: [ annotationThread.build({ @@ -198,7 +203,7 @@ The comment below applies to the following section of the plan: ], }), ], - }, + }), planContent, ); diff --git a/packages/server/src/routes/submit.test.ts b/packages/server/src/routes/submit.test.ts index 6eab337..9c650ff 100644 --- a/packages/server/src/routes/submit.test.ts +++ b/packages/server/src/routes/submit.test.ts @@ -11,6 +11,7 @@ describe('POST /submit', () => { const submission = { status: 'changes_requested' as const, threads: [annotationThread.build(), globalThread.build()], + approvalMode: 'acceptEdits' as const, }; const res = await fetch(`${running.url}/submit`, { @@ -25,6 +26,25 @@ describe('POST /submit', () => { }); }); + it('round-trips approvalMode: auto unchanged through /submit', async () => { + await withServer(ctx, async (running) => { + const submission = { + status: 'changes_requested' as const, + threads: [annotationThread.build(), globalThread.build()], + approvalMode: 'auto' as const, + }; + + const res = await fetch(`${running.url}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(submission), + }); + + expect(res.status).toBe(204); + expect(await running.result).toEqual(submission); + }); + }); + it('returns 400 when the submission fails schema validation', async () => { await withServer(ctx, async (running) => { const res = await fetch(`${running.url}/submit`, { diff --git a/packages/shared/src/annotationSchema.test.ts b/packages/shared/src/annotationSchema.test.ts index 4fc8e13..f90059f 100644 --- a/packages/shared/src/annotationSchema.test.ts +++ b/packages/shared/src/annotationSchema.test.ts @@ -87,6 +87,20 @@ describe('AnnotationSubmissionSchema', () => { const subject = parsed.threads[0]?.subject; expect(subject?.kind === 'annotation' ? subject.anchor.kind : null).toBe('element'); }); + + it('defaults approvalMode to acceptEdits when absent', () => { + const parsed = AnnotationSubmissionSchema.parse({ status: 'approved' }); + expect(parsed.approvalMode).toBe('acceptEdits'); + }); + + it('accepts approvalMode: auto', () => { + const parsed = AnnotationSubmissionSchema.parse({ status: 'approved', approvalMode: 'auto' }); + expect(parsed.approvalMode).toBe('auto'); + }); + + it('rejects an invalid approvalMode', () => { + expect(() => AnnotationSubmissionSchema.parse({ status: 'approved', approvalMode: 'bypassPermissions' })).toThrow(); + }); }); describe('ElementAnnotationAnchorSchema', () => { diff --git a/packages/shared/src/annotationSchema.ts b/packages/shared/src/annotationSchema.ts index 83dd6f1..9678076 100644 --- a/packages/shared/src/annotationSchema.ts +++ b/packages/shared/src/annotationSchema.ts @@ -7,6 +7,10 @@ export type ContentKind = z.infer; export const AnnotationStatusSchema = z.enum(['approved', 'changes_requested']); export type AnnotationStatus = z.infer; +// Must stay a subset of PermissionMode from @anthropic-ai/claude-agent-sdk. +export const ApprovalModeSchema = z.enum(['acceptEdits', 'auto']); +export type ApprovalMode = z.infer; + export const TextQuoteSelectorSchema = z.object({ exact: z.string().nonempty(), prefix: z.string(), @@ -166,6 +170,7 @@ export type CommentThread = z.infer; export const AnnotationSubmissionSchema = z.object({ status: AnnotationStatusSchema, threads: z.array(CommentThreadSchema).default([]), + approvalMode: ApprovalModeSchema.default('acceptEdits'), }); export type AnnotationSubmission = z.infer; diff --git a/packages/shared/src/testFactories.ts b/packages/shared/src/testFactories.ts index bb4bcf5..967322c 100644 --- a/packages/shared/src/testFactories.ts +++ b/packages/shared/src/testFactories.ts @@ -96,6 +96,7 @@ export const globalThread = Factory.define(() => { export const annotationSubmission = Factory.define(() => ({ status: 'changes_requested', threads: [globalThread.build(), annotationThread.build()], + approvalMode: 'acceptEdits', })); export const annotationPayload = Factory.define(() => ({ diff --git a/packages/website/src/content/docs/usage/claude-code.mdx b/packages/website/src/content/docs/usage/claude-code.mdx index 0530ec1..6e60565 100644 --- a/packages/website/src/content/docs/usage/claude-code.mdx +++ b/packages/website/src/content/docs/usage/claude-code.mdx @@ -19,6 +19,17 @@ If you have the PlanBridge Claude Code plugin installed (via `contextbridge inst If you have no feedback on a plan and you want Claude to start implementing, click "Approve Plan". This will tell Claude to start implementing. +### Choosing an approval mode + +Next to "Approve Plan" is a mode selector with two options: Accept Edits (the default) and Auto. These map directly to Claude Code's `acceptEdits` and `auto` permission modes. + +- Accept Edits exits plan mode into Claude Code's `acceptEdits` permission mode: file edits are auto-accepted, but other tools still prompt if not pre-approved in configuration. +- Auto exits into Claude Code's `auto` permission mode, where a model classifier approves or denies permission prompts. + +The selection is not remembered: every review starts back at Accept Edits. If you choose Auto, the primary button's label changes to "Approve Plan (Auto)" so you can confirm the choice before submitting. + +The selector only appears for Claude Code plan reviews, not for `contextbridge plan`, `contextbridge open`, or Codex. It also only appears while you have no comments on the plan. Once you have feedback to send, the button becomes "Submit Feedback" and the mode no longer applies. + ## Iterating on a Plan If you have feedback on a plan, select the relevant text and comment. When you click "Request Changes", Claude will adjust the plan per your feedback. Once the changes are made, you'll be re-presented the updated plan.