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 && ( + - ))} + )})}
{(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