Merge desktop branch-from-here support into main

Bring the verified worktree implementation into local main while preserving the existing local main commits and unrelated dirty working tree changes.

Constraint: Local main already had unrelated uncommitted provider/runtime edits, so the merge only touches the desktop branch feature paths.
Confidence: high
Scope-risk: moderate
Tested: bun run verify before merge; merge completed without conflicts
Not-tested: Post-merge full quality gate on dirty local main
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 18:31:47 +08:00
commit fed9b6c9f6
16 changed files with 1783 additions and 256 deletions

View File

@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setBaseUrl } from './client'
import { sessionsApi } from './sessions'
describe('sessionsApi', () => {
afterEach(() => {
setBaseUrl('http://127.0.0.1:3456')
vi.restoreAllMocks()
})
it('posts branch requests to the session branch endpoint', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
sessionId: 'branch-session',
title: 'Branch',
workDir: '/workspace/repo',
sourceSessionId: 'source-session',
targetMessageId: 'message-1',
}), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
setBaseUrl('http://127.0.0.1:49237')
const result = await sessionsApi.branch('source-session', {
targetMessageId: 'message-1',
title: 'Branch',
})
expect(result.sessionId).toBe('branch-session')
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('http://127.0.0.1:49237/api/sessions/source-session/branch')
expect(init).toMatchObject({
method: 'POST',
body: JSON.stringify({
targetMessageId: 'message-1',
title: 'Branch',
}),
})
})
})

View File

