Revert "fix: session branch visibility and parent-child tree display (#561)"

This reverts commit 1b52da3587325715f06948d5d526f71b00c7d9fb.

The session parent-child tree display in sidebar is visually unappealing
and unnecessary. The branch button behavior during AI response is also
intentional - users should not switch branches mid-conversation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 19:26:15 +08:00
parent fe8aac6b83
commit 6e1766abae
11 changed files with 13 additions and 620 deletions

View File

@ -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(<MessageList />)
// 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 () => {

View File

@ -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')

View File

@ -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) => (
<div key={session.id} className="relative mb-0.5 last:mb-0">
{depth > 0 && (
<div
className="absolute top-0 border-l-2 border-[var(--color-border)]"
style={{
left: `calc(${indentLeft} - 0.5rem)`,
height: isLastChild ? '50%' : '100%',
}}
aria-hidden="true"
/>
)}
{renamingId === session.id ? (
<input
autoFocus
@ -965,7 +950,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}
}}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
style={{ paddingLeft: `calc(0.75rem + ${indentLeft})` }}
/>
) : (
<button
@ -988,17 +972,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
: 'sidebar-session-row--idle text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
style={{ paddingLeft: `calc(0.625rem + ${indentLeft})` }}
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
>
<span className="flex min-w-0 items-center gap-2">
{depth > 0 && (
<GitBranch
size={13}
className="flex-shrink-0 text-[var(--color-text-tertiary)] opacity-50"
aria-hidden="true"
/>
)}
{isBatchMode ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
@ -1032,7 +1008,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
</button>
)}
</div>
)})}
))}
</div>
{(hiddenCount > 0 || sessionsExpanded) && (
<div className="mt-2 flex justify-start px-2.5">

View File

@ -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<string, string>()
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<string, string | number>) => {
const translations: Record<string, string> = {
'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<ReturnType<typeof useChatStore.getState>>)
useUIStore.setState({
sidebarOpen: true,
addToast,
} as Partial<ReturnType<typeof useUIStore.getState>>)
})
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(<Sidebar />)
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(<Sidebar />)
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(<Sidebar />)
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)
})
})

View File

@ -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'])
})
})

View File

@ -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<string, TreeSession[]>()
const sessionMap = new Map<string, TreeSession>()
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
}

View File

@ -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')
})
})

View File

@ -125,8 +125,6 @@ export const useSessionStore = create<SessionStore>((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) => ({

View File

@ -10,8 +10,6 @@ export type SessionListItem = {
projectRoot?: string | null
workDir: string | null
workDirExists: boolean
sourceSessionId?: string
sourceMessageId?: string
}
export type MessageEntry = {

View File

@ -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
// --------------------------------------------------------------------------

View File

@ -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<string, unknown>).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