feat: add fork assistant message to new session

Add fork button on assistant messages to start new execution session
Include synthetic meta-instruction when forking to explain context
This commit is contained in:
Bohdan Triapitsyn
2025-12-09 00:40:43 +02:00
parent 96aa5bdd18
commit 2779e19103
13 changed files with 937 additions and 46 deletions
@@ -20,6 +20,7 @@ import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts } from './message/partUtils';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { TurnGroupingContext } from './hooks/useTurnGrouping';
@@ -461,16 +462,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return assistantSummaryForCopy;
}
const textParts = displayParts
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
.map((part) => {
const text = part.text || part.content || '';
return text.trim();
})
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
return flattenAssistantTextParts(displayParts);
}, [assistantSummaryForCopy, displayParts, isUser]);
const hasTextContent = messageTextContent.length > 0;
@@ -33,6 +33,7 @@ interface TurnDiffStats {
export interface TurnGroupingContext {
turnId: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
summaryBody?: string;
@@ -49,6 +50,7 @@ export interface TurnGroupingContext {
markPartsPreviewed: (partIds: string[]) => void;
}
interface TurnUiState {
isExpanded: boolean;
previewedPartIds: Set<string>;
@@ -361,6 +363,8 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
const firstAssistantId = turn.assistantMessages[0]?.info.id;
const isFirstAssistantInTurn = messageId === firstAssistantId;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
const isLastAssistantInTurn = messageId === lastAssistantId;
const uiState = getOrCreateTurnState(turn.turnId);
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
@@ -368,6 +372,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
return {
turnId: turn.turnId,
isFirstAssistantInTurn,
isLastAssistantInTurn,
summaryBody,
activityParts,
hasTools,
@@ -378,10 +383,12 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
previewedPartIds: uiState.previewedPartIds,
toggleGroup: () => toggleGroup(turn.turnId),
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
};
}, [getOrCreateTurnState, lastTurnId, sessionIsWorking, markPartsPreviewedInternal, messageToTurn, toggleGroup, turnActivityInfo]
} satisfies TurnGroupingContext;
},
[getOrCreateTurnState, lastTurnId, markPartsPreviewedInternal, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
);
return {
turns,
getTurnForMessage,
@@ -15,11 +15,14 @@ import { cn } from '@/lib/utils';
import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine } from '@remixicon/react';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionStore } from '@/stores/sessionStore';
import { useSessionStore } from '@/stores/useSessionStore';
const useMigrationTimer = (
turnGroupingContext: TurnGroupingContext | undefined,
@@ -318,6 +321,12 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = React.useMemo(() => {
return parts.some((part) => part.type === 'step-finish' && (part as { reason?: string | null | undefined }).reason === 'stop');
}, [parts]);
const hasTools = toolParts.length > 0;
const hasPendingTools = React.useMemo(() => {
@@ -511,6 +520,18 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
);
const handleForkClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!createSessionFromAssistantMessage) {
return;
}
void createSessionFromAssistantMessage(messageId);
},
[createSessionFromAssistantMessage, messageId]
);
React.useEffect(() => {
return () => {
clearCopyHintTimeout();
@@ -944,6 +965,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
summaryBody &&
summaryBody.trim().length > 0;
const shouldShowFooter = hasTextContent && assistantTextParts.length > 0 && hasStopFinish && isLastAssistantInTurn;
return (
<div
className={cn(
@@ -960,24 +983,30 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
{renderedParts}
{showSummaryBody && (
<FadeInOnReveal key="summary-body">
<div
className="group/assistant-text relative break-words"
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
{canCopyMessage && (
<div className="group/assistant-text relative break-words">
<SimpleMarkdownRenderer
content={summaryBody}
/>
</div>
</FadeInOnReveal>
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-end gap-2">
{onCopyMessage && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'absolute z-10 flex h-7 w-7 items-center justify-center rounded-full border border-border/40 shadow-none bg-background/95 supports-[backdrop-filter]:bg-background/80 hover:bg-accent duration-150',
'opacity-0 pointer-events-none disabled:opacity-30 disabled:text-muted-foreground/40',
hasCopyableText &&
'group-hover/message:opacity-60 group-hover/message:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto',
(copyHintVisible || isMessageCopied) && 'opacity-100 pointer-events-auto'
'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 && 'opacity-50',
(copyHintVisible || isMessageCopied) && 'text-primary'
)}
style={{ insetInlineEnd: '0.32rem', insetBlockStart: '-0.4rem' }}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
@@ -997,18 +1026,30 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5 text-foreground hover:text-primary focus-visible:text-primary" />
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
)}
<SimpleMarkdownRenderer
content={summaryBody}
/>
</div>
</FadeInOnReveal>
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="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"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
);
+2 -2
View File
@@ -5,7 +5,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { RiArrowDownSLine, RiArrowUpSLine, RiChat1Line, RiCodeLine, RiGitBranchLine, RiLayoutLeftLine, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowUpSLine, RiChat4Line, RiCodeLine, RiGitBranchLine, RiLayoutLeftLine, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
@@ -233,7 +233,7 @@ export const Header: React.FC = () => {
}, [isDesktopApp]);
const tabs: TabConfig[] = React.useMemo(() => [
{ id: 'chat', label: 'Chat', icon: RiChat1Line },
{ id: 'chat', label: 'Chat', icon: RiChat4Line },
{ id: 'diff', label: 'Diff', icon: RiCodeLine, badge: diffFileCount > 0 ? diffFileCount : undefined },
{ id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine },
{ id: 'git', label: 'Git', icon: RiGitBranchLine },
@@ -0,0 +1,8 @@
export const EXECUTION_FORK_META_TEXT =
"This message comes from an AI assistant in another session. The user wants you to respond according to its content: " +
"if it is an implementation plan, your task is to implement that plan; " +
"if it is a conclusion or summary, your task is to verify it, explain whether you agree or disagree, and correct it if needed. " +
"Always clearly state what you understand your task to be, and wait for the user's approval of your conclusions before taking any further actions.";
export const isExecutionForkMetaText = (text: string | null | undefined): boolean =>
typeof text === 'string' && text.trim() === EXECUTION_FORK_META_TEXT.trim();
@@ -0,0 +1,13 @@
import type { Part } from '@opencode-ai/sdk';
type TextLikePart = Part & { text?: string; content?: string };
export const flattenAssistantTextParts = (parts: Part[]): string => {
const textParts = parts
.filter((part): part is TextLikePart => part?.type === 'text')
.map((part) => (part.text || part.content || '').trim())
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
};
+8
View File
@@ -354,6 +354,7 @@ class OpencodeService {
providerID: string;
modelID: string;
text: string;
prefaceText?: string;
agent?: string;
files?: Array<{
type: 'file';
@@ -372,6 +373,13 @@ class OpencodeService {
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
const parts: Array<TextPartInput | FilePartInput | AgentPartInputLite> = [];
if (params.prefaceText && params.prefaceText.trim()) {
parts.push({
type: 'text',
text: params.prefaceText
});
}
// Add text part if there's content
if (params.text && params.text.trim()) {
const textPart: TextPartInput = {
+22 -2
View File
@@ -3,6 +3,7 @@ import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { Message, Part } from "@opencode-ai/sdk";
import { opencodeClient } from "@/lib/opencode/client";
import { isExecutionForkMetaText } from "@/lib/messages/executionMeta";
import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes";
import { MEMORY_LIMITS } from "./types/sessionTypes";
import {
@@ -332,7 +333,15 @@ export const useMessageStore = create<MessageStore>()(
userMessageMarker: message.info.role === "user" ? true : (message.info as any)?.userMessageMarker,
} as any;
const serverParts = Array.isArray(message.parts) ? [...message.parts] : [];
const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => {
if (part?.type === 'text') {
const raw = (part as any).text ?? (part as any).content ?? '';
if (isExecutionForkMetaText(raw)) {
return { ...part, synthetic: true } as Part;
}
}
return part;
});
const existingEntry = infoWithMarker?.id
? previousMessagesById.get(infoWithMarker.id as string)
: undefined;
@@ -935,6 +944,9 @@ export const useMessageStore = create<MessageStore>()(
}
const incomingText = extractTextFromPart(part);
if (isExecutionForkMetaText(incomingText)) {
(part as any).synthetic = true;
}
if (streamDebugEnabled() && actualRole === "assistant") {
try {
console.info("[STREAM-TRACE] part", {
@@ -1789,7 +1801,15 @@ export const useMessageStore = create<MessageStore>()(
: (message.info as any)?.animationSettled,
} as any;
const serverParts = Array.isArray(message.parts) ? [...message.parts] : [];
const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => {
if (part?.type === 'text') {
const raw = (part as any).text ?? (part as any).content ?? '';
if (isExecutionForkMetaText(raw)) {
return { ...part, synthetic: true } as Part;
}
}
return part;
});
const messageId = typeof infoWithMarker?.id === "string" ? (infoWithMarker.id as string) : undefined;
const existingEntry = messageId ? previousMessagesById.get(messageId) : undefined;
+4 -4
View File
@@ -21,7 +21,7 @@ interface SessionState {
interface SessionActions {
loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null) => Promise<Session | null>;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
updateSessionTitle: (id: string, title: string) => Promise<void>;
@@ -449,7 +449,7 @@ export const useSessionStore = create<SessionStore>()(
}
},
createSession: async (title?: string, directoryOverride?: string | null) => {
createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => {
set({ error: null });
const directoryStore = useDirectoryStore.getState();
const fallbackDirectory = normalizePath(directoryStore.currentDirectory);
@@ -461,7 +461,7 @@ export const useSessionStore = create<SessionStore>()(
const optimisticSession: Session = {
id: tempId,
title: title || "New session",
parentID: undefined,
parentID: parentID ?? undefined,
directory: targetDirectory ?? null,
projectID: (previousState.sessions[0] as { projectID?: string })?.projectID ?? "",
version: "0.0.0",
@@ -529,7 +529,7 @@ export const useSessionStore = create<SessionStore>()(
};
try {
const createRequest = () => opencodeClient.createSession({ title });
const createRequest = () => opencodeClient.createSession({ title, parentID: parentID ?? undefined });
let session: Session | null = null;
try {
+2 -1
View File
@@ -100,7 +100,8 @@ export interface SessionStore {
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => void;
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null) => Promise<Session | null>;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
+61 -2
View File
@@ -13,6 +13,9 @@ import { useContextStore } from "./contextStore";
import { usePermissionStore } from "./permissionStore";
import { opencodeClient } from "@/lib/opencode/client";
import { useDirectoryStore } from "./useDirectoryStore";
import { useConfigStore } from "./useConfigStore";
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta";
import { flattenAssistantTextParts } from "@/lib/messages/messageText";
export type { AttachedFile, EditPermissionMode };
export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes";
@@ -105,14 +108,70 @@ export const useSessionStore = create<SessionStore>()(
},
loadSessions: () => useSessionManagementStore.getState().loadSessions(),
createSession: async (title?: string, directoryOverride?: string | null) => {
const result = await useSessionManagementStore.getState().createSession(title, directoryOverride);
createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => {
const result = await useSessionManagementStore.getState().createSession(title, directoryOverride, parentID);
if (result?.id) {
await get().setCurrentSession(result.id);
}
return result;
},
createSessionFromAssistantMessage: async (sourceMessageId: string) => {
if (!sourceMessageId) {
return;
}
const messageStore = useMessageStore.getState();
const { messages, lastUsedProvider } = messageStore;
let sourceEntry: { info: Message; parts: Part[] } | undefined;
let sourceSessionId: string | undefined;
messages.forEach((messageList, sessionId) => {
const found = messageList.find((entry) => entry.info?.id === sourceMessageId);
if (found && !sourceEntry) {
sourceEntry = found;
sourceSessionId = sessionId;
}
});
if (!sourceEntry || sourceEntry.info.role !== "assistant") {
return;
}
const assistantPlanText = flattenAssistantTextParts(sourceEntry.parts);
if (!assistantPlanText.trim()) {
return;
}
const sessionManagementStore = useSessionManagementStore.getState();
const directory = resolveSessionDirectory(
sessionManagementStore.sessions,
sourceSessionId ?? null,
sessionManagementStore.getWorktreeMetadata,
);
const session = await get().createSession(undefined, directory ?? null, null);
if (!session) {
return;
}
const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState();
const providerID = currentProviderId || lastUsedProvider?.providerID;
const modelID = currentModelId || lastUsedProvider?.modelID;
if (!providerID || !modelID) {
return;
}
await opencodeClient.sendMessage({
id: session.id,
providerID,
modelID,
text: assistantPlanText,
prefaceText: EXECUTION_FORK_META_TEXT,
agent: currentAgentName ?? undefined,
});
},
deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options),
deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options),
updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title),