feat: add automatic review loop (#1840)

This commit is contained in:
Bohdan Triapitsyn
2026-06-26 19:29:44 +03:00
committed by GitHub
parent 45df19c3b2
commit 1f549e4525
21 changed files with 787 additions and 35 deletions
@@ -0,0 +1,76 @@
import React, { memo } from 'react';
import { Icon } from '@/components/icon/Icon';
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
export const AutoReviewBanner = memo(() => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const run = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return null;
const run = state.runsByOriginalSessionID[currentSessionId] ?? null;
return run?.runtimeKey === getRuntimeKey() ? run : null;
}, [currentSessionId]));
const stopRun = useAutoReviewStore((state) => state.stopRun);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
if (!currentSessionId || !run || run.status !== 'running') {
return null;
}
const statusLabel = run.phase === 'waiting_for_reviewer'
? t('chat.autoReview.status.waitingForReviewer')
: t('chat.autoReview.status.waitingForImplementer');
const handleOpenReviewSession = () => {
openContextPanelTab(run.directory, {
mode: 'chat',
dedupeKey: `session:${run.reviewSessionID}`,
label: t('chat.autoReview.reviewSessionLabel'),
readOnly: true,
});
};
return (
<div className="pb-2 w-full px-1">
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<span className="typography-ui-label font-medium text-foreground">
{t('chat.autoReview.title')}
<BusyDots />
</span>
<div className="typography-meta text-muted-foreground">
{statusLabel}
</div>
</div>
<Button
type="button"
variant="secondary"
size="xs"
onClick={handleOpenReviewSession}
>
{t('chat.autoReview.actions.open')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => stopRun(currentSessionId)}
>
{t('chat.autoReview.actions.stop')}
</Button>
</div>
</div>
</div>
);
});
AutoReviewBanner.displayName = 'AutoReviewBanner';
+22 -3
View File
@@ -5,6 +5,7 @@ import { BrowserVoiceButton } from '@/components/voice';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store';
@@ -16,11 +17,13 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog';
import type { ToolPopupContent } from './message/types';
import { QueuedMessageChips } from './QueuedMessageChips';
import { AutoReviewBanner } from './AutoReviewBanner';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
@@ -1131,6 +1134,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
variant: execution.variant || undefined,
generateHandoff: execution.generateHandoff,
returnAfterHandoffRequest: execution.generateHandoff,
autoReview: execution.autoReview,
});
setReviewDialogOpen(false);
} catch (error) {
@@ -1635,6 +1639,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return false;
const run = state.runsByOriginalSessionID[currentSessionId];
return run?.status === 'running' && run.runtimeKey === getRuntimeKey();
}, [currentSessionId]));
const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => {
if (!isMobile) {
@@ -1764,6 +1773,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? queuedMessages.filter((message) => message.id === queuedMessageId)
: queuedMessages;
if (queuedOnly && autoReviewRunning) {
return;
}
if (queuedOnly) {
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
@@ -1790,6 +1803,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly && autoReviewRunning) {
handleQueueMessage();
return;
}
if (currentSessionId && !queuedOnly) {
const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId);
if (dismissedQuestions) {
@@ -2264,13 +2282,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && sessionPhase !== 'idle';
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
if (queueModeEnabled && canQueue) {
handleQueueMessage();
} else {
void handleSubmitRef.current();
}
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, queueModeEnabled, handleQueueMessage]);
// Draft welcome presets: populate the composer and submit immediately.
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
@@ -2519,7 +2537,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Normal mode: Enter sends, Ctrl+Enter queues
// Note: Queueing only works when there's an existing session (currentSessionId)
// For new sessions (draft), always send immediately
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
if (queueModeEnabled) {
if (isCtrlEnter || !canQueue) {
@@ -4010,6 +4028,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onEditMessage={handleQueuedMessageEdit}
onSendMessage={handleQueuedMessageSend}
/>
<AutoReviewBanner />
{hasDrafts && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{reviewCount > 0 ? (