diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 33cd7827..96322fde 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -2856,36 +2856,27 @@ describe('MessageList nested tool calls', () => {
})
})
- it('hides branch actions for the in-progress turn while preserving them for completed turns', () => {
+ it('hides branch actions while the current session is still running', () => {
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: 'Starting point',
+ content: '从这里开始',
timestamp: 1,
},
{
id: 'local-assistant-1',
transcriptMessageId: 'transcript-assistant-1',
type: 'assistant_text',
- content: 'This is a completed reply.',
+ content: '这是完成的答复。',
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,
- },
],
}),
},
@@ -2893,19 +2884,7 @@ describe('MessageList nested tool calls', () => {
render()
- // 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)
+ expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).toBeNull()
})
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 d301f755..4c20bcc6 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -1265,7 +1265,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
})
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
- const branchActionsDisabled = isMemberSession
+ const branchActionsDisabled =
+ isMemberSession ||
+ chatState !== 'idle' ||
+ streamingText.trim().length > 0 ||
+ Boolean(activeThinkingId) ||
+ Boolean(sessionState?.activeToolUseId) ||
+ Boolean(sessionState?.activeToolName)
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 a3d78f0a..9860caeb 100644
--- a/desktop/src/components/layout/Sidebar.tsx
+++ b/desktop/src/components/layout/Sidebar.tsx
@@ -5,7 +5,6 @@ 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'
@@ -835,7 +834,6 @@ 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
@@ -936,21 +934,8 @@ 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)}`}
>
- {treeItems.map((treeSession) => {
- const { depth, isLastChild, ...session } = treeSession
- const indentLeft = `${Math.min(depth, 3) * 1}rem`
- return (
+ {visibleItems.map((session) => (
- {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
deleted file mode 100644
index 8f4a2cc5..00000000
--- a/desktop/src/components/layout/SidebarTreeView.test.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-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
deleted file mode 100644
index 06c8ca90..00000000
--- a/desktop/src/lib/sessionTree.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-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
deleted file mode 100644
index 2a224849..00000000
--- a/desktop/src/lib/sessionTree.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-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 6fafe094..1eb6fc8d 100644
--- a/desktop/src/stores/sessionStore.test.ts
+++ b/desktop/src/stores/sessionStore.test.ts
@@ -264,42 +264,4 @@ 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 496acf0d..e987bb04 100644
--- a/desktop/src/stores/sessionStore.ts
+++ b/desktop/src/stores/sessionStore.ts
@@ -125,8 +125,6 @@ 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 8e86ce01..d094ae6e 100644
--- a/desktop/src/types/session.ts
+++ b/desktop/src/types/session.ts
@@ -10,8 +10,6 @@ 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 f7d05bd7..dc259673 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -581,39 +581,6 @@ 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 9e9dd139..03e46a28 100644
--- a/src/server/services/sessionService.ts
+++ b/src/server/services/sessionService.ts
@@ -43,8 +43,6 @@ export type SessionListItem = {
projectRoot: string | null
workDir: string | null
workDirExists: boolean
- sourceSessionId?: string
- sourceMessageId?: string
}
export type DeleteSessionFailure = {
@@ -1329,20 +1327,6 @@ 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,
@@ -1353,8 +1337,6 @@ export class SessionService {
projectRoot,
workDir,
workDirExists,
- sourceSessionId,
- sourceMessageId,
}
} catch {
// Skip unreadable files