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