fix(chat): restore fork prompts in the destination composer (#3387)

This commit is contained in:
Andrea V
2026-09-07 20:26:26 +03:00
committed by GitHub
parent a059d54b44
commit 21011cfe0a
9 changed files with 497 additions and 28 deletions
@@ -952,7 +952,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
identity: initialDraftIdentityRef.current, identity: initialDraftIdentityRef.current,
}, },
onIdentityChange: () => setInputMode('normal'), onIdentityChange: () => setInputMode('normal'),
onDraftRestored: () => composerRef.current?.selectAll(), onDraftRestored: (source) => {
if (source === 'fork') composerRef.current?.focus();
composerRef.current?.selectAll();
},
}); });
// Focus textarea when new session draft is opened // Focus textarea when new session draft is opened
@@ -183,6 +183,11 @@ and the send path reading the same grammar.
draft. Two orderings are load-bearing: the debounced write is skipped once draft. Two orderings are load-bearing: the debounced write is skipped once
while a draft is being restored, and a deleted draft's empty signature is while a draft is being restored, and a deleted draft's empty signature is
recorded before a queued write could resurrect it. recorded before a queued write could resurrect it.
Fork replay text and files arrive in `input-store.pendingComposerRestore`,
addressed to the fork's runtime, directory, and session. The hook consumes
them after loading that identity's draft. Selection alone is not enough:
the deferred chat column can still show the source composer. Ordinary
pending text insertions keep their existing path in `ChatInput`.
- `state/useDraftTarget.ts` — the draft can target a directory that does not - `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation. It branch list, or the selector snaps back to the project root mid-creation. It
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { installHookTestDom } from '@/components/session/sidebar/test-utils/testDom';
import { readChatDraft, writeChatDraft, type ChatDraftIdentity } from '@/lib/chatDraftPersistence';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useInputStore } from '@/sync/input-store';
import { useComposerDraft } from '../useComposerDraft';
const source: ChatDraftIdentity = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'source' };
const fork: ChatDraftIdentity = { ...source, sessionId: 'fork' };
const replayFile = { url: 'data:text/plain;base64,aGVsbG8=', mimeType: 'text/plain', filename: 'replay.txt' };
function renderComposer(persistEnabled: boolean) {
const dom = installHookTestDom();
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
globalThis.requestAnimationFrame = (callback) => {
frames.set(++frameId, callback);
return frameId;
};
globalThis.cancelAnimationFrame = (id) => { frames.delete(id); };
const root = createRoot(dom.container);
const restored: string[] = [];
const result = { text: '', mentions: new Set<string>(), restored };
function Probe({ identity }: { identity: ChatDraftIdentity }) {
const [message, setMessage] = React.useState('source draft @source.ts');
const messageRef = React.useRef(message);
const confirmedMentionsRef = React.useRef(new Set(['source.ts']));
React.useEffect(() => { messageRef.current = message; }, [message]);
useComposerDraft({
message, messageRef, setMessage, confirmedMentionsRef, identity, persistEnabled,
initialDraft: { text: '', identity: source },
onDraftRestored: (reason) => { result.restored.push(reason); },
});
result.text = message;
result.mentions = confirmedMentionsRef.current;
return null;
}
const render = (identity: ChatDraftIdentity) => {
act(() => { root.render(React.createElement(Probe, { identity })); });
};
render(source);
return {
result,
render,
flushFrames: () => {
const pending = [...frames.values()];
frames.clear();
act(() => { for (const callback of pending) callback(0); });
},
teardown: () => {
act(() => { root.unmount(); });
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
dom.restore();
},
};
}
beforeEach(() => {
getDeferredSafeStorage().removeItem('openchamber.chatDrafts.v2');
useInputStore.setState({ pendingComposerRestore: null });
useInputStore.getState().clearAttachedFiles();
});
describe('fork composer restoration', () => {
for (const persistEnabled of [true, false]) {
test(`waits for the rendered fork and preserves the source, persistence=${persistEnabled}`, () => {
writeChatDraft(fork, 'previous fork draft @old.ts', ['old.ts']);
useInputStore.getState().addRestoredAttachment({ ...replayFile, filename: 'source.txt' });
const sourceFiles = useInputStore.getState().attachedFiles;
const composer = renderComposer(persistEnabled);
try {
// Selection already changed, but the deferred chat column still renders source.
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay prompt', files: [replayFile] } });
});
expect(composer.result.text).toBe('source draft @source.ts');
expect(useInputStore.getState().attachedFiles).toBe(sourceFiles);
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
composer.render(fork);
expect(composer.result.text).toBe('replay prompt');
expect(composer.result.mentions.size).toBe(0);
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
composer.flushFrames();
expect(composer.result.restored).toContain('fork');
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
composer.render(source);
expect(composer.result.text).toBe(persistEnabled ? 'source draft @source.ts' : '');
expect(readChatDraft(fork).text).toBe(persistEnabled ? 'replay prompt' : '');
} finally {
composer.teardown();
}
});
}
test('waits through unrelated session, directory, and runtime renders', () => {
const composer = renderComposer(true);
try {
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay', files: [] } });
});
for (const identity of [
{ ...fork, sessionId: 'other' },
{ ...fork, directory: '/other' },
{ ...fork, runtimeKey: 'runtime-b' },
]) {
composer.render(identity);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
}
composer.render(fork);
expect(composer.result.text).toBe('replay');
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
} finally {
composer.teardown();
}
});
test('persists a replay even when its text equals the outgoing source draft', async () => {
const composer = renderComposer(true);
try {
const text = composer.result.text;
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text, files: [] } });
});
composer.render(fork);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 550)); });
expect(readChatDraft(fork)).toEqual({ text, confirmedMentions: new Set() });
expect(readChatDraft(source)).toEqual({ text, confirmedMentions: new Set(['source.ts']) });
} finally {
composer.teardown();
}
});
test('restores file-only and empty prompts without keeping destination text or files', () => {
const composer = renderComposer(true);
try {
writeChatDraft(fork, 'stale destination text', []);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [replayFile] } });
});
composer.render(fork);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [] } });
});
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles).toEqual([]);
} finally {
composer.teardown();
}
});
});
@@ -12,6 +12,7 @@
*/ */
import React from 'react'; import React from 'react';
import { useInputStore } from '@/sync/input-store';
import { import {
getChatDraftIdentityKey, getChatDraftIdentityKey,
@@ -52,8 +53,8 @@ export interface ComposerDraftOptions {
initialDraft: { text: string; identity: ChatDraftIdentity | null }; initialDraft: { text: string; identity: ChatDraftIdentity | null };
/** Called when the composer switches to a different draft identity. */ /** Called when the composer switches to a different draft identity. */
onIdentityChange?: () => void; onIdentityChange?: () => void;
/** Called after a non-empty draft is restored, to select its text. */ /** Called after restoring a saved draft or fork replay, to select its text. */
onDraftRestored?: () => void; onDraftRestored?: (source: 'saved' | 'fork') => void;
} }
export interface ComposerDraftControls { export interface ComposerDraftControls {
@@ -81,6 +82,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
const skipNextPersistRef = React.useRef(false); const skipNextPersistRef = React.useRef(false);
const lastPersistedRef = React.useRef<Map<string, string>>(new Map()); const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity); const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
const pendingComposerRestore = useInputStore((state) => state.pendingComposerRestore);
// Callbacks reach the effects through a ref so a caller passing inline // Callbacks reach the effects through a ref so a caller passing inline
// functions does not re-run the persistence effects on every render. // functions does not re-run the persistence effects on every render.
@@ -129,7 +131,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
writeChatDraft(initialDraft.identity, '', []); writeChatDraft(initialDraft.identity, '', []);
return; return;
} }
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.()); requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
// Runs once; the initial draft is captured at mount by design. // Runs once; the initial draft is captured at mount by design.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistEnabled]); }, [persistEnabled]);
@@ -160,10 +162,35 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
setMessage(restored.text); setMessage(restored.text);
confirmedMentionsRef.current = restored.confirmedMentions; confirmedMentionsRef.current = restored.confirmedMentions;
if (restored.text) { if (restored.text) {
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.()); requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
} }
}, [clearPending, confirmedMentionsRef, identity, messageRef, persistEnabled, persistNow, setMessage]); }, [clearPending, confirmedMentionsRef, identity, messageRef, persistEnabled, persistNow, setMessage]);
// The chat column can still show the source after navigation selects a fork.
// Apply its replay only after the destination's draft has been loaded above.
React.useEffect(() => {
if (!pendingComposerRestore) return;
const input = useInputStore.getState();
const pending = input.consumePendingComposerRestore(identity);
if (!pending) return;
clearPending();
skipNextPersistRef.current = true;
messageRef.current = pending.text;
confirmedMentionsRef.current = new Set();
setMessage(pending.text);
// Equal source/replay text need not trigger another render to persist.
if (persistEnabled) persistNow(pending.target, pending.text);
input.clearAttachedFiles();
for (const file of pending.files) input.addRestoredAttachment(file);
requestAnimationFrame(() => {
const current = currentIdentityRef.current;
if (current && getChatDraftIdentityKey(current) === getChatDraftIdentityKey(pending.target)) {
callbacksRef.current.onDraftRestored?.('fork');
}
});
}, [clearPending, confirmedMentionsRef, identity, messageRef, pendingComposerRestore, persistEnabled, persistNow, setMessage]);
// A draft deleted elsewhere (session deleted, drafts cleared) clears the // A draft deleted elsewhere (session deleted, drafts cleared) clears the
// composer if it is the one on screen. // composer if it is the one on screen.
React.useEffect(() => subscribeChatDraftDeletion((deleted) => { React.useEffect(() => subscribeChatDraftDeletion((deleted) => {
+1 -1
View File
@@ -52,7 +52,7 @@ So:
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes | | `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments | | `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | | `input-store.ts` | Draft input state, attached files, synthetic parts, destination-scoped fork replay handoff | App UI state; fork replay targets runtime + directory + session |
| `selection-store.ts` | Model/agent/variant selections | App UI state | | `selection-store.ts` | Model/agent/variant selections | App UI state |
| `voice-store.ts` | Voice state | App UI state | | `voice-store.ts` | Voice state | App UI state |
+32
View File
@@ -50,6 +50,38 @@ const waitForReaderCount = async (count: number) => {
const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
describe("input-store composer restore", () => {
beforeEach(() => {
useInputStore.setState({ pendingComposerRestore: null })
})
test("only the destination can consume a restore, and only once", () => {
const target = { runtimeKey: "runtime", directory: "/repo", sessionId: "fork" }
const pending = { target, text: "replay", files: [] }
useInputStore.setState({ pendingComposerRestore: pending })
for (const identity of [
null,
{ ...target, sessionId: "source" },
{ ...target, directory: "/elsewhere" },
{ ...target, runtimeKey: "other-runtime" },
]) {
expect(useInputStore.getState().consumePendingComposerRestore(identity)).toBeNull()
expect(useInputStore.getState().pendingComposerRestore).toBe(pending)
}
expect(useInputStore.getState().consumePendingComposerRestore(target)).toBe(pending)
expect(useInputStore.getState().consumePendingComposerRestore(target)).toBeNull()
})
test("keeps ordinary pending text independent from fork restoration", () => {
const target = { runtimeKey: "runtime", directory: "/repo", sessionId: "fork" }
const pending = { target, text: "", files: [] }
useInputStore.setState({ pendingComposerRestore: pending })
useInputStore.getState().setPendingInputText("ordinary insertion", "append")
expect(useInputStore.getState().consumePendingInputText()).toEqual({ text: "ordinary insertion", mode: "append" })
expect(useInputStore.getState().consumePendingComposerRestore(target)).toBe(pending)
})
})
describe("input-store attachments", () => { describe("input-store attachments", () => {
beforeEach(() => { beforeEach(() => {
pendingReaders.length = 0 pendingReaders.length = 0
+14
View File
@@ -7,6 +7,7 @@ import { create } from "zustand"
import type { ContextPartMetadata } from '@/lib/messages/contextParts' import type { ContextPartMetadata } from '@/lib/messages/contextParts'
import type { AttachedFile } from "@/stores/types/sessionTypes" import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFiles } from "./attachment-files" import { prepareAttachmentFiles } from "./attachment-files"
import { getChatDraftIdentityKey, type ChatDraftIdentity } from "@/lib/chatDraftPersistence"
const FILE_URI_PREFIX = "file://" const FILE_URI_PREFIX = "file://"
const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3 const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3
@@ -128,6 +129,12 @@ export type VSCodeActiveEditorFile = {
} }
export type InputState = { export type InputState = {
pendingComposerRestore: {
target: ChatDraftIdentity
text: string
files: Array<{ url: string; mimeType: string; filename: string }>
} | null
consumePendingComposerRestore: (target: ChatDraftIdentity | null) => InputState["pendingComposerRestore"]
pendingInputText: string | null pendingInputText: string | null
pendingInputMode: "replace" | "append" | "append-inline" pendingInputMode: "replace" | "append" | "append-inline"
pendingSyntheticParts: SyntheticContextPart[] | null pendingSyntheticParts: SyntheticContextPart[] | null
@@ -158,6 +165,13 @@ export type InputState = {
} }
export const useInputStore = create<InputState>()((set, get) => ({ export const useInputStore = create<InputState>()((set, get) => ({
pendingComposerRestore: null,
consumePendingComposerRestore: (target) => {
const pending = get().pendingComposerRestore
if (!pending || !target || getChatDraftIdentityKey(pending.target) !== getChatDraftIdentityKey(target)) return null
set({ pendingComposerRestore: null })
return pending
},
pendingInputText: null, pendingInputText: null,
pendingInputMode: "replace", pendingInputMode: "replace",
pendingSyntheticParts: null, pendingSyntheticParts: null,
+223 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach, mock } from "bun:test" import { describe, expect, test, beforeEach, mock } from "bun:test"
import type { PermissionRequest } from "@/types/permission" import type { PermissionRequest } from "@/types/permission"
import type { QuestionRequest } from "@/types/question" import type { QuestionRequest } from "@/types/question"
import type { InputState } from "./input-store"
// Mock SDK client that records permission.reply / question.reply calls // Mock SDK client that records permission.reply / question.reply calls
const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = [] const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = []
@@ -18,6 +19,10 @@ const failingRevertSessionIds = new Set<string>()
const failingUnrevertSessionIds = new Set<string>() const failingUnrevertSessionIds = new Set<string>()
let afterUnrevertCall: ((sessionId: string) => void) | null = null let afterUnrevertCall: ((sessionId: string) => void) | null = null
let sessionDeleteError: unknown | null = null let sessionDeleteError: unknown | null = null
let sessionForkResult: Session | null = null
let sessionForkError: Error | null = null
let beforeSessionForkResolve: (() => void) | null = null
const selectedSessions: Array<{ sessionId: string | null; directoryHint?: string | null }> = []
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null
@@ -179,6 +184,13 @@ mock.module("@/lib/opencode/client", () => ({
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } }) replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? []) return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
}), }),
forkSession: mock(async (sessionId: string, messageId?: string, directory?: string | null): Promise<Session> => {
replyCalls.push({ method: "session.fork", params: { sessionID: sessionId, messageID: messageId, directory } })
beforeSessionForkResolve?.()
if (sessionForkError) throw sessionForkError
if (!sessionForkResult) throw new Error("Missing fork session fixture")
return sessionForkResult
}),
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true) return Promise.resolve(true)
@@ -236,7 +248,9 @@ mock.module("./session-ui-store", () => ({
return null return null
}, },
currentSessionId: null, currentSessionId: null,
setCurrentSession: () => {}, setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => {
selectedSessions.push({ sessionId, directoryHint })
},
setWorktreeMetadata: () => {}, setWorktreeMetadata: () => {},
setSessionDirectory: (sessionID: string, directory: string) => { setSessionDirectory: (sessionID: string, directory: string) => {
movedSessionDirectories.push({ sessionID, directory }) movedSessionDirectories.push({ sessionID, directory })
@@ -246,15 +260,27 @@ mock.module("./session-ui-store", () => ({
})) }))
// Mock useInputStore // Mock useInputStore
const inputState = { const inputState: Pick<InputState,
"pendingComposerRestore" | "pendingInputText" | "pendingInputMode" | "attachedFiles"
| "clearAttachedFiles" | "addRestoredAttachment"
> = {
pendingComposerRestore: null,
pendingInputText: "", pendingInputText: "",
pendingInputMode: "normal" as const, pendingInputMode: "replace",
attachedFiles: [], attachedFiles: [],
clearAttachedFiles: () => { clearAttachedFiles: () => {
inputState.attachedFiles = [] inputState.attachedFiles = []
}, },
addRestoredAttachment: (attachment: never) => { addRestoredAttachment: (attachment) => {
inputState.attachedFiles = [...inputState.attachedFiles, attachment] inputState.attachedFiles = [...inputState.attachedFiles, {
id: attachment.url,
file: new File([], attachment.filename, { type: attachment.mimeType }),
dataUrl: attachment.url,
mimeType: attachment.mimeType,
filename: attachment.filename,
size: 0,
source: "server",
}]
}, },
} }
@@ -1935,6 +1961,197 @@ describe("respondToPermission passes directory", () => {
}) })
}) })
describe("forkFromMessage composer restore", () => {
const sourceSession: Session = {
id: "session-a",
slug: "source-session",
projectID: "project-a",
directory: "/test/project",
title: "Source session",
version: "1",
time: { created: 1, updated: 1 },
}
const forkedSession: Session = { ...sourceSession, id: "session-fork", slug: "forked-session" }
const textPart: Part = {
id: "part-text",
sessionID: sourceSession.id,
messageID: "message-fork",
type: "text",
text: "Replay this prompt",
}
const filePart: Part = {
id: "part-file",
sessionID: sourceSession.id,
messageID: "message-fork",
type: "file",
url: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
filename: "screenshot.png",
}
const restoredFile = { url: filePart.url, mimeType: filePart.mime, filename: filePart.filename }
beforeEach(() => {
replyCalls.length = 0
selectedSessions.length = 0
runtimeKey = "fork-runtime"
sessionForkResult = forkedSession
sessionForkError = null
beforeSessionForkResolve = null
inputState.pendingComposerRestore = null
inputState.pendingInputText = "Keep the source draft"
inputState.pendingInputMode = "append"
inputState.attachedFiles = [{
id: "source-attachment",
file: new File(["source"], "source.txt", { type: "text/plain" }),
dataUrl: "data:text/plain;base64,c291cmNl",
mimeType: "text/plain",
filename: "source.txt",
size: 6,
source: "local",
}]
})
for (const directory of ["/test/project", "/canonical/project"]) {
test(`stages the replay for the returned session in ${directory} without changing the source composer`, async () => {
sessionForkResult = { ...forkedSession, directory }
const source = createStore({}, {
session: [sourceSession],
part: { "message-fork": [textPart, filePart] },
})
const sourceInput = { ...inputState }
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => "/other/project")
await forkFromMessage(sourceSession.id, "message-fork")
expect(replyCalls).toEqual([{
method: "session.fork",
params: { sessionID: sourceSession.id, messageID: "message-fork", directory: sourceSession.directory },
}])
expect(inputState.pendingComposerRestore).toEqual({
target: { runtimeKey: "fork-runtime", directory, sessionId: forkedSession.id },
text: "Replay this prompt",
files: [restoredFile],
})
expect(inputState.pendingInputText).toBe(sourceInput.pendingInputText)
expect(inputState.pendingInputMode).toBe(sourceInput.pendingInputMode)
expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles)
expect(inputState.attachedFiles).toHaveLength(1)
expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: directory }])
expect(source.getState().session).toEqual([sourceSession, sessionForkResult])
})
}
test("uses the returned project worktree when the fork has no directory", async () => {
const forkWithProject: Session & { project: { worktree: string } } = {
...forkedSession, directory: "", project: { worktree: "/canonical/worktree" },
}
sessionForkResult = forkWithProject
const source = createStore({}, { session: [sourceSession], part: { "message-fork": [textPart] } })
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory)
await forkFromMessage(sourceSession.id, "message-fork")
expect(inputState.pendingComposerRestore?.target.directory).toBe("/canonical/worktree")
expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: "/canonical/worktree" }])
})
test("stages a file-only prompt with empty text without replacing source attachments", async () => {
const source = createStore({}, {
session: [sourceSession],
part: { "message-fork": [filePart] },
})
const sourceInput = { ...inputState }
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory)
await forkFromMessage(sourceSession.id, "message-fork")
expect(inputState.pendingComposerRestore).toEqual({
target: { runtimeKey: "fork-runtime", directory: sourceSession.directory, sessionId: forkedSession.id },
text: "",
files: [restoredFile],
})
expect(inputState.pendingInputText).toBe(sourceInput.pendingInputText)
expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles)
expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: sourceSession.directory }])
})
test("excludes synthetic text and files from the staged replay", async () => {
const syntheticFile: Part & { synthetic: boolean } = {
...filePart,
id: "part-synthetic-file",
url: "file:///test/project/generated.txt",
mime: "text/plain",
filename: "generated.txt",
synthetic: true,
}
const source = createStore({}, {
session: [sourceSession],
part: { "message-fork": [
{ ...textPart, id: "part-synthetic-text", text: "Generated file contents", synthetic: true },
textPart,
syntheticFile,
filePart,
] },
})
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory)
await forkFromMessage(sourceSession.id, "message-fork")
expect(inputState.pendingComposerRestore).toEqual({
target: { runtimeKey: "fork-runtime", directory: sourceSession.directory, sessionId: forkedSession.id },
text: "Replay this prompt",
files: [restoredFile],
})
})
test("leaves input, selection, and sessions unchanged when the fork fails", async () => {
sessionForkError = new Error("fork failed")
const source = createStore({}, {
session: [sourceSession],
part: { "message-fork": [textPart, filePart] },
})
const sourceState = source.getState()
const sourceInput = { ...inputState }
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory)
await expect(forkFromMessage(sourceSession.id, "message-fork")).rejects.toThrow("fork failed")
expect(inputState).toEqual(sourceInput)
expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles)
expect(selectedSessions).toEqual([])
expect(source.getState()).toBe(sourceState)
})
test("does not select, mutate, or stage a fork resolved after the runtime changes", async () => {
beforeSessionForkResolve = () => { runtimeKey = "other-runtime" }
const source = createStore({}, {
session: [sourceSession],
part: { "message-fork": [textPart, filePart] },
})
const sourceState = source.getState()
const sourceInput = { ...inputState }
const { forkFromMessage, setActionRefs } = await import("./session-actions")
setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory)
await forkFromMessage(sourceSession.id, "message-fork")
expect(replyCalls).toEqual([{
method: "session.fork",
params: { sessionID: sourceSession.id, messageID: "message-fork", directory: sourceSession.directory },
}])
expect(runtimeKey).toBe("other-runtime")
expect(inputState).toEqual(sourceInput)
expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles)
expect(selectedSessions).toEqual([])
expect(source.getState()).toBe(sourceState)
})
})
describe("revertToMessage passes session directory", () => { describe("revertToMessage passes session directory", () => {
beforeEach(() => { beforeEach(() => {
replyCalls.length = 0 replyCalls.length = 0
@@ -1944,7 +2161,7 @@ describe("revertToMessage passes session directory", () => {
failingRevertSessionIds.clear() failingRevertSessionIds.clear()
Object.assign(inputState, { Object.assign(inputState, {
pendingInputText: "previous draft", pendingInputText: "previous draft",
pendingInputMode: "normal" as const, pendingInputMode: "replace",
attachedFiles: [], attachedFiles: [],
}) })
}) })
+21 -14
View File
@@ -3,7 +3,7 @@
* Replaces the action methods from the old useSessionStore. * Replaces the action methods from the old useSessionStore.
*/ */
import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" import type { FilePart, OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary" import { Binary } from "./binary"
import { useSessionUIStore } from "./session-ui-store" import { useSessionUIStore } from "./session-ui-store"
import { useInputStore } from "./input-store" import { useInputStore } from "./input-store"
@@ -43,6 +43,7 @@ import { normalizePath } from "@/lib/pathNormalization"
import { mergeMessages } from "./optimistic" import { mergeMessages } from "./optimistic"
import { messagesBefore, messagesFrom } from "./message-ordering" import { messagesBefore, messagesFrom } from "./message-ordering"
import { deleteChatDirectory } from "@/lib/chatDirectories" import { deleteChatDirectory } from "@/lib/chatDirectories"
import { createChatDraftIdentity } from "@/lib/chatDraftPersistence"
const MESSAGE_REFETCH_LIMIT = 100 const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30 const SEND_CONFIRMATION_REFETCH_LIMIT = 30
@@ -2425,9 +2426,10 @@ export async function unrevertSession(sessionId: string): Promise<void> {
* 1. Extract text from the message for input restoration * 1. Extract text from the message for input restoration
* 2. Call the runtime fork endpoint * 2. Call the runtime fork endpoint
* 3. Insert the new session into the child store (so sidebar updates immediately) * 3. Insert the new session into the child store (so sidebar updates immediately)
* 4. Switch to new session and set pending input text * 4. Switch to the new session and stage its composer replay
*/ */
export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> { export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> {
const expectedRuntimeKey = getRuntimeKey()
const { store, directory } = dirStoreForSession(sessionId) const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState() const state = store.getState()
@@ -2442,9 +2444,12 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "") .map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
.join("\n") .join("\n")
.trim() .trim()
const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>> const fileParts = parts.filter((part): part is FilePart => part.type === "file" && !isSyntheticPart(part))
const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory) const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory)
if (isStaleRuntime(expectedRuntimeKey)) return
const target = createChatDraftIdentity(expectedRuntimeKey, resolveSessionOwnedDirectory(forkedSession) ?? directory, forkedSession.id)
if (!target) throw new Error("Forked session has no composer directory")
// Insert new session into child store so sidebar updates immediately // Insert new session into child store so sidebar updates immediately
const current = store.getState() const current = store.getState()
@@ -2456,22 +2461,24 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
} }
// Switch to new session // Switch to new session
useSessionUIStore.getState().setCurrentSession(forkedSession.id) useSessionUIStore.getState().setCurrentSession(forkedSession.id, target.directory)
// Restore forked message text and file attachments to input // Navigation is deferred in the chat column. Leave the source composer alone
if (messageText) { // until the rendered draft identity matches the fork, including for file-only prompts.
useInputStore.setState({ useInputStore.setState({
pendingInputText: messageText, pendingComposerRestore: {
pendingInputMode: "replace" as const, target,
text: messageText,
files: fileParts.filter((part) => part.url).map((part) => ({
url: part.url,
mimeType: part.mime,
filename: part.filename ?? "attachment",
})),
},
}) })
}
// Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts)
// The forked session is a fresh draft target, so the attached context of the // The forked session is a fresh draft target, so the attached context of the
// forked message follows the text into its composer. // forked message follows the text into its composer.
if (directory) { restoreContextPartsToInput(parts, { directory: target.directory, sessionKey: forkedSession.id })
restoreContextPartsToInput(parts, { directory, sessionKey: forkedSession.id })
}
} }
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> { export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {