Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/annotation/src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
181 changes: 181 additions & 0 deletions packages/annotation/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<void>();
const submitAnnotation = vi
.fn<(submission: AnnotationSubmission) => Promise<void>>()
.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' } });
Expand Down
9 changes: 7 additions & 2 deletions packages/annotation/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}
/>
</div>
Expand Down
86 changes: 77 additions & 9 deletions packages/annotation/src/SubmitBar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,6 +27,8 @@ export interface SubmissionState {
error: string | null;
label: string;
feedbackCount: number;
approvalMode: ApprovalMode;
setApprovalMode: (mode: ApprovalMode) => void;
}

export interface SubmitBarProps {
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -57,18 +71,72 @@ export function SubmitBar({ submission, source }: SubmitBarProps) {
</div>
) : null}

<Button
className="w-full"
data-testid={submitBarTestIds.button}
disabled={submission.submitted || submission.submitting}
onClick={() => void submission.submit()}
>
{submission.submitting ? 'Submitting…' : submission.label}
</Button>
<div className="flex w-full">
<Button
className={showModeTrigger ? 'flex-1 rounded-r-none' : 'w-full'}
data-testid={submitBarTestIds.button}
disabled={submission.submitted || submission.submitting}
onClick={() => void submission.submit()}
>
{getSubmitButtonLabel(source, submission)}
</Button>

{showModeTrigger ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Choose approval mode"
className="w-8 rounded-l-none border-l border-l-primary-foreground/20 px-0 has-[>svg]:px-0"
data-testid={submitBarTestIds.modeTrigger}
disabled={submission.submitting}
title="Choose approval mode"
>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
onValueChange={(value) => submission.setApprovalMode(value as ApprovalMode)}
value={submission.approvalMode}
>
<DropdownMenuRadioItem data-testid={submitBarTestIds.modeOption('acceptEdits')} value="acceptEdits">
{approvalModeCopy.acceptEdits}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem data-testid={submitBarTestIds.modeOption('auto')} value="auto">
{approvalModeCopy.auto}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</>
);
}

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;
}
Loading