@ -40,6 +40,17 @@ export type CreateSessionRequest = {
workDir?: string
repository?: CreateSessionRepositoryOptions
}
export type BranchSessionRequest = {
targetMessageId: string
title?: string
}
export type BranchSessionResponse = {
sessionId: string
title: string
workDir: string | null
sourceSessionId: string
targetMessageId: string
}
export type RepositoryBranchInfo = {
name: string
current: boolean
@ -314,6 +325,10 @@ export const sessionsApi = {
return api.post<CreateSessionResponse>('/api/sessions', body)
},
branch(sessionId: string, body: BranchSessionRequest) {
return api.post<BranchSessionResponse>(`/api/sessions/${sessionId}/branch`, body)
},
delete(sessionId: string) {
return api.delete<{ ok: true }>(`/api/sessions/${sessionId}`)
},

View File

@ -6,6 +6,7 @@ import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { UIMessage } from '../../types/chat'
@ -105,6 +106,7 @@ describe('MessageList nested tool calls', () => {
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
@ -2290,6 +2292,107 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
})
it('branches from completed transcript-backed chat messages using the original transcript id', async () => {
const branchSession = vi.fn().mockResolvedValue({
sessionId: 'branched-session-1',
title: 'Branched session',
workDir: '/tmp/branched-session-1',
})
const connectToSession = vi.fn()
useSessionStore.setState({
sessions: [{
id: ACTIVE_TAB,
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 2,
projectPath: '/tmp/source-project',
projectRoot: '/tmp/source-project',
workDir: '/tmp/source-project',
workDirExists: true,
}],
branchSession: branchSession as never,
})
useChatStore.setState({
connectToSession: connectToSession as never,
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'local-user-1',
transcriptMessageId: 'transcript-user-1',
type: 'user_text',
content: '从这里开始',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
content: '这是完成的答复。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
const branchButtons = screen.getAllByRole('button', { name: 'Branch from here' })
expect(branchButtons).toHaveLength(2)
fireEvent.click(branchButtons[1]!)
await waitFor(() => {
expect(branchSession).toHaveBeenCalledWith(ACTIVE_TAB, 'transcript-assistant-1')
})
expect(connectToSession).toHaveBeenCalledWith('branched-session-1')
expect(useTabStore.getState().activeTabId).toBe('branched-session-1')
const tabs = useTabStore.getState().tabs
expect(tabs[tabs.length - 1]).toMatchObject({
sessionId: 'branched-session-1',
title: 'Branched session',
type: 'session',
})
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Created branched session "Branched session".',
})
})
it('hides branch actions while the current session is still running', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
streamingText: 'partial',
messages: [
{
id: 'local-user-1',
transcriptMessageId: 'transcript-user-1',
type: 'user_text',
content: '从这里开始',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
content: '这是完成的答复。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
expect(screen.queryByRole('button', { name: 'Branch from here' })).toBeNull()
})
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never)

View File

@ -1,8 +1,9 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, GitBranch, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
@ -48,6 +49,11 @@ type RewindTurnTarget = {
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type BranchableMessageTarget = {
uiMessageId: string
transcriptMessageId: string
}
type TurnChangeCardModel = {
target: RewindTurnTarget
checkpoint: SessionTurnCheckpoint
@ -353,6 +359,37 @@ function SelectableChatMessage({
)
}
function MessageBranchAction({
align,
label,
loading,
onClick,
}: {
align: ChatMessageRole
label: string
loading?: boolean
onClick: () => void
}) {
return (
<div
className={`pointer-events-none relative z-10 -mt-3 h-0 w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
align === 'user' ? 'flex justify-end pr-1' : 'flex justify-start pl-1'
}`}
>
<button
type="button"
onClick={onClick}
disabled={loading}
aria-label={label}
title={label}
className="pointer-events-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
<GitBranch size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
)
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
@ -450,6 +487,48 @@ function isTurnResponseMessage(message: UIMessage) {
)
}
function getBranchableMessageTargets(messages: UIMessage[]): Map<string, BranchableMessageTarget> {
const branchableTargets = new Map<string, BranchableMessageTarget>()
let currentTurnCandidates: Array<Extract<UIMessage, { type: 'user_text' | 'assistant_text' }>> = []
let hasResponseForCurrentTurn = false
const markCurrentTurnBranchable = () => {
if (!hasResponseForCurrentTurn) return
for (const candidate of currentTurnCandidates) {
if (!candidate.transcriptMessageId) continue
branchableTargets.set(candidate.id, {
uiMessageId: candidate.id,
transcriptMessageId: candidate.transcriptMessageId,
})
}
}
for (const message of messages) {
if (message.type === 'user_text') {
markCurrentTurnBranchable()
currentTurnCandidates = []
hasResponseForCurrentTurn = false
if (!message.pending && message.transcriptMessageId) {
currentTurnCandidates = [message]
}
continue
}
if (currentTurnCandidates.length === 0) continue
if (isTurnResponseMessage(message)) {
hasResponseForCurrentTurn = true
}
if (message.type === 'assistant_text' && message.transcriptMessageId) {
currentTurnCandidates.push(message)
}
}
markCurrentTurnBranchable()
return branchableTargets
}
export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] {
let userMessageIndex = -1
const completedTurns: RewindTurnTarget[] = []
@ -628,6 +707,7 @@ type MessageListProps = {
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const MAX_SCROLL_SNAPSHOTS = 100
const EMPTY_MESSAGES: UIMessage[] = []
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
@ -685,6 +765,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const sessionState = useChatStore((s) =>
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
)
const branchSession = useSessionStore((s) => s.branchSession)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
@ -692,7 +773,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false,
)
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? []
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
const streamingText = sessionState?.streamingText ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
@ -713,9 +794,17 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [branchingMessageId, setBranchingMessageId] = useState<string | null>(null)
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const branchActionsDisabled =
isMemberSession ||
chatState !== 'idle' ||
streamingText.trim().length > 0 ||
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
Boolean(sessionState?.activeToolName)
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
@ -842,6 +931,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const branchableMessageTargets = useMemo(
() => branchActionsDisabled ? new Map<string, BranchableMessageTarget>() : getBranchableMessageTargets(messages),
[branchActionsDisabled, messages],
)
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
@ -982,6 +1075,29 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
t,
])
const handleBranchMessage = useCallback(async (target: BranchableMessageTarget) => {
if (!resolvedSessionId || branchingMessageId) return
setBranchingMessageId(target.uiMessageId)
try {
const result = await branchSession(resolvedSessionId, target.transcriptMessageId)
const title = result.title.trim() || t('sidebar.newSession')
useTabStore.getState().openTab(result.sessionId, title)
useChatStore.getState().connectToSession(result.sessionId)
addToast({
type: 'success',
message: t('chat.branchSuccess', { title }),
})
} catch (error) {
addToast({
type: 'error',
message: t('chat.branchError', { detail: getApiErrorMessage(error) }),
})
} finally {
setBranchingMessageId(null)
}
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
return (
<div className="relative min-h-0 flex-1">
<div
@ -1024,6 +1140,17 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})()
: null
}
branchAction={
(() => {
const branchTarget = branchableMessageTargets.get(item.message.id)
if (!branchTarget) return undefined
return {
label: t('chat.branchFromHere'),
loading: branchingMessageId === branchTarget.uiMessageId,
onBranch: () => { void handleBranchMessage(branchTarget) },
}
})()
}
/>
)}
@ -1112,12 +1239,18 @@ export const MessageBlock = memo(function MessageBlock({
activeThinkingId,
agentTaskNotifications,
toolResult,
branchAction,
}: {
sessionId?: string | null
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
branchAction?: {
label: string
loading?: boolean
onBranch: () => void
}
}) {
const t = useTranslation()
@ -1130,10 +1263,20 @@ export const MessageBlock = memo(function MessageBlock({
role="user"
content={message.content}
>
<UserMessage
content={message.content}
attachments={message.attachments}
/>
<div className="group">
<UserMessage
content={message.content}
attachments={message.attachments}
/>
{branchAction ? (
<MessageBranchAction
align="user"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
</SelectableChatMessage>
)
case 'assistant_text':
@ -1144,7 +1287,17 @@ export const MessageBlock = memo(function MessageBlock({
role="assistant"
content={message.content}
>
<AssistantMessage content={message.content} />
<div className="group">
<AssistantMessage content={message.content} />
{branchAction ? (
<MessageBranchAction
align="assistant"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
</SelectableChatMessage>
)
case 'thinking':

View File

@ -951,6 +951,9 @@ export const en = {
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
'chat.contextReferencesOnly': 'Added {count} references',
'chat.addSelectionToChat': 'Add to chat',
'chat.branchFromHere': 'Branch from here',
'chat.branchSuccess': 'Created branched session "{title}".',
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
'chat.userMessageReference': 'User message',
'chat.assistantMessageReference': 'Assistant message',
'chat.slashCommands': 'Slash commands',

View File

@ -953,6 +953,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加到对话',
'chat.branchFromHere': '从这里派生',
'chat.branchSuccess': '已创建派生会话“{title}”。',
'chat.branchError': '从该消息派生失败。详情:{detail}',
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令',

View File

@ -172,6 +172,8 @@ describe('chatStore history mapping', () => {
updateTabTitleMock.mockReset()
updateTabStatusMock.mockReset()
updateSessionTitleMock.mockReset()
vi.mocked(sessionsApi.getMessages).mockReset()
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
sessionStoreSnapshot.sessions = []
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
@ -251,6 +253,44 @@ describe('chatStore history mapping', () => {
])
})
it('preserves transcript message ids on natural-language history messages', () => {
const messages: MessageEntry[] = [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '请从这里继续',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
model: 'opus',
content: [
{ type: 'text', text: '这里是答复。' },
{ type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'transcript-user-1',
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
},
{
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
},
{
type: 'tool_use',
},
])
})
it('restores /goal local command output from transcript history', () => {
const messages: MessageEntry[] = [
{
@ -458,6 +498,143 @@ describe('chatStore history mapping', () => {
})
})
it('hydrates transcript ids for a just-completed live turn', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
content: 'live answer',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [
{
id: 'live-user',
type: 'user_text',
content: 'live prompt',
timestamp: 1,
},
],
streamingText: 'live answer',
chatState: 'streaming',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
await vi.waitFor(() => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
},
{
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
},
])
})
})
it('retries transcript id hydration after the assistant message is persisted', async () => {
vi.useFakeTimers()
vi.mocked(sessionsApi.getMessages)
.mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
],
})
.mockResolvedValueOnce({
messages: [
{
id: 'transcript-user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: 'live prompt',
},
{
id: 'transcript-assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
content: 'live answer',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [
{
id: 'live-user',
type: 'user_text',
content: 'live prompt',
timestamp: 1,
},
],
streamingText: 'live answer',
chatState: 'streaming',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
await Promise.resolve()
await Promise.resolve()
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
const firstHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(firstHydrationMessages[0]).toMatchObject({
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
})
expect(firstHydrationMessages[1]).toMatchObject({
type: 'assistant_text',
})
expect(firstHydrationMessages[1]).not.toHaveProperty('transcriptMessageId')
await vi.advanceTimersByTimeAsync(750)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
const secondHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(secondHydrationMessages[0]).toMatchObject({
type: 'user_text',
transcriptMessageId: 'transcript-user-1',
})
expect(secondHydrationMessages[1]).toMatchObject({
type: 'assistant_text',
transcriptMessageId: 'transcript-assistant-1',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('merges consecutive assistant text blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{

View File

@ -233,15 +233,26 @@ function appendAssistantTextMessage(
content: string,
timestamp: number,
model?: string,
transcriptMessageId?: string,
): UIMessage[] {
if (!content.trim()) return messages
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
const canMergeIntoLast =
last?.type === 'assistant_text' &&
(
transcriptMessageId
? last.transcriptMessageId === transcriptMessageId
: !last.transcriptMessageId
)
if (canMergeIntoLast) {
const merged: UIMessage = {
...last,
content: last.content + content,
...(model ?? last.model ? { model: model ?? last.model } : {}),
...(transcriptMessageId ?? last.transcriptMessageId
? { transcriptMessageId: transcriptMessageId ?? last.transcriptMessageId }
: {}),
}
return [...messages.slice(0, -1), merged]
}
@ -253,6 +264,7 @@ function appendAssistantTextMessage(
type: 'assistant_text',
content,
timestamp,
...(transcriptMessageId ? { transcriptMessageId } : {}),
...(model ? { model } : {}),
},
]
@ -330,6 +342,91 @@ function mergeRestoredTerminalGoalEvents(
: messages
}
function mergeRestoredTranscriptMessageIds(
messages: UIMessage[],
restoredMessages: UIMessage[],
): UIMessage[] {
const restoredCandidates = restoredMessages.filter((
message,
): message is Extract<UIMessage, { type: 'user_text' | 'assistant_text' }> =>
(message.type === 'user_text' || message.type === 'assistant_text') &&
typeof message.transcriptMessageId === 'string' &&
message.transcriptMessageId.length > 0)
if (restoredCandidates.length === 0) return messages
let restoredCursor = 0
let changed = false
const merged = messages.map((message) => {
if (
(message.type !== 'user_text' && message.type !== 'assistant_text') ||
message.transcriptMessageId
) {
return message
}
const matchIndex = restoredCandidates.findIndex((candidate, index) =>
index >= restoredCursor &&
candidate.type === message.type &&
candidate.content.trim() === message.content.trim())
if (matchIndex === -1) return message
restoredCursor = matchIndex + 1
changed = true
return {
...message,
transcriptMessageId: restoredCandidates[matchIndex]!.transcriptMessageId,
}
})
return changed ? merged : messages
}
function mergeRestoredHistoryIntoLiveMessages(
messages: UIMessage[],
restoredMessages: UIMessage[],
): UIMessage[] {
return mergeRestoredTerminalGoalEvents(
mergeRestoredTranscriptMessageIds(messages, restoredMessages),
restoredMessages,
)
}
function needsTranscriptIdHydrationRetry(session: PerSessionState | undefined): boolean {
if (!session || session.chatState !== 'idle') return false
let currentTurnHasHydratedUser = false
for (const message of session.messages) {
if (message.type === 'user_text') {
currentTurnHasHydratedUser = Boolean(message.transcriptMessageId)
continue
}
if (
currentTurnHasHydratedUser &&
message.type === 'assistant_text' &&
!message.transcriptMessageId
) {
return true
}
}
return false
}
function refreshCompletedTranscriptHistory(
get: () => ChatStore,
sessionId: string,
): void {
void get().loadHistory(sessionId).then(() => {
if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return
setTimeout(() => {
if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return
void get().loadHistory(sessionId)
}, 750)
})
}
function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] {
if (!data || typeof data !== 'object') return []
const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths
@ -705,7 +802,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
s.backgroundAgentTasks ?? {},
restoredBackgroundTasks,
),
messages: mergeRestoredTerminalGoalEvents(
messages: mergeRestoredHistoryIntoLiveMessages(
mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks),
uiMessages,
),
@ -1072,6 +1169,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
target: { type: 'session', sessionId },
})
}
refreshCompletedTranscriptHistory(get, sessionId)
break
}
@ -1666,13 +1764,24 @@ function pushAssistantHistoryText(
content: string,
timestamp: number,
model?: string,
transcriptMessageId?: string,
): void {
if (!content.trim()) return
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
const canMergeIntoLast =
last?.type === 'assistant_text' &&
(
transcriptMessageId
? last.transcriptMessageId === transcriptMessageId
: !last.transcriptMessageId
)
if (canMergeIntoLast) {
last.content += content
if (model && !last.model) last.model = model
if (transcriptMessageId && !last.transcriptMessageId) {
last.transcriptMessageId = transcriptMessageId
}
return
}
@ -1681,6 +1790,7 @@ function pushAssistantHistoryText(
type: 'assistant_text',
content,
timestamp,
...(transcriptMessageId ? { transcriptMessageId } : {}),
...(model ? { model } : {}),
})
}
@ -1881,6 +1991,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: teammateContents.join('\n\n'),
...(msg.id ? { transcriptMessageId: msg.id } : {}),
timestamp,
})
continue
@ -1890,6 +2001,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
timestamp,
@ -1898,13 +2010,22 @@ export function mapHistoryMessagesToUiMessages(
}
if (msg.type === 'assistant' && typeof msg.content === 'string') {
if (!msg.content.trim()) continue
uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
uiMessages.push({
id: msg.id || nextId(),
type: 'assistant_text',
content: msg.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
timestamp,
model: msg.model,
})
continue
}
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
else if (block.type === 'text' && block.text) pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model)
else if (block.type === 'text' && block.text) {
pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model, msg.id || undefined)
}
else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
}
continue
@ -1930,6 +2051,7 @@ export function mapHistoryMessagesToUiMessages(
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
attachments: allAttachments.length > 0 ? allAttachments : undefined,
timestamp,

View File

@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createMock, listMock } = vi.hoisted(() => ({
const { branchMock, createMock, listMock } = vi.hoisted(() => ({
branchMock: vi.fn(),
createMock: vi.fn(),
listMock: vi.fn(),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
branch: branchMock,
create: createMock,
list: listMock,
delete: vi.fn(),
@ -33,6 +35,7 @@ function createDeferred<T>() {
describe('sessionStore', () => {
beforeEach(() => {
branchMock.mockReset()
createMock.mockReset()
listMock.mockReset()
useSessionStore.setState({
@ -135,7 +138,7 @@ describe('sessionStore', () => {
it('forwards direct branch switch repository options when creating a session', async () => {
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
listMock.mockResolvedValue({ sessions: [], total: 0 })
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: false },
@ -165,4 +168,100 @@ describe('sessionStore', () => {
expect(useSessionStore.getState().sessions[0]?.workDir)
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
})
it('returns the branched session before the background refresh completes', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-1',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
}],
})
const result = await Promise.race([
useSessionStore.getState().branchSession('session-source-1', 'transcript-message-1'),
delay(100).then(() => 'timed-out'),
])
expect(result).toMatchObject({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
})
expect(branchMock).toHaveBeenCalledWith('session-source-1', {
targetMessageId: 'transcript-message-1',
})
expect(useSessionStore.getState().activeSessionId).toBe('session-branch-1')
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-1',
title: 'Branch from here',
projectPath: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-1',
projectRoot: '/workspace/repo',
workDirExists: true,
})
expect(listMock).toHaveBeenCalledOnce()
})
it('updates an existing optimistic branch row when the branch session id is already present', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-existing',
title: 'Updated branch',
workDir: '/workspace/repo/branches/session-branch-existing',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-2',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [
{
id: 'session-branch-existing',
title: 'Old branch title',
createdAt: '2026-05-18T00:00:00.000Z',
modifiedAt: '2026-05-18T00:00:00.000Z',
messageCount: 3,
projectPath: '/workspace/old',
projectRoot: '/workspace/old',
workDir: '/workspace/old',
workDirExists: true,
},
{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
},
],
})
await useSessionStore.getState().branchSession('session-source-1', 'transcript-message-2')
expect(useSessionStore.getState().sessions).toHaveLength(2)
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-existing',
title: 'Updated branch',
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-existing',
})
})
})

View File

@ -1,5 +1,10 @@
import { create } from 'zustand'
import { sessionsApi, type BatchDeleteSessionsResponse, type CreateSessionRepositoryOptions } from '../api/sessions'
import {
sessionsApi,
type BatchDeleteSessionsResponse,
type BranchSessionResponse,
type CreateSessionRepositoryOptions,
} from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useTabStore } from './tabStore'
import type { SessionListItem } from '../types/session'
@ -9,6 +14,8 @@ type CreateSessionOptions = {
repository?: CreateSessionRepositoryOptions
}
type BranchSessionResult = Pick<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
type SessionStore = {
sessions: SessionListItem[]
activeSessionId: string | null
@ -19,6 +26,11 @@ type SessionStore = {
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
branchSession: (
sourceSessionId: string,
targetMessageId: string,
options?: { title?: string },
) => Promise<BranchSessionResult>
deleteSession: (id: string) => Promise<void>
deleteSessions: (ids: string[]) => Promise<BatchDeleteSessionsResponse>
enterBatchMode: () => void
@ -96,6 +108,43 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
return id
},
branchSession: async (sourceSessionId, targetMessageId, options) => {
const result = await sessionsApi.branch(sourceSessionId, {
targetMessageId,
...(options?.title ? { title: options.title } : {}),
})
const sourceSession = get().sessions.find((session) => session.id === sourceSessionId)
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
id: result.sessionId,
title: result.title || 'New Session',
createdAt: now,
modifiedAt: now,
messageCount: 0,
projectPath: sourceSession?.projectPath ?? '',
projectRoot: sourceSession?.projectRoot ?? sourceSession?.workDir ?? result.workDir ?? null,
workDir: result.workDir ?? sourceSession?.workDir ?? null,
workDirExists: true,
}
set((state) => ({
sessions: state.sessions.some((session) => session.id === result.sessionId)
? state.sessions.map((session) =>
session.id === result.sessionId
? { ...session, ...optimisticSession }
: session)
: [optimisticSession, ...state.sessions],
activeSessionId: result.sessionId,
}))
void get().fetchSessions()
return {
sessionId: result.sessionId,
title: result.title,
workDir: result.workDir,
}
},
deleteSession: async (id: string) => {
await sessionsApi.delete(id)
useSessionRuntimeStore.getState().clearSelection(id)

View File

@ -212,8 +212,8 @@ export type TaskSummaryItem = {
}
export type UIMessage =
| { id: string; type: 'user_text'; content: string; modelContent?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
| { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; transcriptMessageId?: string; timestamp: number; model?: string }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }

View File

@ -1,223 +1,13 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { logEvent } from '../../services/analytics/index.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type {
ContentReplacementEntry,
Entry,
LogOption,
SerializedMessage,
TranscriptMessage,
} from '../../types/logs.js'
import { parseJSONL } from '../../utils/json.js'
import type { LogOption } from '../../types/logs.js'
import { getTranscriptPath } from '../../utils/sessionStorage.js'
import {
getProjectDir,
getTranscriptPath,
getTranscriptPathForSession,
isTranscriptMessage,
saveCustomTitle,
searchSessionsByCustomTitle,
} from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { escapeRegExp } from '../../utils/stringUtils.js'
type TranscriptEntry = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
}
}
/**
* Derive a single-line title base from the first user message.
* Collapses whitespace multiline first messages (pasted stacks, code)
* otherwise flow into the saved title and break the resume hint.
*/
export function deriveFirstPrompt(
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
): string {
const content = firstUserMessage?.message?.content
if (!content) return 'Branched conversation'
const raw =
typeof content === 'string'
? content
: content.find(
(block): block is { type: 'text'; text: string } =>
block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
)
}
/**
* Creates a fork of the current conversation by copying from the transcript file.
* Preserves all original metadata (timestamps, gitBranch, etc.) while updating
* sessionId and adding forkedFrom traceability.
*/
async function createFork(customTitle?: string): Promise<{
sessionId: UUID
title: string | undefined
forkPath: string
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementEntry['replacements']
}> {
const forkSessionId = randomUUID() as UUID
const originalSessionId = getSessionId()
const projectDir = getProjectDir(getOriginalCwd())
const forkSessionPath = getTranscriptPathForSession(forkSessionId)
const currentTranscriptPath = getTranscriptPath()
// Ensure project directory exists
await mkdir(projectDir, { recursive: true, mode: 0o700 })
// Read current transcript file
let transcriptContent: Buffer
try {
transcriptContent = await readFile(currentTranscriptPath)
} catch {
throw new Error('No conversation to branch')
}
if (transcriptContent.length === 0) {
throw new Error('No conversation to branch')
}
// Parse all transcript entries (messages + metadata entries like content-replacement)
const entries = parseJSONL<Entry>(transcriptContent)
// Filter to only main conversation messages (exclude sidechains and non-message entries)
const mainConversationEntries = entries.filter(
(entry): entry is TranscriptMessage =>
isTranscriptMessage(entry) && !entry.isSidechain,
)
// Content-replacement entries for the original session. These record which
// tool_result blocks were replaced with previews by the per-message budget.
// Without them in the fork JSONL, `claude -r {forkId}` reconstructs state
// with an empty replacements Map → previously-replaced results are classified
// as FROZEN and sent as full content (prompt cache miss + permanent overage).
// sessionId must be rewritten since loadTranscriptFile keys lookup by the
// session's messages' sessionId.
const contentReplacementRecords = entries
.filter(
(entry): entry is ContentReplacementEntry =>
entry.type === 'content-replacement' &&
entry.sessionId === originalSessionId,
)
.flatMap(entry => entry.replacements)
if (mainConversationEntries.length === 0) {
throw new Error('No messages to branch')
}
// Build forked entries with new sessionId and preserved metadata
let parentUuid: UUID | null = null
const lines: string[] = []
const serializedMessages: SerializedMessage[] = []
for (const entry of mainConversationEntries) {
// Create forked transcript entry preserving all original metadata
const forkedEntry: TranscriptEntry = {
...entry,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: originalSessionId,
messageUuid: entry.uuid,
},
}
// Build serialized message for LogOption
const serialized: SerializedMessage = {
...entry,
sessionId: forkSessionId,
}
serializedMessages.push(serialized)
lines.push(jsonStringify(forkedEntry))
if (entry.type !== 'progress') {
parentUuid = entry.uuid
}
}
// Append content-replacement entry (if any) with the fork's sessionId.
// Written as a SINGLE entry (same shape as insertContentReplacement) so
// loadTranscriptFile's content-replacement branch picks it up.
if (contentReplacementRecords.length > 0) {
const forkedReplacementEntry: ContentReplacementEntry = {
type: 'content-replacement',
sessionId: forkSessionId,
replacements: contentReplacementRecords,
}
lines.push(jsonStringify(forkedReplacementEntry))
}
// Write the fork session file
await writeFile(forkSessionPath, lines.join('\n') + '\n', {
encoding: 'utf8',
mode: 0o600,
})
return {
sessionId: forkSessionId,
title: customTitle,
forkPath: forkSessionPath,
serializedMessages,
contentReplacementRecords,
}
}
/**
* Generates a unique fork name by checking for collisions with existing session names.
* If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc.
*/
async function getUniqueForkName(baseName: string): Promise<string> {
const candidateName = `${baseName} (Branch)`
// Check if this exact name already exists
const existingWithExactName = await searchSessionsByCustomTitle(
candidateName,
{ exact: true },
)
if (existingWithExactName.length === 0) {
return candidateName
}
// Name collision - find a unique numbered suffix
// Search for all sessions that start with the base pattern
const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
// Extract existing fork numbers to find the next available
const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1
const forkNumberPattern = new RegExp(
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
)
for (const session of existingForks) {
const match = session.customTitle?.match(forkNumberPattern)
if (match) {
if (match[1]) {
usedNumbers.add(parseInt(match[1], 10))
} else {
usedNumbers.add(1) // " (Branch)" without number is treated as 1
}
}
}
// Find the next available number
let nextNumber = 2
while (usedNumbers.has(nextNumber)) {
nextNumber++
}
return `${baseName} (Branch ${nextNumber})`
}
createSessionBranch,
deriveFirstPrompt,
} from '../../utils/sessionBranching.js'
export async function call(
onDone: LocalJSXCommandOnDone,
@ -225,7 +15,6 @@ export async function call(
args: string,
): Promise<React.ReactNode> {
const customTitle = args?.trim() || undefined
const originalSessionId = getSessionId()
try {
@ -233,29 +22,23 @@ export async function call(
sessionId,
title,
forkPath,
workDir,
serializedMessages,
contentReplacementRecords,
} = await createFork(customTitle)
// Build LogOption for resume
const now = new Date()
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(m => m.type === 'user'),
)
// Save custom title - use provided title or firstPrompt as default
// This ensures /status and /resume show the same session name
// Always add " (Branch)" suffix to make it clear this is a branched session
// Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)")
const baseName = title ?? firstPrompt
const effectiveTitle = await getUniqueForkName(baseName)
await saveCustomTitle(sessionId, effectiveTitle, forkPath)
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: !!title,
} = await createSessionBranch({
sourceSessionId: originalSessionId,
sourceTranscriptPath: getTranscriptPath(),
title: customTitle,
sourceWorkDir: getOriginalCwd(),
})
const now = new Date()
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(
(message): message is Extract<typeof serializedMessages[number], { type: 'user' }> =>
message.type === 'user',
),
)
const forkLog: LogOption = {
date: now.toISOString().split('T')[0]!,
messages: serializedMessages,
@ -267,12 +50,16 @@ export async function call(
messageCount: serializedMessages.length,
isSidechain: false,
sessionId,
customTitle: effectiveTitle,
customTitle: title,
contentReplacements: contentReplacementRecords,
}
// Resume into the fork
const titleInfo = title ? ` "${title}"` : ''
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: !!customTitle,
})
const titleInfo = customTitle ? ` "${customTitle}"` : ''
const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
@ -280,7 +67,6 @@ export async function call(
await context.resume(sessionId, forkLog, 'fork')
onDone(successMessage, { display: 'system' })
} else {
// Fallback if resume not available
onDone(
`Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
)

View File

@ -14,6 +14,8 @@ import {
} from '../services/repositoryLaunchService.js'
import { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js'
import { parseJSONL } from '../../utils/json.js'
import { createSessionBranch } from '../../utils/sessionBranching.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js'
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
@ -265,6 +267,33 @@ function makeAssistantToolUseEntry(
}
}
function makeToolResultUserEntry(
toolUseId: string,
content: string,
uuid?: string,
parentUuid?: string,
sessionId = 'test-session',
): Record<string, unknown> {
return {
parentUuid: parentUuid || null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content,
}],
},
uuid: uuid || crypto.randomUUID(),
timestamp: '2026-01-01T00:02:30.000Z',
userType: 'external',
cwd: '/tmp/test',
sessionId,
}
}
function makeMetaUserEntry(): Record<string, unknown> {
return {
parentUuid: null,
@ -306,6 +335,17 @@ function makeWorktreeStateEntry(
}
}
function makeContentReplacementEntry(
sessionId: string,
replacements: Array<{ kind: 'tool-result'; toolUseId: string; replacement: string }>,
): Record<string, unknown> {
return {
type: 'content-replacement',
sessionId,
replacements,
}
}
async function writeFileHistoryBackup(
sessionId: string,
backupFileName: string,
@ -1564,6 +1604,154 @@ describe('SessionService', () => {
expect(launchInfo!.workDir).toBe(expectedWorkDir)
expect(launchInfo!.transcriptMessageCount).toBe(2)
})
it('createSessionBranch should preserve branch metadata, copied snapshots, and filtered replacements', async () => {
const sessionId = 'branch-source-session'
const workDir = path.join(tmpDir, 'branch-source')
const worktreePath = path.join(workDir, '.claude', 'worktrees', 'desktop-main-12345678')
const firstUserId = crypto.randomUUID()
const firstAssistantId = crypto.randomUUID()
const firstToolResultId = crypto.randomUUID()
const laterUserId = crypto.randomUUID()
const laterAssistantId = crypto.randomUUID()
const repository = {
branch: 'feature/rail',
worktree: true,
baseRef: 'feature/rail',
repoRoot: workDir,
}
const sourceProjectDir = sanitizePath(workDir)
const sourcePath = await writeSessionFile(sourceProjectDir, sessionId, [
makeSessionMetaEntry(workDir),
{
type: 'session-meta',
isMeta: true,
workDir,
repository,
timestamp: '2026-01-01T00:00:00.000Z',
},
makeWorktreeStateEntry(sessionId, worktreePath, {
originalCwd: workDir,
}),
makeFileHistorySnapshotEntry(firstUserId, {
'src/step.js': {
backupFileName: 'branch-source-step@v1',
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('branch this conversation', firstUserId),
cwd: workDir,
sessionId,
},
{
...makeAssistantToolUseEntry([
{ id: 'tool-1', name: 'Read', input: { path: 'src/step.js' } },
], firstUserId),
uuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeToolResultUserEntry('tool-1', 'first tool result', firstToolResultId, firstAssistantId, sessionId),
cwd: workDir,
},
makeContentReplacementEntry(sessionId, [
{ kind: 'tool-result', toolUseId: 'tool-1', replacement: 'preview-1' },
{ kind: 'tool-result', toolUseId: 'tool-2', replacement: 'preview-2' },
]),
makeFileHistorySnapshotEntry(laterUserId, {
'src/step.js': {
backupFileName: 'branch-source-step@v2',
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('later prompt', laterUserId),
parentUuid: firstToolResultId,
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('later reply', laterUserId),
uuid: laterAssistantId,
cwd: workDir,
sessionId,
},
])
const sourceBefore = await fs.readFile(sourcePath, 'utf-8')
const branch = await createSessionBranch({
sourceSessionId: sessionId,
sourceTranscriptPath: sourcePath,
targetMessageId: firstToolResultId,
title: 'Desktop branch',
sourceWorkDir: workDir,
sourceRepository: repository,
sourceWorktreeSession: {
originalCwd: workDir,
worktreePath,
worktreeName: 'desktop-main-12345678',
worktreeBranch: 'worktree-desktop-main-12345678',
originalBranch: 'main',
sessionId,
},
})
const branchMessages = await service.getSessionMessages(branch.sessionId)
expect(branchMessages.map((message) => message.id)).toEqual([
firstUserId,
firstAssistantId,
firstToolResultId,
])
expect(branch.title).toBe('Desktop branch (Branch)')
const launchInfo = await service.getSessionLaunchInfo(branch.sessionId)
expect(launchInfo).toMatchObject({
workDir,
repository,
worktreeSession: {
originalCwd: workDir,
worktreePath,
},
})
const branchEntries = parseJSONL<Record<string, unknown>>(await fs.readFile(branch.forkPath))
expect(branchEntries.some((entry) => (
entry.type === 'content-replacement' &&
entry.sessionId === branch.sessionId &&
Array.isArray(entry.replacements) &&
entry.replacements.length === 1 &&
(entry.replacements[0] as { toolUseId?: string }).toolUseId === 'tool-1'
))).toBe(true)
expect(branchEntries.some((entry) => (
entry.type === 'file-history-snapshot' &&
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
(entry.snapshot as { messageId?: string }).messageId === firstUserId
))).toBe(true)
expect(branchEntries.some((entry) => (
entry.type === 'file-history-snapshot' &&
typeof (entry.snapshot as { messageId?: string } | undefined)?.messageId === 'string' &&
(entry.snapshot as { messageId?: string }).messageId === laterUserId
))).toBe(false)
expect(branchEntries.some((entry) => (
entry.type === 'custom-title' &&
entry.customTitle === 'Desktop branch (Branch)'
))).toBe(true)
expect(branchEntries.filter((entry) => (
entry.type === 'user' ||
entry.type === 'assistant'
)).every((entry) => (
entry.sessionId === branch.sessionId &&
typeof (entry.forkedFrom as { sessionId?: string } | undefined)?.sessionId === 'string'
))).toBe(true)
const sourceAfter = await fs.readFile(sourcePath, 'utf-8')
expect(sourceAfter).toBe(sourceBefore)
})
})
// ============================================================================
@ -2731,6 +2919,147 @@ describe('Sessions API', () => {
})
})
it('POST /api/sessions/:id/branch should create a branched session up to the target message', async () => {
const sessionId = '11111111-1111-4111-8111-111111111111'
const workDir = path.join(tmpDir, 'branch-api-workdir')
const firstUserId = crypto.randomUUID()
const firstAssistantId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const secondAssistantId = crypto.randomUUID()
await writeSessionFile(sanitizePath(workDir), sessionId, [
{
type: 'session-meta',
isMeta: true,
workDir,
timestamp: '2026-01-01T00:00:00.000Z',
},
{
...makeUserEntry('first prompt', firstUserId),
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('first reply', firstUserId),
uuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeUserEntry('second prompt', secondUserId),
parentUuid: firstAssistantId,
cwd: workDir,
sessionId,
},
{
...makeAssistantEntry('second reply', secondUserId),
uuid: secondAssistantId,
cwd: workDir,
sessionId,
},
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetMessageId: firstAssistantId,
title: 'API branch',
}),
})
expect(res.status).toBe(201)
const body = await res.json() as {
sessionId: string
title: string
workDir: string
sourceSessionId: string
targetMessageId: string
}
expect(body).toMatchObject({
title: 'API branch (Branch)',
workDir,
sourceSessionId: sessionId,
targetMessageId: firstAssistantId,
})
const branchMessages = await service.getSessionMessages(body.sessionId)
expect(branchMessages.map((message) => message.id)).toEqual([
firstUserId,
firstAssistantId,
])
})
it('POST /api/sessions/:id/branch should reject sidechain targets', async () => {
const sessionId = '22222222-2222-4222-8222-222222222222'
const rootUserId = crypto.randomUUID()
const rootAssistantId = crypto.randomUUID()
const sidechainId = crypto.randomUUID()
await writeSessionFile('-tmp-api-branch-sidechain', sessionId, [
makeSnapshotEntry(),
{
...makeUserEntry('root prompt', rootUserId),
sessionId,
},
{
...makeAssistantEntry('root reply', rootUserId),
uuid: rootAssistantId,
sessionId,
},
{
...makeUserEntry('side question', sidechainId),
parentUuid: rootAssistantId,
isSidechain: true,
sessionId,
},
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: sidechainId }),
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({
error: 'BAD_REQUEST',
})
})
it('POST /api/sessions/:id/branch should validate request bodies and missing sessions', async () => {
const methodNotAllowedRes = await fetch(`${baseUrl}/api/sessions/33333333-3333-4333-8333-333333333333/branch`)
expect(methodNotAllowedRes.status).toBe(405)
const missingTargetRes = await fetch(`${baseUrl}/api/sessions/branch-missing-target/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(missingTargetRes.status).toBe(400)
const invalidJsonRes = await fetch(`${baseUrl}/api/sessions/branch-invalid-json/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{',
})
expect(invalidJsonRes.status).toBe(400)
const invalidTitleRes = await fetch(`${baseUrl}/api/sessions/44444444-4444-4444-8444-444444444444/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: 'message-1', title: 123 }),
})
expect(invalidTitleRes.status).toBe(400)
const missingSessionRes = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetMessageId: 'missing-target' }),
})
expect(missingSessionRes.status).toBe(404)
})
it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const firstUserId = crypto.randomUUID()

