feat: add session review handoff flow

Introduce a desktop/web-only /handoff-review flow that generates a handoff from the active implementation session, creates or reuses a separate review session in the same directory, and links the pair through hidden OpenChamber session metadata.

Add review flow orchestration, metadata helpers, magic prompts, localized command/action labels, session metadata create/update support, and assistant message transfer actions for sending reviewer feedback back to the implementer or implementation responses back to the reviewer.

Review sessions are ordinary sessions, not child sessions. The flow avoids exposing session IDs or routing metadata to agents, hides review controls on mobile and VS Code, hides unrelated assistant actions inside review sessions, cleans up stale metadata where possible, and uses the optimistic send path so cross-session sends scroll like normal composer messages.
This commit is contained in:
Bohdan Triapitsyn
2026-06-07 01:22:40 +03:00
parent e0113c637d
commit 1f9769a932
19 changed files with 1381 additions and 13 deletions
+24 -1
View File
@@ -15,6 +15,7 @@ import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/us
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog';
import type { ToolPopupContent } from './message/types';
@@ -1106,10 +1107,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'catch-up', 'debug', 'weigh', 'explore',
]);
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
for (const command of availableCommands) names.add(command.name.toLowerCase());
for (const skill of availableSkills) names.add(skill.name.toLowerCase());
return names;
}, [availableCommands, availableSkills]);
}, [availableCommands, availableSkills, isMobile]);
// /command and /skill spans (primary color). Only tokens that match a known
// command/skill name are highlighted — partial/unknown tokens stay plain.
@@ -1937,6 +1939,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
return;
}
else if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) {
try {
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || '';
if (!directory) {
throw new Error('Session directory is unavailable');
}
await startReviewFlow({
originalSessionID: currentSessionId,
directory,
providerID: providerIdToSend,
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
agentMentionName,
});
scrollToBottom?.();
} catch (error) {
console.error('[review-flow] failed to start review flow', error);
}
return;
}
else if (commandName === 'plan-feature' && (currentSessionId || newSessionDraftOpen)) {
try {
await sessionActions.waitForConnectionOrThrow();
@@ -7,6 +7,8 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -65,6 +67,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const hasSession = Boolean(currentSessionId);
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const canStartSessionCommand = hasSession || hasNewSessionDraft;
const isMobile = useUIStore((state) => state.isMobile);
const canUseReviewHandoffFlow = hasSession && !isMobile && !isVSCodeRuntime();
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
const [loading, setLoading] = React.useState(false);
@@ -152,6 +156,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
: []
),
...(canUseReviewHandoffFlow
? [{ id: 'openchamber:handoff-review', name: 'handoff-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.handoffReviewDescription'), isOpenChamber: true }]
: []
),
...(canStartSessionCommand
? [{ id: 'openchamber:plan-feature', name: 'plan-feature', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.featurePlanDescription'), isOpenChamber: true }]
: []
@@ -217,6 +225,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
: []
),
...(canUseReviewHandoffFlow
? [{ id: 'openchamber:handoff-review', name: 'handoff-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.handoffReviewDescription'), isOpenChamber: true }]
: []
),
...(canStartSessionCommand
? [{ id: 'openchamber:plan-feature', name: 'plan-feature', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.featurePlanDescription'), isOpenChamber: true }]
: []
@@ -253,7 +265,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, commandsWithMetadata, skills, t]);
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -48,6 +48,12 @@ import { useI18n } from '@/lib/i18n';
import { extractLoopbackUrls } from '@/lib/url';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import {
getReviewTransferDirection,
sendImplementationResponseToReviewer,
sendReviewFeedbackToOriginal,
} from '@/lib/reviewFlow';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
@@ -641,6 +647,11 @@ interface AssistantMessageActionButtonsProps {
hasCopyableText: boolean;
isTouchContext: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
reviewTransferAction?: {
ariaLabel: string;
tooltip: string;
onClick: () => void | Promise<void>;
};
onShareImage: (sourceElement?: HTMLElement | null) => Promise<void>;
ttsText: string;
}
@@ -649,6 +660,7 @@ const AssistantMessageActionButtons = React.memo(({
hasCopyableText,
isTouchContext,
onCopyMessage,
reviewTransferAction,
onShareImage,
ttsText,
}: AssistantMessageActionButtonsProps) => {
@@ -660,6 +672,7 @@ const AssistantMessageActionButtons = React.memo(({
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const [isMessageCopied, setIsMessageCopied] = React.useState(false);
const [isSharing, setIsSharing] = React.useState(false);
const [isTransferringReview, setIsTransferringReview] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
const copiedResetTimeoutRef = React.useRef<number | null>(null);
const canCopyMessage = Boolean(onCopyMessage);
@@ -758,6 +771,21 @@ const AssistantMessageActionButtons = React.memo(({
[hasCopyableText, isSharing, onShareImage]
);
const handleReviewTransferClick = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!reviewTransferAction || isTransferringReview || !hasCopyableText) return;
setIsTransferringReview(true);
try {
await reviewTransferAction.onClick();
} finally {
setIsTransferringReview(false);
}
},
[hasCopyableText, isTransferringReview, reviewTransferAction]
);
const readAloudTooltip = React.useMemo(() => {
if (isTTSPlaying) {
return t('chat.messageBody.tts.stopSpeaking');
@@ -831,6 +859,34 @@ const AssistantMessageActionButtons = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
</Tooltip>
)}
{reviewTransferAction && chatSurfaceMode !== 'mini-chat' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={isTransferringReview || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isTransferringReview) && 'opacity-50'
)}
aria-label={reviewTransferAction.ariaLabel}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
void handleReviewTransferClick(event);
}}
>
{isTransferringReview ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
) : (
<Icon name="arrow-left-right" className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{reviewTransferAction.tooltip}</TooltipContent>
</Tooltip>
) : null}
{chatSurfaceMode !== 'mini-chat' ? <Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1065,9 +1121,50 @@ const AssistantMessageBody = React.memo(({
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getDirectoryForSession = useSessionUIStore((state) => state.getDirectoryForSession);
const currentSession = useGlobalSessionsStore((state) => {
if (!sessionId) return null;
for (const candidate of state.activeSessions) {
if (candidate.id === sessionId) return candidate;
}
for (const candidate of state.archivedSessions) {
if (candidate.id === sessionId) return candidate;
}
return null;
});
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects);
const effectiveDirectory = useEffectiveDirectory();
const currentReviewTransferDirection = getReviewTransferDirection(currentSession);
const isReviewSessionView = currentReviewTransferDirection === 'review-to-original';
const reviewTransferDirection = (!isMobile && !isVSCode) ? currentReviewTransferDirection : null;
const reviewTransferAction = React.useMemo(() => {
const transferText = assistantPlanText.trim();
if (!sessionId || !effectiveDirectory || !transferText || !reviewTransferDirection) return undefined;
if (reviewTransferDirection === 'review-to-original') {
return {
ariaLabel: t('chat.messageBody.actions.sendReviewFeedback'),
tooltip: t('chat.messageBody.actions.sendReviewFeedback'),
onClick: async () => {
try {
await sendReviewFeedbackToOriginal(sessionId, effectiveDirectory, transferText);
} catch (error) {
console.error('[review-flow] failed to send review feedback', error);
}
},
};
}
return {
ariaLabel: t('chat.messageBody.actions.sendImplementationResponse'),
tooltip: t('chat.messageBody.actions.sendImplementationResponse'),
onClick: async () => {
try {
await sendImplementationResponseToReviewer(sessionId, effectiveDirectory, transferText);
} catch (error) {
console.error('[review-flow] failed to send implementation response', error);
}
},
};
}, [assistantPlanText, effectiveDirectory, reviewTransferDirection, sessionId, t]);
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const [isForkDialogOpen, setIsForkDialogOpen] = React.useState(false);
@@ -1481,8 +1578,9 @@ const AssistantMessageBody = React.memo(({
onCopyMessage={onCopyMessage}
onShareImage={shareMessageAsImage}
ttsText={assistantPlanText}
reviewTransferAction={reviewTransferAction}
/>
), [assistantPlanText, hasCopyableText, isTouchContext, onCopyMessage, shareMessageAsImage]);
), [assistantPlanText, hasCopyableText, isTouchContext, onCopyMessage, reviewTransferAction, shareMessageAsImage]);
const renderJustificationActions = React.useCallback((activity: NonNullable<TurnGroupingContext['activityParts']>[number]) => {
if (!showSplitAssistantMessageActions || !isSortedRenderMode) {
@@ -1830,7 +1928,7 @@ const AssistantMessageBody = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
</Tooltip>
) : null}
{canUseProjectPlanActions ? (
{canUseProjectPlanActions && !isReviewSessionView ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1851,7 +1949,7 @@ const AssistantMessageBody = React.memo(({
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
</Tooltip>
) : null}
{!isMiniChatSurface ? <Tooltip>
{!isMiniChatSurface && !isReviewSessionView ? <Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
@@ -1866,7 +1964,7 @@ const AssistantMessageBody = React.memo(({
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
</Tooltip> : null}
{canShowMultiRunAction ? (
{canShowMultiRunAction && !isReviewSessionView ? (
<Tooltip>
<TooltipTrigger asChild>
<Button