From 512af9d6436ae85c5b5705b12785efd4f57ada3e Mon Sep 17 00:00:00 2001 From: Joao Palharini Date: Mon, 27 Jul 2026 18:26:54 -0300 Subject: [PATCH 1/2] feat(shared): carry an approval mode on plan submissions Adds ApprovalModeSchema ('acceptEdits' | 'auto') and an approvalMode field on AnnotationSubmission, defaulting to 'acceptEdits' so existing payloads keep today's behavior. Call sites that built AnnotationSubmission literals by hand now use the annotationSubmission fishery factory, per the repo testing conventions, so future schema additions do not ripple through them. Refs #247 Co-Authored-By: Claude Opus 5 --- packages/annotation/src/useAnnotationState.ts | 1 + .../formatters/annotation/markdown.test.ts | 56 ++++++++++--------- .../src/formatters/document/markdown.test.ts | 33 ++++++----- .../cli/src/formatters/plan/templates.test.ts | 19 ++++--- packages/server/src/routes/submit.test.ts | 1 + packages/shared/src/annotationSchema.test.ts | 14 +++++ packages/shared/src/annotationSchema.ts | 5 ++ packages/shared/src/testFactories.ts | 1 + 8 files changed, 82 insertions(+), 48 deletions(-) diff --git a/packages/annotation/src/useAnnotationState.ts b/packages/annotation/src/useAnnotationState.ts index bae9183..7540f1f 100644 --- a/packages/annotation/src/useAnnotationState.ts +++ b/packages/annotation/src/useAnnotationState.ts @@ -141,6 +141,7 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use const submission: AnnotationSubmission = { status: getSubmitStatus(nextThreads, trimmedGlobal), threads: submissionThreads, + approvalMode: 'acceptEdits', }; setSubmitError(null); 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/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..fd84082 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`, { 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(() => ({ From 41337f69139c98e3a29dc741cd1fe20fcdff2f29 Mon Sep 17 00:00:00 2001 From: Joao Palharini Date: Mon, 27 Jul 2026 21:45:46 -0300 Subject: [PATCH 2/2] feat: let reviewers approve a plan into Claude Code auto mode The Claude Code PermissionRequest hook hardcoded acceptEdits as the mode the session lands in after a plan is approved. The submit bar now carries an attached mode selector (Accept Edits by default, or Auto), and the hook envelope sets whichever the reviewer chose. Auto is Claude Code's model-classifier permission mode. The selector only appears for Claude Code reviews with no pending feedback, and the choice is not remembered between reviews. Refs #247 Co-Authored-By: Claude Opus 5 --- packages/annotation/src/App.stories.tsx | 22 +++ packages/annotation/src/App.test.tsx | 181 ++++++++++++++++++ packages/annotation/src/App.tsx | 9 +- packages/annotation/src/SubmitBar.tsx | 86 ++++++++- .../annotation/src/useAnnotationState.test.ts | 39 ++++ packages/annotation/src/useAnnotationState.ts | 35 +++- .../plan/claudeHookResponse.test.ts | 21 ++ .../src/formatters/plan/claudeHookResponse.ts | 9 +- packages/server/src/routes/submit.test.ts | 19 ++ .../src/content/docs/usage/claude-code.mdx | 11 ++ 10 files changed, 412 insertions(+), 20 deletions(-) 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 7540f1f..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,10 +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: 'acceptEdits', + approvalMode: status === 'approved' ? approvalMode : 'acceptEdits', }; setSubmitError(null); @@ -262,6 +269,8 @@ export function useAnnotationState({ initialThreads, initialGlobalComment }: Use closeCountdownSeconds, label: submitLabel, feedbackCount, + approvalMode, + setApprovalMode, }, closeReview: { dialogOpen: closeReviewDialogOpen, @@ -306,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/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/server/src/routes/submit.test.ts b/packages/server/src/routes/submit.test.ts index fd84082..9c650ff 100644 --- a/packages/server/src/routes/submit.test.ts +++ b/packages/server/src/routes/submit.test.ts @@ -26,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/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.