View File

@ -33,6 +33,10 @@ import {
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
import { SessionStore } from '../../../adapters/common/session-store.js'
import {
createSessionBranch,
SessionBranchingError,
} from '../../utils/sessionBranching.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
@ -124,6 +128,16 @@ export async function handleSessionsApi(
return await rewindSession(req, sessionId)
}
if (subResource === 'branch') {
if (req.method !== 'POST') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await branchSession(req, sessionId)
}
if (subResource === 'turn-checkpoints') {
if (req.method !== 'GET') {
return Response.json(
@ -710,6 +724,56 @@ async function rewindSession(req: Request, sessionId: string): Promise<Response>
return Response.json(result)
}
async function branchSession(req: Request, sessionId: string): Promise<Response> {
let body: { targetMessageId?: unknown; title?: unknown }
try {
body = (await req.json()) as { targetMessageId?: unknown; title?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
if (typeof body.targetMessageId !== 'string' || body.targetMessageId.trim().length === 0) {
throw ApiError.badRequest('targetMessageId (string) is required in request body')
}
if (body.title !== undefined && typeof body.title !== 'string') {
throw ApiError.badRequest('title must be a string')
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
if (!launchInfo) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
try {
const result = await createSessionBranch({
sourceSessionId: sessionId,
sourceTranscriptPath: launchInfo.filePath,
targetMessageId: body.targetMessageId.trim(),
title: body.title?.trim() || undefined,
sourceWorkDir: launchInfo.workDir,
sourceRepository: launchInfo.repository,
sourceWorktreeSession: launchInfo.worktreeSession,
})
return Response.json({
sessionId: result.sessionId,
title: result.title,
workDir: result.workDir ?? launchInfo.workDir,
sourceSessionId: sessionId,
targetMessageId: body.targetMessageId.trim(),
}, { status: 201 })
} catch (error) {
if (error instanceof SessionBranchingError) {
if (error.code === 'SOURCE_NOT_FOUND') {
throw ApiError.notFound(error.message)
}
throw ApiError.badRequest(error.message)
}
throw error
}
}
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
const checkpoints = await listSessionTurnCheckpoints(sessionId)
return Response.json({ checkpoints })

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test'
import type { SerializedMessage } from '../../types/logs.js'
import { deriveFirstPrompt, SessionBranchingError } from '../sessionBranching.js'
describe('sessionBranching', () => {
it('derives a compact branch title from multiline user text', () => {
const message = {
type: 'user',
message: {
role: 'user',
content: ' first line\n\nsecond line ',
},
} as SerializedMessage
expect(deriveFirstPrompt(message)).toBe('first line second line')
})
it('keeps branching errors identifiable by code', () => {
const error = new SessionBranchingError(
'INVALID_TARGET',
'targetMessageId must reference a main conversation message',
)
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('SessionBranchingError')
expect(error.code).toBe('INVALID_TARGET')
})
})

View File

@ -0,0 +1,554 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, readdir, writeFile } from 'fs/promises'
import * as path from 'node:path'
import type {
AttributionSnapshotMessage,
PersistedWorktreeSession,
SerializedMessage,
TranscriptMessage,
} from '../types/logs.js'
import type { ContentReplacementRecord } from './toolResultStorage.js'
import { parseJSONL } from './json.js'
import { buildConversationChain, loadTranscriptFile } from './sessionStorage.js'
import { jsonStringify } from './slowOperations.js'
import { escapeRegExp } from './stringUtils.js'
type SessionMetaEntry = {
type: 'session-meta'
isMeta?: boolean
workDir?: string
repository?: unknown
timestamp?: string
[key: string]: unknown
}
type WorktreeStateEntry = {
type: 'worktree-state'
sessionId: string
worktreeSession: PersistedWorktreeSession | null
[key: string]: unknown
}
type ModeEntry = {
type: 'mode'
sessionId: string
mode: string
[key: string]: unknown
}
type PrLinkEntry = {
type: 'pr-link'
sessionId: string
prNumber: number
prUrl: string
prRepository: string
timestamp: string
[key: string]: unknown
}
type FileHistorySnapshotEntry = {
type: 'file-history-snapshot'
messageId: string
snapshot?: {
messageId?: string
trackedFileBackups?: Record<string, unknown>
timestamp?: string
}
[key: string]: unknown
}
type ContentReplacementEntry = {
type: 'content-replacement'
sessionId: string
replacements: ContentReplacementRecord[]
[key: string]: unknown
}
type TranscriptEntry = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
}
}
type RawEntry =
| SessionMetaEntry
| WorktreeStateEntry
| ModeEntry
| PrLinkEntry
| FileHistorySnapshotEntry
| AttributionSnapshotMessage
| ContentReplacementEntry
| Record<string, unknown>
export type SessionBranchResult = {
sessionId: UUID
title: string
forkPath: string
workDir: string | null
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementRecord[]
}
export type CreateSessionBranchOptions = {
sourceSessionId: string
sourceTranscriptPath: string
title?: string
targetMessageId?: string
sourceWorkDir?: string | null
sourceRepository?: unknown
sourceWorktreeSession?: PersistedWorktreeSession | null
}
export class SessionBranchingError extends Error {
constructor(
readonly code:
| 'SOURCE_NOT_FOUND'
| 'INVALID_TARGET'
| 'NO_BRANCHABLE_MESSAGES',
message: string,
) {
super(message)
this.name = 'SessionBranchingError'
}
}
function isTranscriptEntry(entry: RawEntry): entry is TranscriptMessage {
return (
typeof entry === 'object' &&
entry !== null &&
(entry.type === 'user' ||
entry.type === 'assistant' ||
entry.type === 'attachment' ||
entry.type === 'system') &&
typeof (entry as TranscriptMessage).uuid === 'string'
)
}
function isSessionMetaEntry(entry: RawEntry): entry is SessionMetaEntry {
return entry.type === 'session-meta'
}
function isWorktreeStateEntry(entry: RawEntry): entry is WorktreeStateEntry {
return entry.type === 'worktree-state'
}
function isModeEntry(entry: RawEntry): entry is ModeEntry {
return entry.type === 'mode'
}
function isPrLinkEntry(entry: RawEntry): entry is PrLinkEntry {
return entry.type === 'pr-link'
}
function isFileHistorySnapshotEntry(entry: RawEntry): entry is FileHistorySnapshotEntry {
return entry.type === 'file-history-snapshot'
}
function isAttributionSnapshotEntry(entry: RawEntry): entry is AttributionSnapshotMessage {
return entry.type === 'attribution-snapshot'
}
function getSnapshotMessageId(entry: FileHistorySnapshotEntry): string | null {
if (typeof entry.snapshot?.messageId === 'string') return entry.snapshot.messageId
if (typeof entry.messageId === 'string') return entry.messageId
return null
}
function getAttributionMessageId(entry: AttributionSnapshotMessage): string | null {
return typeof entry.messageId === 'string' ? entry.messageId : null
}
function findLatestActiveLeaf(
messages: Map<UUID, TranscriptMessage>,
leafUuids: Set<UUID>,
): TranscriptMessage | undefined {
let latest: TranscriptMessage | undefined
let latestTimestamp = -Infinity
const candidates = leafUuids.size > 0
? [...leafUuids]
.map((uuid) => messages.get(uuid))
.filter((message): message is TranscriptMessage => !!message)
: [...messages.values()]
for (const message of candidates) {
if (message.isSidechain) continue
const timestamp = Date.parse(message.timestamp)
if (timestamp >= latestTimestamp) {
latest = message
latestTimestamp = timestamp
}
}
return latest
}
function extractToolResultIds(message: TranscriptMessage): string[] {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return []
}
return message.message.content.flatMap((block) => (
block.type === 'tool_result' && typeof block.tool_use_id === 'string'
? [block.tool_use_id]
: []
))
}
async function readRawEntries(filePath: string): Promise<RawEntry[]> {
const content = await readFile(filePath)
return parseJSONL<RawEntry>(content)
}
async function listExistingTitles(projectDirPath: string): Promise<string[]> {
let dirents: import('fs').Dirent[]
try {
dirents = await readdir(projectDirPath, { withFileTypes: true })
} catch {
return []
}
const titles: string[] = []
for (const dirent of dirents) {
if (!dirent.isFile() || !dirent.name.endsWith('.jsonl')) continue
try {
const entries = await readRawEntries(path.join(projectDirPath, dirent.name))
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (
entry?.type === 'custom-title' &&
typeof (entry as Record<string, unknown>).customTitle === 'string'
) {
titles.push((entry as Record<string, unknown>).customTitle as string)
break
}
}
} catch {
continue
}
}
return titles
}
export function deriveFirstPrompt(
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
): string {
const content = firstUserMessage?.message?.content
if (!content) return 'Branched conversation'
const raw =
typeof content === 'string'
? content
: content.find(
(block): block is { type: 'text'; text: string } =>
block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
)
}
export async function getUniqueForkName(
baseName: string,
projectDirPath: string,
): Promise<string> {
const existingTitles = new Set(
(await listExistingTitles(projectDirPath))
.map((title) => title.trim())
.filter(Boolean),
)
const candidateName = `${baseName} (Branch)`
if (!existingTitles.has(candidateName)) {
return candidateName
}
const usedNumbers = new Set<number>([1])
const forkNumberPattern = new RegExp(
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
)
for (const title of existingTitles) {
const match = title.match(forkNumberPattern)
if (!match) continue
usedNumbers.add(match[1] ? parseInt(match[1], 10) : 1)
}
let nextNumber = 2
while (usedNumbers.has(nextNumber)) {
nextNumber++
}
return `${baseName} (Branch ${nextNumber})`
}
function buildPreservedMetadataEntries(
sourceEntries: RawEntry[],
copiedMessageIds: Set<string>,
forkSessionId: UUID,
): RawEntry[] {
return sourceEntries.flatMap((entry) => {
if (isSessionMetaEntry(entry)) {
return [entry]
}
if (isWorktreeStateEntry(entry) && entry.worktreeSession) {
return [{
...entry,
sessionId: forkSessionId,
worktreeSession: {
...entry.worktreeSession,
sessionId: forkSessionId,
},
}]
}
if (isModeEntry(entry)) {
return [{ ...entry, sessionId: forkSessionId }]
}
if (isPrLinkEntry(entry)) {
return [{ ...entry, sessionId: forkSessionId }]
}
if (isFileHistorySnapshotEntry(entry)) {
const messageId = getSnapshotMessageId(entry)
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
}
if (isAttributionSnapshotEntry(entry)) {
const messageId = getAttributionMessageId(entry)
return messageId && copiedMessageIds.has(messageId) ? [entry] : []
}
return []
})
}
function ensureSyntheticSessionMeta(
preservedMetadataEntries: RawEntry[],
sourceWorkDir: string | null | undefined,
sourceRepository: unknown,
): RawEntry[] {
const hasSessionMeta = preservedMetadataEntries.some(isSessionMetaEntry)
if (hasSessionMeta || (!sourceWorkDir && sourceRepository === undefined)) {
return preservedMetadataEntries
}
return [{
type: 'session-meta',
isMeta: true,
...(sourceWorkDir ? { workDir: sourceWorkDir } : {}),
...(sourceRepository !== undefined ? { repository: sourceRepository } : {}),
timestamp: new Date().toISOString(),
}, ...preservedMetadataEntries]
}
function ensureSyntheticWorktreeState(
preservedMetadataEntries: RawEntry[],
forkSessionId: UUID,
sourceWorktreeSession: PersistedWorktreeSession | null | undefined,
): RawEntry[] {
const hasWorktreeState = preservedMetadataEntries.some(isWorktreeStateEntry)
if (hasWorktreeState || !sourceWorktreeSession) {
return preservedMetadataEntries
}
return [{
type: 'worktree-state',
sessionId: forkSessionId,
worktreeSession: {
...sourceWorktreeSession,
sessionId: forkSessionId,
},
}, ...preservedMetadataEntries]
}
export async function createSessionBranch(
options: CreateSessionBranchOptions,
): Promise<SessionBranchResult> {
const {
sourceSessionId,
sourceTranscriptPath,
title,
targetMessageId,
sourceWorkDir,
sourceRepository,
sourceWorktreeSession,
} = options
const projectDirPath = path.dirname(sourceTranscriptPath)
const sourceEntries = await readRawEntries(sourceTranscriptPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new SessionBranchingError(
'SOURCE_NOT_FOUND',
`Session not found: ${sourceSessionId}`,
)
}
throw error
})
const transcript = await loadTranscriptFile(sourceTranscriptPath, {
keepAllLeaves: true,
})
const activeLeaf = findLatestActiveLeaf(transcript.messages, transcript.leafUuids)
if (!activeLeaf) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No conversation to branch',
)
}
const activeChain = buildConversationChain(transcript.messages, activeLeaf)
.filter((message) => !message.isSidechain)
if (activeChain.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No conversation to branch',
)
}
const targetIndex = targetMessageId
? activeChain.findIndex((message) => message.uuid === targetMessageId)
: activeChain.length - 1
if (targetIndex < 0) {
throw new SessionBranchingError(
'INVALID_TARGET',
'targetMessageId must reference a main conversation message in the active chain',
)
}
const copiedMessages = activeChain.slice(0, targetIndex + 1)
if (copiedMessages.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No messages to branch',
)
}
const copiedMessageIds = new Set(copiedMessages.map((message) => message.uuid))
const sourceMessageEntriesById = new Map(
sourceEntries
.filter(
(entry): entry is TranscriptMessage =>
isTranscriptEntry(entry) &&
entry.isSidechain !== true &&
copiedMessageIds.has(entry.uuid),
)
.map((entry) => [entry.uuid, entry]),
)
const branchMessageEntries = copiedMessages.flatMap((message) => {
const entry = sourceMessageEntriesById.get(message.uuid)
return entry ? [entry] : []
})
if (branchMessageEntries.length === 0) {
throw new SessionBranchingError(
'NO_BRANCHABLE_MESSAGES',
'No messages to branch',
)
}
const copiedToolResultIds = new Set(
copiedMessages.flatMap((message) => extractToolResultIds(message)),
)
const contentReplacementRecords = (
transcript.contentReplacements.get(sourceSessionId as UUID) ?? []
).filter((record) => copiedToolResultIds.has(record.toolUseId))
const forkSessionId = randomUUID() as UUID
const forkPath = path.join(projectDirPath, `${forkSessionId}.jsonl`)
await mkdir(projectDirPath, { recursive: true, mode: 0o700 })
const serializedMessages: SerializedMessage[] = []
const messageLines: string[] = []
let parentUuid: UUID | null = null
for (const entry of branchMessageEntries) {
const forkedEntry: TranscriptEntry = {
...entry,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: sourceSessionId,
messageUuid: entry.uuid,
},
}
serializedMessages.push({
...entry,
sessionId: forkSessionId,
})
messageLines.push(jsonStringify(forkedEntry))
parentUuid = entry.uuid
}
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(
(message): message is Extract<SerializedMessage, { type: 'user' }> =>
message.type === 'user',
),
)
const effectiveTitle = await getUniqueForkName(title ?? firstPrompt, projectDirPath)
let metadataEntries = buildPreservedMetadataEntries(
sourceEntries,
copiedMessageIds,
forkSessionId,
)
metadataEntries = ensureSyntheticSessionMeta(
metadataEntries,
sourceWorkDir ?? null,
sourceRepository,
)
metadataEntries = ensureSyntheticWorktreeState(
metadataEntries,
forkSessionId,
sourceWorktreeSession,
)
const lines = [
...metadataEntries.map((entry) => jsonStringify(entry)),
...messageLines,
]
if (contentReplacementRecords.length > 0) {
lines.push(jsonStringify({
type: 'content-replacement',
sessionId: forkSessionId,
replacements: contentReplacementRecords,
} satisfies ContentReplacementEntry))
}
lines.push(jsonStringify({
type: 'custom-title',
sessionId: forkSessionId,
customTitle: effectiveTitle,
}))
await writeFile(forkPath, `${lines.join('\n')}\n`, {
encoding: 'utf8',
mode: 0o600,
})
const workDirFromMetadata =
(
metadataEntries.find(isSessionMetaEntry)?.workDir as string | undefined
) ??
sourceWorkDir ??
branchMessageEntries.findLast((entry) => typeof entry.cwd === 'string')?.cwd ??
null
return {
sessionId: forkSessionId,
title: effectiveTitle,
forkPath,
workDir: workDirFromMetadata,
serializedMessages,
contentReplacementRecords,
}
}