diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 583d4b9d..8ce5f1b6 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -36,7 +36,9 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
-import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
+import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
+import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
+import { resumeAutoReviewRun } from '@/lib/reviewFlow';
import { SyncProvider } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
@@ -268,6 +270,9 @@ function App({ apis }: AppProps) {
return subscribeRuntimeEndpointChanged((detail) => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
+ if (detail.previousRuntimeKey) {
+ useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
+ }
disposeTerminalInputTransport();
opencodeClient.reconnectToRuntimeBaseUrl();
useConfigStore.setState({
@@ -288,6 +293,28 @@ function App({ apis }: AppProps) {
});
}, []);
+ const autoReviewResumeSignature = useAutoReviewStore((state) => {
+ const runtimeKey = getRuntimeKey();
+ return Object.values(state.runsByOriginalSessionID)
+ .filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey)
+ .map((run) => `${run.originalSessionID}:${run.phase}:${run.lastForwardedMessageID ?? ''}:${run.expectedAssistantParentID ?? ''}`)
+ .sort()
+ .join('|');
+ });
+
+ React.useEffect(() => {
+ if (embeddedSessionChat) {
+ return;
+ }
+
+ const runtimeKey = getRuntimeKey();
+ const runs = Object.values(useAutoReviewStore.getState().runsByOriginalSessionID)
+ .filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey);
+ for (const run of runs) {
+ resumeAutoReviewRun(run.originalSessionID);
+ }
+ }, [autoReviewResumeSignature, embeddedSessionChat, runtimeEndpointEpoch]);
+
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
return () => {
diff --git a/packages/ui/src/components/chat/AutoReviewBanner.tsx b/packages/ui/src/components/chat/AutoReviewBanner.tsx
new file mode 100644
index 00000000..8156ada3
--- /dev/null
+++ b/packages/ui/src/components/chat/AutoReviewBanner.tsx
@@ -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 (
+
+
+
+
+
+
+ {t('chat.autoReview.title')}
+
+
+
+ {statusLabel}
+
+
+
+
+
+
+
+ );
+});
+
+AutoReviewBanner.displayName = 'AutoReviewBanner';
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx
index 12f5c143..abf4bd4c 100644
--- a/packages/ui/src/components/chat/ChatInput.tsx
+++ b/packages/ui/src/components/chat/ChatInput.tsx
@@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo
onEditMessage={handleQueuedMessageEdit}
onSendMessage={handleQueuedMessageSend}
/>
+
{hasDrafts && (
{reviewCount > 0 ? (
diff --git a/packages/ui/src/components/session/ReviewFlowDialog.tsx b/packages/ui/src/components/session/ReviewFlowDialog.tsx
index 13d09524..4d2ad09c 100644
--- a/packages/ui/src/components/session/ReviewFlowDialog.tsx
+++ b/packages/ui/src/components/session/ReviewFlowDialog.tsx
@@ -24,6 +24,7 @@ export type ReviewFlowExecution = {
variant: string;
agent: string;
generateHandoff: boolean;
+ autoReview: boolean;
};
type ReviewFlowDialogProps = {
@@ -45,6 +46,7 @@ const getInitialExecution = (params: {
variant: params.variant,
agent: params.agent,
generateHandoff: true,
+ autoReview: false,
});
export function ReviewFlowDialog({
@@ -165,6 +167,16 @@ export function ReviewFlowDialog({
{t('diffView.reviewDialog.generateHandoff')}
+
+
{t('chat.modelControls.model')}
= ({
variant: execution.variant || undefined,
generateHandoff: execution.generateHandoff,
returnAfterHandoffRequest: execution.generateHandoff,
+ autoReview: execution.autoReview,
});
setReviewDialogOpen(false);
} catch (error) {
diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
index 6d52eb85..59867ef0 100644
--- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
+++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts
@@ -4,6 +4,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
+import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { getSyncSessionStatus } from '@/sync/sync-refs';
import { useDirectorySync } from '@/sync/sync-context';
@@ -117,10 +118,12 @@ export const shouldDispatchQueuedAutoSend = (
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
+ const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
const inFlightSessionsRef = React.useRef>(new Set());
const previousStatusRef = React.useRef