diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index eef2fe32..32c05509 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -2725,27 +2725,36 @@ describe('MessageList nested tool calls', () => {
})
})
- it('hides branch actions while the current session is still running', () => {
+ it('hides branch actions for the in-progress turn while preserving them for completed turns', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
streamingText: 'partial',
messages: [
+ // Completed turn — should still show branch buttons
{
id: 'local-user-1',
transcriptMessageId: 'transcript-user-1',
type: 'user_text',
- content: '从这里开始',
+ content: 'Starting point',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
- content: '这是完成的答复。',
+ content: 'This is a completed reply.',
timestamp: 2,
},
+ // In-progress turn (no response yet) — should NOT show branch buttons
+ {
+ id: 'local-user-2',
+ transcriptMessageId: 'transcript-user-2',
+ type: 'user_text',
+ content: 'A new question without a reply yet',
+ timestamp: 3,
+ },
],
}),
},
@@ -2753,7 +2762,19 @@ describe('MessageList nested tool calls', () => {
render()
- expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).toBeNull()
+ // Completed turn messages are still branchable
+ const branchButtons = screen.getAllByRole('button', { name: 'Fork a new conversation' })
+ expect(branchButtons).toHaveLength(2)
+
+ // The first user message should be branchable (has response)
+ expect(branchButtons[0]!.closest('[data-message-actions]')).toBeTruthy()
+ // The second user message should NOT appear in branch buttons
+ const secondUserBranchBtn = screen
+ .queryAllByRole('button', { name: 'Fork a new conversation' })
+ .filter((btn) =>
+ btn.closest('[data-message-actions]')?.textContent?.includes('A new question')
+ )
+ expect(secondUserBranchBtn).toHaveLength(0)
})
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 8f8156b0..253d80f0 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -1217,13 +1217,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
})
const [isVirtualScrollActive, setIsVirtualScrollActive] = useState(false)
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
- const branchActionsDisabled =
- isMemberSession ||
- chatState !== 'idle' ||
- streamingText.trim().length > 0 ||
- Boolean(activeThinkingId) ||
- Boolean(sessionState?.activeToolUseId) ||
- Boolean(sessionState?.activeToolName)
+ const branchActionsDisabled = isMemberSession
const hasCompactingDivider = messages.some((message) =>
message.type === 'compact_summary' && message.phase === 'compacting')
diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx
index 9860caeb..a3d78f0a 100644
--- a/desktop/src/components/layout/Sidebar.tsx
+++ b/desktop/src/components/layout/Sidebar.tsx
@@ -5,6 +5,7 @@ import { useUIStore } from '../../stores/uiStore'
import { useTranslation, type TranslationKey } from '../../i18n'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session'
+import { buildSessionTree } from '../../lib/sessionTree'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
@@ -834,6 +835,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const visibleItems = projectCollapsed
? []
: getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId)
+ const treeItems = buildSessionTree(visibleItems)
const hiddenCount = project.sessions.length - visibleItems.length
const groupIds = project.sessions.map((session) => session.id)
const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length
@@ -934,8 +936,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
className={hasInternalScroll ? 'max-h-[420px] overflow-y-auto pr-1' : undefined}
data-testid={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`}
>
- {visibleItems.map((session) => (
+ {treeItems.map((treeSession) => {
+ const { depth, isLastChild, ...session } = treeSession
+ const indentLeft = `${Math.min(depth, 3) * 1}rem`
+ return (
+ {depth > 0 && (
+
+ )}
{renamingId === session.id ? (
) : (
- ))}
+ )})}
{(hiddenCount > 0 || sessionsExpanded) && (
diff --git a/desktop/src/components/layout/SidebarTreeView.test.tsx b/desktop/src/components/layout/SidebarTreeView.test.tsx
new file mode 100644
index 00000000..8f4a2cc5
--- /dev/null
+++ b/desktop/src/components/layout/SidebarTreeView.test.tsx
@@ -0,0 +1,285 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import '@testing-library/jest-dom/vitest'
+
+// Mock localStorage with full API before any imports
+const localStorageStore = new Map()
+vi.stubGlobal('localStorage', {
+ getItem: vi.fn((key: string) => localStorageStore.get(key) ?? null),
+ setItem: vi.fn((key: string, value: string) => { localStorageStore.set(key, value) }),
+ removeItem: vi.fn((key: string) => { localStorageStore.delete(key) }),
+ clear: vi.fn(() => { localStorageStore.clear() }),
+ get length() { return localStorageStore.size },
+ key: vi.fn((index: number) => [...localStorageStore.keys()][index] ?? null),
+})
+
+const desktopUiPreferencesApiMock = vi.hoisted(() => ({
+ getPreferences: vi.fn(),
+ updateSidebarPreferences: vi.fn(),
+}))
+
+vi.mock('../../api/desktopUiPreferences', () => ({
+ desktopUiPreferencesApi: desktopUiPreferencesApiMock,
+}))
+
+const openTargetStoreMock = vi.hoisted(() => ({
+ ensureTargets: vi.fn(),
+ openTarget: vi.fn(),
+ targets: [{ id: 'finder', kind: 'file_manager', label: 'Finder', platform: 'darwin' }],
+}))
+
+vi.mock('../../stores/openTargetStore', () => ({
+ useOpenTargetStore: {
+ getState: () => openTargetStoreMock,
+ },
+}))
+
+vi.mock('../../i18n', () => ({
+ useTranslation: () => (key: string, params?: Record) => {
+ const translations: Record = {
+ 'sidebar.newSession': 'New Session',
+ 'sidebar.settings': 'Settings',
+ 'sidebar.searchPlaceholder': 'Search sessions',
+ 'sidebar.noSessions': 'No sessions',
+ 'sidebar.noMatching': 'No matching sessions',
+ 'sidebar.sessionListFailed': 'Session list failed',
+ 'sidebar.refreshSessions': 'Refresh sessions',
+ 'sidebar.projects': 'Projects',
+ 'sidebar.projectMenu': 'Project menu',
+ 'sidebar.newProject': 'New project',
+ 'sidebar.archiveAllChats': 'Archive all chats',
+ 'sidebar.organizeSidebar': 'Organize sidebar',
+ 'sidebar.sortCondition': 'Sort condition',
+ 'sidebar.organizeByProject': 'By project',
+ 'sidebar.organizeByRecentProject': 'Recent projects',
+ 'sidebar.organizeByTime': 'By time',
+ 'sidebar.sortByCreatedAt': 'Created time',
+ 'sidebar.sortByUpdatedAt': 'Updated time',
+ 'sidebar.newBlankProject': 'New blank project',
+ 'sidebar.useExistingFolder': 'Use existing folder',
+ 'sidebar.chooseProjectFolderUnavailable': 'Folder selection is only available in the desktop app.',
+ 'sidebar.projectActions': 'Project actions for {project}',
+ 'sidebar.pinProject': 'Pin Project',
+ 'sidebar.unpinProject': 'Unpin Project',
+ 'sidebar.openInFinder': 'Open in Finder',
+ 'sidebar.openInFinderFailed': 'Could not open the project in Finder.',
+ 'sidebar.openInFinderUnavailable': 'No file manager is available.',
+ 'sidebar.hideProjectFromSidebar': 'Hide from Sidebar',
+ 'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
+ 'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
+ 'sidebar.projectHidden': '{project} was hidden from the sidebar.',
+ 'sidebar.newSessionInProject': 'New session in {project}',
+ 'sidebar.showMoreSessions': 'Expand display',
+ 'sidebar.showFewerSessions': 'Collapse display',
+ 'sidebar.expandProject': 'Expand {project}',
+ 'sidebar.collapseProject': 'Collapse {project}',
+ 'sidebar.worktree': 'worktree',
+ 'sidebar.sessionRunning': 'Session running',
+ 'common.retry': 'Retry',
+ 'common.loading': 'Loading...',
+ 'common.cancel': 'Cancel',
+ 'common.delete': 'Delete',
+ 'common.rename': 'Rename',
+ 'sidebar.timeGroup.today': 'Today',
+ 'sidebar.timeGroup.yesterday': 'Yesterday',
+ 'sidebar.timeGroup.last7days': 'Last 7 Days',
+ 'sidebar.timeGroup.last30days': 'Last 30 Days',
+ 'sidebar.timeGroup.older': 'Older',
+ 'sidebar.missingDir': 'Missing',
+ 'sidebar.confirmDelete': 'Delete this session?',
+ 'sidebar.batchManage': 'Batch manage',
+ 'sidebar.batchSelectedCount': '{count} selected',
+ 'sidebar.batchSelectAll': 'Select all',
+ 'sidebar.batchDeselectAll': 'Deselect all',
+ 'sidebar.batchSelectGroup': 'Select {group}',
+ 'sidebar.batchDeleteSelected': 'Delete selected ({count})',
+ 'sidebar.batchDeleteConfirm': 'Delete {count} sessions?',
+ 'sidebar.batchDeleteConfirmBody': 'The following sessions will be deleted:',
+ 'sidebar.batchDeleteMore': '...and {count} more',
+ 'sidebar.batchExit': 'Cancel batch mode',
+ 'sidebar.batchDeleteSucceeded': 'Deleted {count} sessions.',
+ 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.',
+ 'sidebar.collapse': 'Collapse sidebar',
+ 'sidebar.expand': 'Expand sidebar',
+ 'session.lastUpdated': 'last updated {time}',
+ 'session.timeJustNow': 'just now',
+ 'session.timeMinutes': '{n}m ago',
+ 'session.timeHours': '{n}h ago',
+ 'session.timeDays': '{n}d ago',
+ }
+ let text = translations[key] ?? key
+ for (const [name, value] of Object.entries(params ?? {})) {
+ text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value))
+ }
+ return text
+ },
+}))
+
+import { Sidebar } from './Sidebar'
+import { useChatStore } from '../../stores/chatStore'
+import { useSessionStore } from '../../stores/sessionStore'
+import { useTabStore } from '../../stores/tabStore'
+import { useUIStore } from '../../stores/uiStore'
+import type { SessionListItem } from '../../types/session'
+
+function makeSession(
+ id: string,
+ title: string,
+ projectRoot: string,
+ sourceSessionId?: string,
+ sourceMessageId?: string,
+): SessionListItem {
+ return {
+ id,
+ title,
+ createdAt: new Date().toISOString(),
+ modifiedAt: new Date().toISOString(),
+ messageCount: 1,
+ projectPath: projectRoot,
+ projectRoot,
+ workDir: projectRoot,
+ workDirExists: true,
+ sourceSessionId,
+ sourceMessageId,
+ }
+}
+
+describe('Sidebar forked session tree view', () => {
+ const connectToSession = vi.fn()
+ const disconnectSession = vi.fn()
+ const fetchSessions = vi.fn()
+ const createSession = vi.fn()
+ const deleteSession = vi.fn()
+ const deleteSessions = vi.fn()
+ const addToast = vi.fn()
+ const renameSession = vi.fn()
+
+ beforeEach(() => {
+ localStorageStore.clear()
+ connectToSession.mockReset()
+ disconnectSession.mockReset()
+ fetchSessions.mockReset()
+ addToast.mockReset()
+ desktopUiPreferencesApiMock.getPreferences.mockReset()
+ desktopUiPreferencesApiMock.updateSidebarPreferences.mockReset()
+ desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable'))
+ desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({
+ ok: true,
+ preferences: {
+ schemaVersion: 1,
+ sidebar: {
+ projectOrder: [],
+ pinnedProjects: [],
+ hiddenProjects: [],
+ projectOrganization: 'recentProject',
+ projectSortBy: 'updatedAt',
+ },
+ },
+ })
+
+ useTabStore.setState({ tabs: [], activeTabId: null })
+ useSessionStore.setState({
+ sessions: [],
+ activeSessionId: null,
+ isLoading: false,
+ error: null,
+ isBatchMode: false,
+ selectedSessionIds: new Set(),
+ fetchSessions,
+ createSession,
+ deleteSession,
+ deleteSessions,
+ renameSession,
+ })
+ useChatStore.setState({
+ connectToSession,
+ disconnectSession,
+ } as Partial>)
+ useUIStore.setState({
+ sidebarOpen: true,
+ addToast,
+ } as Partial>)
+ })
+
+ afterEach(() => {
+ cleanup()
+ useTabStore.setState({ tabs: [], activeTabId: null })
+ localStorageStore.clear()
+ })
+
+ it('renders a basic session in the sidebar', async () => {
+ useSessionStore.setState({
+ sessions: [
+ makeSession('basic-1', 'Basic Session', '/workspace/repo'),
+ ],
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Basic Session')).toBeInTheDocument()
+ })
+ })
+
+ it('applies left indent to forked child sessions', async () => {
+ const sessions = [
+ makeSession('root-1', 'Root Session', '/workspace/repo'),
+ makeSession('fork-1', 'Forked child', '/workspace/repo', 'root-1', 'msg-1'),
+ makeSession('fork-2', 'Another child', '/workspace/repo', 'root-1', 'msg-2'),
+ ]
+ useSessionStore.setState({ sessions })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Root Session')).toBeInTheDocument()
+ })
+
+ // Verify forked sessions exist in the DOM
+ expect(screen.getByText('Forked child')).toBeInTheDocument()
+ expect(screen.getByText('Another child')).toBeInTheDocument()
+
+ // Fork icons (GitBranch SVGs) should appear in forked session buttons
+ const forkBtn = screen.getByText('Forked child').closest('button')!
+ const rootBtn = screen.getByText('Root Session').closest('button')!
+
+ const forkIcons = forkBtn.querySelectorAll('svg')
+ const rootIcons = rootBtn.querySelectorAll('svg')
+
+ // Forked session should have at least one more SVG (the GitBranch icon)
+ // than the root session (which only has the checkmark/batch icon)
+ expect(forkIcons.length).toBeGreaterThan(rootIcons.length)
+ })
+
+ it('shows fork icon for deep nesting (fork of a fork)', async () => {
+ const sessions = [
+ makeSession('root', 'Level 0', '/workspace/repo'),
+ makeSession('fork-l1', 'Level 1', '/workspace/repo', 'root', 'msg-a'),
+ makeSession('fork-l2', 'Level 2', '/workspace/repo', 'fork-l1', 'msg-b'),
+ ]
+ useSessionStore.setState({ sessions })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Level 0')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Level 1')).toBeInTheDocument()
+ expect(screen.getByText('Level 2')).toBeInTheDocument()
+
+ // All three sessions should be rendered
+ const l0Btn = screen.getByText('Level 0').closest('button')!
+ const l1Btn = screen.getByText('Level 1').closest('button')!
+ const l2Btn = screen.getByText('Level 2').closest('button')!
+
+ // Level 0 (root) should have no fork icon
+ // Level 1 and Level 2 should have fork icons (GitBranch SVGs)
+ const l0SvgCount = l0Btn.querySelectorAll('svg').length
+ const l1SvgCount = l1Btn.querySelectorAll('svg').length
+ const l2SvgCount = l2Btn.querySelectorAll('svg').length
+
+ expect(l1SvgCount).toBeGreaterThan(l0SvgCount)
+ expect(l2SvgCount).toBeGreaterThan(l0SvgCount)
+ })
+})
diff --git a/desktop/src/lib/sessionTree.test.ts b/desktop/src/lib/sessionTree.test.ts
new file mode 100644
index 00000000..06c8ca90
--- /dev/null
+++ b/desktop/src/lib/sessionTree.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import { buildSessionTree } from './sessionTree'
+import type { SessionListItem } from '../types/session'
+
+function makeSession(
+ id: string,
+ title: string,
+ sourceSessionId?: string,
+ sourceMessageId?: string,
+): SessionListItem {
+ return {
+ id,
+ title,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ modifiedAt: '2026-01-01T00:00:00.000Z',
+ messageCount: 1,
+ projectPath: '/workspace/repo',
+ projectRoot: '/workspace/repo',
+ workDir: '/workspace/repo',
+ workDirExists: true,
+ sourceSessionId,
+ sourceMessageId,
+ }
+}
+
+describe('buildSessionTree', () => {
+ it('returns flat list unchanged when no sessions have parents', () => {
+ const sessions = [
+ makeSession('s1', 'Session 1'),
+ makeSession('s2', 'Session 2'),
+ makeSession('s3', 'Session 3'),
+ ]
+ const tree = buildSessionTree(sessions)
+ expect(tree.map((t) => t.id)).toEqual(['s1', 's2', 's3'])
+ expect(tree.every((t) => t.depth === 0)).toBe(true)
+ })
+
+ it('nests forked sessions under their parent', () => {
+ const sessions = [
+ makeSession('parent', 'Parent Session'),
+ makeSession('child', 'Forked Child', 'parent', 'msg-1'),
+ makeSession('sibling', 'Sibling Session'),
+ ]
+ const tree = buildSessionTree(sessions)
+ expect(tree.map((t) => ({ id: t.id, depth: t.depth }))).toEqual([
+ { id: 'parent', depth: 0 },
+ { id: 'child', depth: 1 },
+ { id: 'sibling', depth: 0 },
+ ])
+ })
+
+ it('supports multi-level nesting (fork of a fork)', () => {
+ const sessions = [
+ makeSession('root', 'Root'),
+ makeSession('fork1', 'Fork Level 1', 'root', 'msg-a'),
+ makeSession('fork2', 'Fork Level 2', 'fork1', 'msg-b'),
+ ]
+ const tree = buildSessionTree(sessions)
+ expect(tree.map((t) => ({ id: t.id, depth: t.depth }))).toEqual([
+ { id: 'root', depth: 0 },
+ { id: 'fork1', depth: 1 },
+ { id: 'fork2', depth: 2 },
+ ])
+ })
+
+ it('groups multiple children of the same parent', () => {
+ const sessions = [
+ makeSession('root', 'Root'),
+ makeSession('child-a', 'Child A', 'root', 'msg-1'),
+ makeSession('child-b', 'Child B', 'root', 'msg-2'),
+ ]
+ const tree = buildSessionTree(sessions)
+ expect(tree.map((t) => ({ id: t.id, depth: t.depth }))).toEqual([
+ { id: 'root', depth: 0 },
+ { id: 'child-a', depth: 1 },
+ { id: 'child-b', depth: 1 },
+ ])
+ })
+
+ it('handles sessions whose parent is not in the list', () => {
+ // This can happen when a parent session is deleted
+ const sessions = [
+ makeSession('orphan', 'Orphan Fork', 'nonexistent-parent', 'msg-1'),
+ ]
+ const tree = buildSessionTree(sessions)
+ // Orphan sessions should be rendered at root level
+ expect(tree.map((t) => ({ id: t.id, depth: t.depth }))).toEqual([
+ { id: 'orphan', depth: 0 },
+ ])
+ })
+
+ it('marks the last child of a parent for visual connecting line', () => {
+ const sessions = [
+ makeSession('root', 'Root'),
+ makeSession('child-a', 'Child A', 'root', 'msg-1'),
+ makeSession('child-b', 'Child B', 'root', 'msg-2'),
+ makeSession('other', 'Other'),
+ ]
+ const tree = buildSessionTree(sessions)
+
+ const childA = tree.find((t) => t.id === 'child-a')!
+ const childB = tree.find((t) => t.id === 'child-b')!
+ const other = tree.find((t) => t.id === 'other')!
+
+ expect(childA.isLastChild).toBe(false)
+ expect(childB.isLastChild).toBe(true)
+ expect(other.isLastChild).toBe(false)
+ })
+
+ it('preserves original sort order within same depth level', () => {
+ const sessions = [
+ makeSession('a', 'A'),
+ makeSession('b-child', 'B Child', 'a', 'msg-1'),
+ makeSession('c', 'C'),
+ makeSession('d', 'D'),
+ ]
+ const tree = buildSessionTree(sessions)
+ expect(tree.map((t) => t.id)).toEqual(['a', 'b-child', 'c', 'd'])
+ })
+})
diff --git a/desktop/src/lib/sessionTree.ts b/desktop/src/lib/sessionTree.ts
new file mode 100644
index 00000000..2a224849
--- /dev/null
+++ b/desktop/src/lib/sessionTree.ts
@@ -0,0 +1,70 @@
+import type { SessionListItem } from '../types/session'
+
+export type TreeSession = SessionListItem & {
+ depth: number
+ isLastChild: boolean
+}
+
+/**
+ * Build a tree-flattened list from sessions with parent-child relationships.
+ * Child sessions (those with sourceSessionId) are inserted after their parent
+ * with increased depth. The input order is preserved at each depth level.
+ */
+export function buildSessionTree(sessions: SessionListItem[]): TreeSession[] {
+ // Build a lookup of parent → children
+ const childrenMap = new Map()
+ const sessionMap = new Map()
+
+ for (const session of sessions) {
+ const treeSession: TreeSession = {
+ ...session,
+ depth: 0,
+ isLastChild: false,
+ }
+ sessionMap.set(session.id, treeSession)
+
+ if (session.sourceSessionId) {
+ const siblings = childrenMap.get(session.sourceSessionId) || []
+ siblings.push(treeSession)
+ childrenMap.set(session.sourceSessionId, siblings)
+ }
+ }
+
+ // Mark the last child in each sibling group
+ for (const children of childrenMap.values()) {
+ if (children.length > 0) {
+ children[children.length - 1]!.isLastChild = true
+ }
+ }
+
+ // Flatten in original order with nesting
+ const result: TreeSession[] = []
+
+ function insertWithChildren(item: TreeSession, depth: number) {
+ item.depth = depth
+ result.push(item)
+
+ const children = childrenMap.get(item.id)
+ if (children) {
+ for (const child of children) {
+ insertWithChildren(child, depth + 1)
+ }
+ }
+ }
+
+ // Collect top-level items in original order, skipping items that have a parent in the list.
+ // Only parents that are actually in the session list should prevent their children from being top-level.
+ const visibleParentIds = new Set(
+ [...childrenMap.keys()].filter((id) => sessionMap.has(id))
+ )
+ for (const session of sessions) {
+ const treeSession = sessionMap.get(session.id)!
+ // Only insert at top level if this session is not a child of another visible session
+ if (!session.sourceSessionId || !visibleParentIds.has(session.sourceSessionId)) {
+ // If the parent is not in this list (orphan), treat as root
+ insertWithChildren(treeSession, 0)
+ }
+ }
+
+ return result
+}
diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts
index 1eb6fc8d..6fafe094 100644
--- a/desktop/src/stores/sessionStore.test.ts
+++ b/desktop/src/stores/sessionStore.test.ts
@@ -264,4 +264,42 @@ describe('sessionStore', () => {
workDir: '/workspace/repo/branches/session-branch-existing',
})
})
+
+ it('preserves sourceSessionId and sourceMessageId in the optimistic branch session', async () => {
+ branchMock.mockResolvedValue({
+ sessionId: 'session-branch-2',
+ title: 'Fork exploration',
+ workDir: '/workspace/repo/branches/session-branch-2',
+ sourceSessionId: 'session-source-2',
+ targetMessageId: 'transcript-message-2',
+ })
+ listMock.mockImplementation(() => new Promise(() => {}))
+ useSessionStore.setState({
+ sessions: [{
+ id: 'session-source-2',
+ title: 'Source session',
+ createdAt: '2026-05-19T00:00:00.000Z',
+ modifiedAt: '2026-05-19T00:00:00.000Z',
+ messageCount: 6,
+ projectPath: '/workspace/repo',
+ projectRoot: '/workspace/repo',
+ workDir: '/workspace/repo',
+ workDirExists: true,
+ }],
+ })
+
+ const result = await useSessionStore.getState().branchSession(
+ 'session-source-2',
+ 'transcript-message-2',
+ { title: 'Fork exploration' },
+ )
+
+ expect(result.sessionId).toBe('session-branch-2')
+
+ const forkedSession = useSessionStore.getState().sessions.find((s) => s.id === 'session-branch-2')
+ expect(forkedSession).toBeDefined()
+ expect(forkedSession!.sourceSessionId).toBe('session-source-2')
+ expect(forkedSession!.sourceMessageId).toBe('transcript-message-2')
+ expect(forkedSession!.title).toBe('Fork exploration')
+ })
})
diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts
index e987bb04..496acf0d 100644
--- a/desktop/src/stores/sessionStore.ts
+++ b/desktop/src/stores/sessionStore.ts
@@ -125,6 +125,8 @@ export const useSessionStore = create((set, get) => ({
projectRoot: sourceSession?.projectRoot ?? sourceSession?.workDir ?? result.workDir ?? null,
workDir: result.workDir ?? sourceSession?.workDir ?? null,
workDirExists: true,
+ sourceSessionId: result.sourceSessionId ?? sourceSessionId,
+ sourceMessageId: result.targetMessageId ?? targetMessageId,
}
set((state) => ({
diff --git a/desktop/src/types/session.ts b/desktop/src/types/session.ts
index d094ae6e..8e86ce01 100644
--- a/desktop/src/types/session.ts
+++ b/desktop/src/types/session.ts
@@ -10,6 +10,8 @@ export type SessionListItem = {
projectRoot?: string | null
workDir: string | null
workDirExists: boolean
+ sourceSessionId?: string
+ sourceMessageId?: string
}
export type MessageEntry = {
diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts
index dc259673..f7d05bd7 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -581,6 +581,39 @@ describe('SessionService', () => {
expect(resultA.sessions[0]!.id).toBe(id1)
})
+ it('should expose sourceSessionId and sourceMessageId for forked sessions', async () => {
+ const sourceSessionId = 'parent-session-0001-0000-0000-000000000000'
+ const forkSessionId = 'forked-session-0001-0000-0000-000000000000'
+ const forkMessageUuid = crypto.randomUUID()
+
+ await writeSessionFile('-tmp-project', forkSessionId, [
+ makeSnapshotEntry(),
+ {
+ parentUuid: null,
+ isSidechain: false,
+ type: 'user',
+ message: { role: 'user', content: 'Forked from parent' },
+ uuid: crypto.randomUUID(),
+ sessionId: forkSessionId,
+ timestamp: new Date().toISOString(),
+ cwd: '/tmp/project',
+ forkedFrom: {
+ sessionId: sourceSessionId,
+ messageUuid: forkMessageUuid,
+ },
+ },
+ makeAssistantEntry('Response in fork'),
+ ])
+
+ const result = await service.listSessions()
+ expect(result.total).toBe(1)
+
+ const session = result.sessions[0]!
+ expect(session.id).toBe(forkSessionId)
+ expect(session.sourceSessionId).toBe(sourceSessionId)
+ expect(session.sourceMessageId).toBe(forkMessageUuid)
+ })
+
// --------------------------------------------------------------------------
// getSession
// --------------------------------------------------------------------------
diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts
index 3bd84a62..d449c930 100644
--- a/src/server/services/sessionService.ts
+++ b/src/server/services/sessionService.ts
@@ -42,6 +42,8 @@ export type SessionListItem = {
projectRoot: string | null
workDir: string | null
workDirExists: boolean
+ sourceSessionId?: string
+ sourceMessageId?: string
}
export type DeleteSessionFailure = {
@@ -1321,6 +1323,20 @@ export class SessionService {
}
}
+ // Extract fork parent relationship from transcript entries
+ let sourceSessionId: string | undefined
+ let sourceMessageId: string | undefined
+ for (const e of entries) {
+ const fork = (e as Record).forkedFrom as
+ | { sessionId?: string; messageUuid?: string }
+ | undefined
+ if (fork?.sessionId) {
+ sourceSessionId = fork.sessionId
+ sourceMessageId = fork.messageUuid
+ break
+ }
+ }
+
return {
id: sessionId,
title,
@@ -1331,6 +1347,8 @@ export class SessionService {
projectRoot,
workDir,
workDirExists,
+ sourceSessionId,
+ sourceMessageId,
}
} catch {
// Skip unreadable files