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
+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),