From 1f9769a93255eb16a866fe9ac3937d066e469dbd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 7 Jun 2026 01:22:40 +0300 Subject: [PATCH] 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. --- packages/ui/src/components/chat/ChatInput.tsx | 25 +- .../components/chat/CommandAutocomplete.tsx | 14 +- .../components/chat/message/MessageBody.tsx | 106 ++- packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/lib/magicPrompts.ts | 75 ++ packages/ui/src/lib/opencode/client.ts | 11 +- packages/ui/src/lib/reviewFlow.ts | 235 ++++++ packages/ui/src/lib/sessionReviewMetadata.ts | 82 ++ .../ui/src/stores/useGlobalSessionsStore.ts | 1 + packages/ui/src/sync/session-actions.ts | 49 ++ packages/ui/src/sync/session-ui-store.ts | 6 +- review-flow-implementation-plan.md | 766 ++++++++++++++++++ 19 files changed, 1381 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/lib/reviewFlow.ts create mode 100644 packages/ui/src/lib/sessionReviewMetadata.ts create mode 100644 review-flow-implementation-plan.md diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 3d9b7a51..0b91c32a 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollTo const names = new Set([ '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 = ({ 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(); diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index eeed9c21..7985563b 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -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 Boolean(state.newSessionDraft?.open)); const canStartSessionCommand = hasSession || hasNewSessionDraft; + const isMobile = useUIStore((state) => state.isMobile); + const canUseReviewHandoffFlow = hasSession && !isMobile && !isVSCodeRuntime(); const [commands, setCommands] = React.useState([]); const [loading, setLoading] = React.useState(false); @@ -152,6 +156,10 @@ export const CommandAutocomplete = React.forwardRef { setSelectedIndex(0); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 79ae9f6c..1eeb8659 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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; + reviewTransferAction?: { + ariaLabel: string; + tooltip: string; + onClick: () => void | Promise; + }; onShareImage: (sourceElement?: HTMLElement | null) => Promise; 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(null); const copiedResetTimeoutRef = React.useRef(null); const canCopyMessage = Boolean(onCopyMessage); @@ -758,6 +771,21 @@ const AssistantMessageActionButtons = React.memo(({ [hasCopyableText, isSharing, onShareImage] ); + const handleReviewTransferClick = React.useCallback( + async (event: React.MouseEvent) => { + 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(({ {t('chat.messageBody.actions.copyAnswer')} )} + {reviewTransferAction && chatSurfaceMode !== 'mini-chat' ? ( + + + + + {reviewTransferAction.tooltip} + + ) : null} {chatSurfaceMode !== 'mini-chat' ?