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
+3
View File
@@ -1641,6 +1641,7 @@ export const dict = {
'chat.commandAutocomplete.command.compactDescription': 'Compress session history using AI to reduce context size',
'chat.commandAutocomplete.command.summaryDescription': 'Non-destructive session summary. Optional topic hint after the command.',
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Review the workspace diff for intent, correctness, and adequacy, graded by severity.',
'chat.commandAutocomplete.command.handoffReviewDescription': 'Create or reuse a separate review session from a generated handoff.',
'chat.commandAutocomplete.command.featurePlanDescription': 'Start a guided, back-and-forth planning session for a new feature.',
'chat.commandAutocomplete.command.catchUpDescription': 'Re-establish context: what you were doing and where to pick up.',
'chat.commandAutocomplete.command.debugDescription': 'Guided root-cause investigation for a bug before proposing a fix.',
@@ -1767,6 +1768,8 @@ export const dict = {
'snippets.source.project': 'project',
'chat.chatInput.toast.compactFailed': 'Failed to compact session',
'chat.chatInput.toast.summaryFailed': 'Failed to generate summary',
'chat.messageBody.actions.sendReviewFeedback': 'Send review feedback to implementing agent',
'chat.messageBody.actions.sendImplementationResponse': 'Send implementation response to reviewing agent',
'chat.chatInput.toast.reviewFailed': 'Failed to review changes',
'chat.chatInput.toast.planFeatureFailed': 'Failed to start feature planning',
'chat.chatInput.toast.catchUpFailed': 'Failed to catch up',
+3
View File
@@ -1607,6 +1607,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.compactDescription": "Comprimir el historial de la sesión usando IA para reducir el tamaño del contexto",
"chat.commandAutocomplete.command.summaryDescription": "Resumen no destructivo de la sesión. Pista opcional del tema después del comando.",
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisa el diff del espacio de trabajo en intención, corrección y adecuación, con hallazgos por severidad.",
"chat.commandAutocomplete.command.handoffReviewDescription": "Crea o reutiliza una sesión de revisión separada a partir de un handoff generado.",
"chat.commandAutocomplete.command.featurePlanDescription": "Inicia una sesión de planificación guiada e interactiva para una nueva función.",
"chat.commandAutocomplete.command.catchUpDescription": "Recupera el contexto: qué estabas haciendo y por dónde continuar.",
"chat.commandAutocomplete.command.debugDescription": "Investigación guiada de la causa raíz de un error antes de proponer una solución.",
@@ -1733,6 +1734,8 @@ export const dict: Record<I18nKey, string> = {
"snippets.source.project": "proyecto",
"chat.chatInput.toast.compactFailed": "No se pudo comprimir la sesión",
"chat.chatInput.toast.summaryFailed": "No se pudo generar el resumen",
"chat.messageBody.actions.sendReviewFeedback": "Enviar feedback de revisión al agente que implementa los cambios",
"chat.messageBody.actions.sendImplementationResponse": "Enviar respuesta de implementación al agente revisor",
"chat.chatInput.toast.reviewFailed": "No se pudieron revisar los cambios",
"chat.chatInput.toast.planFeatureFailed": "No se pudo iniciar la planificación de la función",
"chat.chatInput.toast.catchUpFailed": "No se pudo recuperar el contexto",
+3
View File
@@ -1643,6 +1643,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.compactDescription': 'AI로 세션 기록을 압축해 컨텍스트 크기를 줄입니다',
'chat.commandAutocomplete.command.summaryDescription': '세션 기록을 안전하게 요약합니다. 명령 뒤에 선택적으로 주제 힌트를 넣을 수 있습니다.',
'chat.commandAutocomplete.command.workspaceReviewDescription': '워크스페이스 diff의 의도, 정확성, 적절성을 검토하고 심각도별로 분류합니다.',
'chat.commandAutocomplete.command.handoffReviewDescription': '생성된 인수인계로 별도의 리뷰 세션을 만들거나 재사용합니다.',
'chat.commandAutocomplete.command.featurePlanDescription': '새 기능을 위한 대화형 가이드 계획 세션을 시작합니다.',
'chat.commandAutocomplete.command.catchUpDescription': '맥락을 다시 파악합니다: 무엇을 하고 있었고 어디서 이어서 할지.',
'chat.commandAutocomplete.command.debugDescription': '수정안을 제시하기 전에 버그의 근본 원인을 단계적으로 조사합니다.',
@@ -1767,6 +1768,8 @@ export const dict: Record<I18nKey, string> = {
'snippets.source.project': '프로젝트',
'chat.chatInput.toast.compactFailed': '세션 압축 실패',
'chat.chatInput.toast.summaryFailed': '요약 생성 실패',
'chat.messageBody.actions.sendReviewFeedback': '리뷰 피드백을 변경 구현 에이전트에게 보내기',
'chat.messageBody.actions.sendImplementationResponse': '구현 응답을 리뷰 에이전트에게 보내기',
'chat.chatInput.toast.reviewFailed': '변경사항 검토 실패',
'chat.chatInput.toast.planFeatureFailed': '기능 계획을 시작하지 못했습니다',
'chat.chatInput.toast.catchUpFailed': '컨텍스트를 불러오지 못했습니다',
+3
View File
@@ -622,6 +622,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.compactDescription': 'Skompresuj historię sesji używając AI aby zredukować rozmiar kontekstu',
'chat.commandAutocomplete.command.summaryDescription': 'Niedestrukcyjne podsumowanie sesji. Opcjonalna wskazówka tematu po poleceniu.',
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Sprawdź diff obszaru roboczego pod kątem zamiaru, poprawności i adekwatności — ze znaleziskami według wagi.',
'chat.commandAutocomplete.command.handoffReviewDescription': 'Utwórz lub użyj ponownie osobnej sesji przeglądu z wygenerowanego handoffu.',
'chat.commandAutocomplete.command.featurePlanDescription': 'Rozpocznij prowadzoną, interaktywną sesję planowania nowej funkcji.',
'chat.commandAutocomplete.command.catchUpDescription': 'Przywróć kontekst: nad czym pracowałeś i od czego kontynuować.',
'chat.commandAutocomplete.command.debugDescription': 'Prowadzone badanie pierwotnej przyczyny błędu przed zaproponowaniem poprawki.',
@@ -1052,6 +1053,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.toast.sendAttachmentsFailed': 'Nie udało się wysłać załączników. Spróbuj użyć mniejszej liczby plików lub mniejszych obrazów.',
'chat.chatInput.toast.someFilesSkipped': 'Pominięto niektóre pliki:\n{summary}',
'chat.chatInput.toast.summaryFailed': 'Nie udało się wygenerować podsumowania',
'chat.messageBody.actions.sendReviewFeedback': 'Wyślij uwagi z przeglądu do agenta wdrażającego zmiany',
'chat.messageBody.actions.sendImplementationResponse': 'Wyślij odpowiedź wdrożeniową do agenta przeglądającego',
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Nie udało się przełączyć automatycznej akceptacji uprawnień',
'chat.chatInput.toast.vscodePickFailed': 'Nie udało się wybrać plików w VS Code',
'chat.chatInput.worktreeNew': '+ Nowe',
@@ -1607,6 +1607,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.compactDescription": "Comprimir o histórico da sessão usando IA para reduzir o tamanho do contexto",
"chat.commandAutocomplete.command.summaryDescription": "Resumo não destrutivo da sessão. Dica opcional do tema após o comando.",
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisa o diff do workspace quanto a intenção, correção e adequação, com achados por severidade.",
"chat.commandAutocomplete.command.handoffReviewDescription": "Crie ou reutilize uma sessão separada de revisão a partir de um handoff gerado.",
"chat.commandAutocomplete.command.featurePlanDescription": "Inicie uma sessão de planejamento guiada e interativa para uma nova funcionalidade.",
"chat.commandAutocomplete.command.catchUpDescription": "Retome o contexto: o que você estava fazendo e por onde continuar.",
"chat.commandAutocomplete.command.debugDescription": "Investigação guiada da causa raiz de um bug antes de propor uma correção.",
@@ -1733,6 +1734,8 @@ export const dict: Record<I18nKey, string> = {
"snippets.source.project": "projeto",
"chat.chatInput.toast.compactFailed": "Não foi possível comprimir a sessão",
"chat.chatInput.toast.summaryFailed": "Não foi possível gerar o resumo",
"chat.messageBody.actions.sendReviewFeedback": "Enviar feedback da revisão ao agente que implementa as mudanças",
"chat.messageBody.actions.sendImplementationResponse": "Enviar resposta da implementação ao agente revisor",
"chat.chatInput.toast.reviewFailed": "Não foi possível revisar as alterações",
"chat.chatInput.toast.planFeatureFailed": "Não foi possível iniciar o planejamento da funcionalidade",
"chat.chatInput.toast.catchUpFailed": "Não foi possível retomar o contexto",
+3
View File
@@ -1607,6 +1607,7 @@ export const dict: Record<I18nKey, string> = {
"chat.commandAutocomplete.command.compactDescription": "Стиснути історію сесії за допомогою ШІ, щоб зменшити розмір контексту",
"chat.commandAutocomplete.command.summaryDescription": "Неруйнівний підсумок сесії. Після команди можна додати тему.",
"chat.commandAutocomplete.command.workspaceReviewDescription": "Перевірити diff робочого простору на намір, коректність і адекватність — зі знахідками за рівнем критичності.",
"chat.commandAutocomplete.command.handoffReviewDescription": "Створити або повторно використати окрему сесію ревʼю зі згенерованого handoff.",
"chat.commandAutocomplete.command.featurePlanDescription": "Розпочати покрокову діалогову сесію планування нової фічі.",
"chat.commandAutocomplete.command.catchUpDescription": "Повернутись у контекст: над чим працювали і звідки продовжити.",
"chat.commandAutocomplete.command.debugDescription": "Кероване дослідження першопричини бага перед тим, як пропонувати фікс.",
@@ -1733,6 +1734,8 @@ export const dict: Record<I18nKey, string> = {
"snippets.source.project": "проєкт",
"chat.chatInput.toast.compactFailed": "Не вдалося стиснути сесію",
"chat.chatInput.toast.summaryFailed": "Не вдалося створити підсумок",
"chat.messageBody.actions.sendReviewFeedback": "Надіслати фідбек ревʼю агенту, який реалізує зміни",
"chat.messageBody.actions.sendImplementationResponse": "Надіслати відповідь щодо реалізації агенту-ревʼюеру",
"chat.chatInput.toast.reviewFailed": "Не вдалося переглянути зміни",
"chat.chatInput.toast.planFeatureFailed": "Не вдалося розпочати планування фічі",
"chat.chatInput.toast.catchUpFailed": "Не вдалося зібрати контекст",
@@ -1607,6 +1607,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.compactDescription': '使用 AI 压缩会话历史以减少上下文大小',
'chat.commandAutocomplete.command.summaryDescription': '非破坏性会话总结。命令后可选填主题提示。',
'chat.commandAutocomplete.command.workspaceReviewDescription': '审查工作区 diff 的意图、正确性与充分性,并按严重程度分级。',
'chat.commandAutocomplete.command.handoffReviewDescription': '根据生成的交接内容创建或复用独立的审查会话。',
'chat.commandAutocomplete.command.featurePlanDescription': '为新功能开始一次引导式的来回规划会话。',
'chat.commandAutocomplete.command.catchUpDescription': '重新进入上下文:你之前在做什么、从哪里继续。',
'chat.commandAutocomplete.command.debugDescription': '在提出修复方案前,引导式地排查 bug 的根本原因。',
@@ -1733,6 +1734,8 @@ export const dict: Record<I18nKey, string> = {
'snippets.source.project': '项目',
'chat.chatInput.toast.compactFailed': '压缩会话失败',
'chat.chatInput.toast.summaryFailed': '生成总结失败',
'chat.messageBody.actions.sendReviewFeedback': '将审查反馈发送给实现更改的代理',
'chat.messageBody.actions.sendImplementationResponse': '将实现回应发送给审查代理',
'chat.chatInput.toast.reviewFailed': '审查变更失败',
'chat.chatInput.toast.planFeatureFailed': '无法开始功能规划',
'chat.chatInput.toast.catchUpFailed': '无法获取上下文',
@@ -1611,6 +1611,7 @@ export const dict: Record<I18nKey, string> = {
'chat.commandAutocomplete.command.compactDescription': '使用 AI 壓縮會話歷史以減少上下文大小',
'chat.commandAutocomplete.command.summaryDescription': '非破壞性會話總結。命令後可選填主題提示。',
'chat.commandAutocomplete.command.workspaceReviewDescription': '審查工作區 diff 的意圖、正確性與充分性,並依嚴重程度分級。',
'chat.commandAutocomplete.command.handoffReviewDescription': '根據生成的交接內容建立或重用獨立的審查會話。',
'chat.commandAutocomplete.command.featurePlanDescription': '為新功能開始一次引導式的來回規劃工作階段。',
'chat.commandAutocomplete.command.catchUpDescription': '重新進入上下文:你之前在做什麼、從哪裡繼續。',
'chat.commandAutocomplete.command.debugDescription': '在提出修復方案前,引導式地排查 bug 的根本原因。',
@@ -1737,6 +1738,8 @@ export const dict: Record<I18nKey, string> = {
'snippets.source.project': '項目',
'chat.chatInput.toast.compactFailed': '壓縮會話失敗',
'chat.chatInput.toast.summaryFailed': '生成總結失敗',
'chat.messageBody.actions.sendReviewFeedback': '將審查回饋傳送給實作變更的代理',
'chat.messageBody.actions.sendImplementationResponse': '將實作回應傳送給審查代理',
'chat.chatInput.toast.reviewFailed': '審查變更失敗',
'chat.chatInput.toast.planFeatureFailed': '無法開始功能規劃',
'chat.chatInput.toast.catchUpFailed': '無法取得上下文',
+75
View File
@@ -29,6 +29,11 @@ export type MagicPromptId =
| 'session.summary.instructions'
| 'session.review.visible'
| 'session.review.instructions'
| 'session.reviewHandoff.visible'
| 'session.reviewHandoff.instructions'
| 'session.reviewSession.visible'
| 'session.reviewFeedbackToImplementer.visible'
| 'session.implementationResponseToReviewer.visible'
| 'session.plan.visible'
| 'session.plan.instructions'
| 'session.catchup.visible'
@@ -603,6 +608,76 @@ Output:
- If you find nothing real, say so plainly instead of inventing findings.
Keep the review concise and practical. Respond in the same language the user uses.`,
},
{
id: 'session.reviewHandoff.visible',
title: 'Review Handoff Visible Prompt',
group: 'Session',
description: 'Visible user message sent by the /handoff-review command.',
template: 'Prepare a handoff for another agent to review this work.',
},
{
id: 'session.reviewHandoff.instructions',
title: 'Review Handoff Instructions',
group: 'Session',
description: 'Hidden instructions attached to the /handoff-review command. Produces a handoff for a separate review agent.',
template: `Produce a review handoff for another agent. Do not compact or mutate session history. Your output is an assistant message that OpenChamber will send to a separate reviewer agent.
Include:
- The user's original intent and any later clarifications that changed the intent
- What was implemented and why
- Files changed, with brief purpose per file
- Important design decisions and tradeoffs
- Validation/tests run, if known
- Known gaps, uncertainty, or areas the reviewer should inspect closely
Formatting:
- Concise markdown with clear sections
- No preamble like "Here is a handoff"
- Do not mention OpenChamber metadata, linked sessions, session IDs, or routing
- Respond in the same language the user used most in the conversation`,
},
{
id: 'session.reviewSession.visible',
title: 'Review Session Starter Prompt',
group: 'Session',
description: 'Visible user message sent to the generated review session.',
placeholders: [
{ key: 'handoff', description: 'The generated implementation handoff.' },
],
template: `Please review the changes described in this handoff.
Focus on correctness, regressions, missing implementation, missing tests, and whether the implementation satisfies the stated intent. Provide concise, actionable feedback for the agent implementing the changes.
{{handoff}}`,
},
{
id: 'session.reviewFeedbackToImplementer.visible',
title: 'Review Feedback Transfer Prompt',
group: 'Session',
description: 'Visible user message sent from a review session back to the implementing agent.',
placeholders: [
{ key: 'review_feedback', description: 'Reviewer assistant feedback text.' },
],
template: `Another agent reviewed your changes and left the feedback below.
Please review the feedback, resolve the relevant issues, and explain what you changed.
{{review_feedback}}`,
},
{
id: 'session.implementationResponseToReviewer.visible',
title: 'Implementation Response Transfer Prompt',
group: 'Session',
description: 'Visible user message sent from the implementing agent back to the review session.',
placeholders: [
{ key: 'implementation_response', description: 'Implementing assistant response text.' },
],
template: `The agent implementing the changes has responded to the previous review feedback.
Please review the latest state again and report any remaining issues.
{{implementation_response}}`,
},
{
id: 'session.plan.visible',
+7 -4
View File
@@ -486,20 +486,22 @@ class OpencodeService {
return Array.isArray(response.data) ? response.data : [];
}
async createSession(params?: { parentID?: string; title?: string }, directory?: string | null): Promise<Session> {
async createSession(params?: { parentID?: string; title?: string; metadata?: Record<string, unknown> }, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.create({
...(requestDirectory ? { directory: requestDirectory } : {}),
parentID: params?.parentID,
title: params?.title,
metadata: params?.metadata,
});
return unwrapSdkData(response, 'session.create');
}
async getSession(id: string): Promise<Session> {
async getSession(id: string, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.get({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
...(requestDirectory ? { directory: requestDirectory } : {})
});
return unwrapSdkData(response, 'session.get');
}
@@ -515,12 +517,13 @@ class OpencodeService {
async updateSession(
id: string,
patch: { title?: string; time?: { archived?: number | null } },
patch: { title?: string; metadata?: Record<string, unknown>; time?: { archived?: number | null } },
directory?: string | null,
): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const sdkPatch = {
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
...(patch.time?.archived !== undefined && patch.time.archived !== null ? { time: { archived: patch.time.archived } } : {}),
};
const response = await this.client.session.update({
+235
View File
@@ -0,0 +1,235 @@
import type { Message, Session } from '@opencode-ai/sdk/v2/client';
import { opencodeClient } from '@/lib/opencode/client';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import {
getOriginalSessionID,
getReviewSessionID,
getSessionMetadata,
isReviewSession,
withoutReviewSessionLink,
withReviewSessionLink,
withReviewSessionMarker,
} from '@/lib/sessionReviewMetadata';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useUIStore } from '@/stores/useUIStore';
import { optimisticSend, patchSessionMetadata, waitForConnectionOrThrow } from '@/sync/session-actions';
import { getSyncMessages, getSyncParts, registerSessionDirectory } from '@/sync/sync-refs';
import { markPendingUserSendAnimation } from '@/lib/userSendAnimation';
const HANDOFF_TIMEOUT_MS = 180_000;
const HANDOFF_POLL_MS = 400;
const REVIEW_SESSION_TITLE = 'Review of workspace changes';
type SessionModelContext = {
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
type StartReviewFlowInput = SessionModelContext & {
originalSessionID: string;
directory: string;
agentMentionName?: string;
};
const isMessageCompleted = (message: Message): boolean => {
const finish = (message as { finish?: unknown }).finish;
if (typeof finish === 'string' && finish.length > 0) return true;
const completed = (message as { time?: { completed?: unknown } }).time?.completed;
return typeof completed === 'number' && completed > 0;
};
const getMessageCreatedAt = (message: Message): number => {
const created = (message as { time?: { created?: unknown } }).time?.created;
return typeof created === 'number' && Number.isFinite(created) ? created : 0;
};
const getMessageRole = (message: Message): string => {
const role = (message as { role?: unknown }).role;
return typeof role === 'string' ? role : '';
};
const waitForAssistantText = async (sessionID: string, directory: string, afterCreatedAt: number): Promise<string> => {
const deadline = Date.now() + HANDOFF_TIMEOUT_MS;
while (Date.now() < deadline) {
const messages = getSyncMessages(sessionID, directory);
const candidates = messages
.filter((message) => getMessageRole(message) === 'assistant')
.filter((message) => getMessageCreatedAt(message) >= afterCreatedAt - 1000)
.filter(isMessageCompleted)
.sort((left, right) => getMessageCreatedAt(right) - getMessageCreatedAt(left));
for (const message of candidates) {
const text = flattenAssistantTextParts(getSyncParts(message.id, directory)).trim();
if (text) return text;
}
await new Promise((resolve) => setTimeout(resolve, HANDOFF_POLL_MS));
}
throw new Error('Timed out waiting for handoff response');
};
const resolveModelContext = (sessionID: string): SessionModelContext | null => {
const context = useContextStore.getState();
const config = useConfigStore.getState();
const agent = context.getSessionAgentSelection(sessionID) || config.currentAgentName || undefined;
const sessionModel = context.getSessionModelSelection(sessionID);
const agentModel = agent ? context.getAgentModelForSession(sessionID, agent) : null;
const selectedModel = agentModel || sessionModel || (config.currentProviderId && config.currentModelId
? { providerId: config.currentProviderId, modelId: config.currentModelId }
: null);
if (!selectedModel?.providerId || !selectedModel?.modelId) return null;
const variant = agent
? context.getAgentModelVariantForSession(sessionID, agent, selectedModel.providerId, selectedModel.modelId) || config.currentVariant || undefined
: config.currentVariant || undefined;
return {
providerID: selectedModel.providerId,
modelID: selectedModel.modelId,
agent,
variant,
};
};
const sendPlainMessage = async (
sessionID: string,
directory: string,
text: string,
modelContext?: SessionModelContext | null,
additionalParts?: Array<{ text: string; synthetic?: boolean }>,
): Promise<void> => {
const resolved = modelContext ?? resolveModelContext(sessionID);
if (!resolved) throw new Error('Select a model before sending review flow messages');
markPendingUserSendAnimation(sessionID);
await optimisticSend({
sessionId: sessionID,
content: text,
directory,
providerID: resolved.providerID,
modelID: resolved.modelID,
agent: resolved.agent,
onOptimisticInsert: () => requestChatForceScrollBottom(sessionID),
send: (messageID) => opencodeClient.sendMessage({
id: sessionID,
directory,
providerID: resolved.providerID,
modelID: resolved.modelID,
agent: resolved.agent,
variant: resolved.variant,
text,
additionalParts,
messageId: messageID,
}).then(() => undefined),
});
};
const requestChatForceScrollBottom = (sessionId: string): void => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('openchamber:chat-force-scroll-bottom', {
detail: { sessionId },
}));
};
const openReviewSessionPanel = (directory: string, session: Session): void => {
useUIStore.getState().openContextPanelTab(directory, {
mode: 'chat',
dedupeKey: `session:${session.id}`,
label: session.title ?? null,
});
};
const getSessionOrNull = async (sessionID: string, directory: string): Promise<Session | null> => {
try {
return await opencodeClient.getSession(sessionID, directory);
} catch {
return null;
}
};
const createOrReuseReviewSession = async (originalSessionID: string, directory: string): Promise<Session> => {
const original = await opencodeClient.getSession(originalSessionID, directory);
const existingReviewID = getReviewSessionID(original);
if (existingReviewID) {
const existing = await getSessionOrNull(existingReviewID, directory);
if (existing && isReviewSession(existing)) return existing;
await patchSessionMetadata(originalSessionID, directory, (metadata) => {
const next = { ...metadata };
const openchamber = next.openchamber;
if (openchamber && typeof openchamber === 'object' && !Array.isArray(openchamber)) {
const rest = { ...(openchamber as Record<string, unknown>) };
delete rest.reviewSessionID;
next.openchamber = rest;
}
return next;
});
}
const review = await opencodeClient.createSession({
title: REVIEW_SESSION_TITLE,
metadata: withReviewSessionMarker({}, originalSessionID),
}, directory);
registerSessionDirectory(review.id, directory);
try {
await patchSessionMetadata(originalSessionID, directory, (metadata) => withReviewSessionLink(metadata, review.id));
} catch (error) {
await opencodeClient.deleteSession(review.id, directory).catch((deleteError) => {
console.warn('[review-flow] failed to delete unlinked review session after link failure', deleteError);
});
throw error;
}
useGlobalSessionsStore.getState().upsertSession(review);
return review;
};
export const startReviewFlow = async (input: StartReviewFlowInput): Promise<void> => {
await waitForConnectionOrThrow();
const visibleText = await renderMagicPrompt('session.reviewHandoff.visible');
const instructionsText = await renderMagicPrompt('session.reviewHandoff.instructions');
const startedAt = Date.now();
await sendPlainMessage(input.originalSessionID, input.directory, visibleText, input, [
{ text: instructionsText, synthetic: true },
]);
const handoff = await waitForAssistantText(input.originalSessionID, input.directory, startedAt);
const reviewSession = await createOrReuseReviewSession(input.originalSessionID, input.directory);
const reviewPrompt = await renderMagicPrompt('session.reviewSession.visible', { handoff });
await sendPlainMessage(reviewSession.id, input.directory, reviewPrompt, {
providerID: input.providerID,
modelID: input.modelID,
});
openReviewSessionPanel(input.directory, reviewSession);
};
export const sendReviewFeedbackToOriginal = async (reviewSessionID: string, directory: string, reviewFeedback: string): Promise<void> => {
const reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
const originalSessionID = getOriginalSessionID(reviewSession);
if (!originalSessionID) throw new Error('Original session is missing');
const prompt = await renderMagicPrompt('session.reviewFeedbackToImplementer.visible', { review_feedback: reviewFeedback });
await sendPlainMessage(originalSessionID, directory, prompt);
};
export const sendImplementationResponseToReviewer = async (originalSessionID: string, directory: string, implementationResponse: string): Promise<void> => {
const originalSession = await opencodeClient.getSession(originalSessionID, directory);
const reviewSessionID = getReviewSessionID(originalSession);
if (!reviewSessionID) throw new Error('Review session is missing');
let reviewSession: Session;
try {
reviewSession = await opencodeClient.getSession(reviewSessionID, directory);
} catch (error) {
await patchSessionMetadata(originalSessionID, directory, (metadata) => withoutReviewSessionLink(metadata, reviewSessionID));
throw error;
}
const prompt = await renderMagicPrompt('session.implementationResponseToReviewer.visible', { implementation_response: implementationResponse });
await sendPlainMessage(reviewSessionID, directory, prompt);
openReviewSessionPanel(directory, reviewSession);
};
export const getReviewTransferDirection = (session: Session | null | undefined): 'review-to-original' | 'original-to-review' | null => {
if (isReviewSession(session)) return 'review-to-original';
if (getReviewSessionID(session)) return 'original-to-review';
return null;
};
export const readSessionReviewMetadata = (session: Session | null | undefined) => getSessionMetadata(session);
@@ -0,0 +1,82 @@
import type { Session } from '@opencode-ai/sdk/v2';
export type SessionMetadataRecord = Record<string, unknown>;
type OpenChamberMetadata = {
kind?: 'review';
originalSessionID?: string;
reviewSessionID?: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
export const getSessionMetadata = (session: Session | null | undefined): SessionMetadataRecord => {
const metadata = (session as (Session & { metadata?: unknown }) | null | undefined)?.metadata;
return isRecord(metadata) ? metadata : {};
};
const getOpenChamberMetadata = (metadata: SessionMetadataRecord): OpenChamberMetadata => {
const value = metadata.openchamber;
return isRecord(value) ? value as OpenChamberMetadata : {};
};
export const getReviewSessionID = (session: Session | null | undefined): string | null => {
const value = getOpenChamberMetadata(getSessionMetadata(session)).reviewSessionID;
return typeof value === 'string' && value.trim().length > 0 ? value : null;
};
export const getOriginalSessionID = (session: Session | null | undefined): string | null => {
const value = getOpenChamberMetadata(getSessionMetadata(session)).originalSessionID;
return typeof value === 'string' && value.trim().length > 0 ? value : null;
};
export const isReviewSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).kind === 'review' && Boolean(getOriginalSessionID(session));
export const withReviewSessionLink = (
metadata: SessionMetadataRecord,
reviewSessionID: string,
): SessionMetadataRecord => {
const current = getOpenChamberMetadata(metadata);
return {
...metadata,
openchamber: {
...current,
reviewSessionID,
},
};
};
export const withReviewSessionMarker = (
metadata: SessionMetadataRecord,
originalSessionID: string,
): SessionMetadataRecord => {
const current = getOpenChamberMetadata(metadata);
return {
...metadata,
openchamber: {
...current,
kind: 'review' as const,
originalSessionID,
},
};
};
export const withoutReviewSessionLink = (
metadata: SessionMetadataRecord,
reviewSessionID: string,
): SessionMetadataRecord => {
const current = getOpenChamberMetadata(metadata);
if (current.reviewSessionID !== reviewSessionID) return metadata;
const restOpenChamber = { ...current };
delete restOpenChamber.reviewSessionID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(restOpenChamber).length > 0) {
next.openchamber = restOpenChamber;
} else {
delete next.openchamber;
}
return next;
};
@@ -122,6 +122,7 @@ const getSessionSignature = (session: Session): string => {
session.time?.updated ?? 0,
session.time?.archived ?? 0,
session.share?.url ?? '',
JSON.stringify((session as Session & { metadata?: unknown }).metadata ?? null),
resolveGlobalSessionDirectory(session) ?? '',
].join(':');
};
+49
View File
@@ -16,6 +16,13 @@ import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { stripMessageDiffSnapshots } from "./sanitize"
import { sessionEvents } from "@/lib/sessionEvents"
import {
getOriginalSessionID,
getSessionMetadata,
isReviewSession,
withoutReviewSessionLink,
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
const MESSAGE_REFETCH_LIMIT = 200
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -331,11 +338,13 @@ export async function createSession(
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
): Promise<Session | null> {
try {
const session = await opencodeClient.createSession({
title,
parentID: parentID ?? undefined,
metadata,
}, directoryOverride ?? dir())
const sessionDirectory = (session as { directory?: string | null }).directory ?? null
@@ -354,6 +363,42 @@ export async function createSession(
}
}
export async function patchSessionMetadata(
sessionId: string,
directory: string | null | undefined,
updater: (metadata: SessionMetadataRecord) => SessionMetadataRecord,
): Promise<Session> {
const targetDirectory = directory ?? getSessionDirectory(sessionId)
const current = await opencodeClient.getSession(sessionId, targetDirectory)
const nextMetadata = updater(getSessionMetadata(current))
const updated = await opencodeClient.updateSession(sessionId, { metadata: nextMetadata }, targetDirectory)
useGlobalSessionsStore.getState().upsertSession(updated)
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
return updated
}
async function cleanupReviewMetadataBeforeDelete(sessionId: string, directory?: string | null): Promise<void> {
let session: Session
try {
session = await opencodeClient.getSession(sessionId, directory ?? getSessionDirectory(sessionId))
} catch {
return
}
if (!isReviewSession(session)) return
const originalSessionID = getOriginalSessionID(session)
if (!originalSessionID) return
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
withoutReviewSessionLink(metadata, sessionId),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] review metadata cleanup failed before delete", error)
}
}
/** Optimistically remove a session from every live child store that has it. */
function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
if (!_childStores) return []
@@ -408,6 +453,7 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
ui.setCurrentSession(null)
}
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory)
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
@@ -431,6 +477,7 @@ export async function deleteSessionInDirectory(sessionId: string, directory: str
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, directory)
const deleted = await opencodeClient.deleteSession(sessionId, directory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
@@ -544,6 +591,7 @@ export async function optimisticSend(input: {
agent?: string
directory?: string | null
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
onOptimisticInsert?: () => void
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
send: (messageID: string) => Promise<void>
}): Promise<void> {
@@ -588,6 +636,7 @@ export async function optimisticSend(input: {
message: optimisticMessage,
parts: optimisticParts,
})
input.onOptimisticInsert?.()
// Set busy status
const current = store.getState()
+3 -3
View File
@@ -268,7 +268,7 @@ export type SessionUIState = {
options?: SendMessageOptions,
) => Promise<void>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record<string, unknown>) => Promise<Session | null>
deleteSession: (id: string, options?: Record<string, unknown>) => Promise<boolean>
deleteSessions: (ids: string[], options?: Record<string, unknown>) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean>
@@ -998,14 +998,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// createSession
// ---------------------------------------------------------------------------
createSession: async (title, directoryOverride, parentID) => {
createSession: async (title, directoryOverride, parentID, metadata) => {
const draft = get().newSessionDraft
const targetFolderId = draft.targetFolderId
get().closeNewSessionDraft()
try {
const dir = directoryOverride ?? opencodeClient.getDirectory()
const session = await createSessionAction(title, dir, parentID ?? null)
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
if (!session) return null
if (targetFolderId) {