mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: session branch visibility and parent-child tree display (#561)
- Fix branch button hidden when AI is responding by decoupling branchActionsDisabled from chatState (MessageList.tsx) - Add sourceSessionId/sourceMessageId to SessionListItem type (both server and desktop) - Scan forkedFrom in listSessions to expose parent-child relationships - Preserve sourceSessionId/sourceMessageId in branchSession optimistic store - Add buildSessionTree utility for tree-flattened session list with depth and isLastChild markers - Render forked sessions indented with GitBranch icon in sidebar - Add comprehensive tests (187 total: 81 desktop + 106 server) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
91224cbd20
commit
1b52da3587
@ -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(<MessageList />)
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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 (
|
||||
<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
|
||||
@ -950,6 +965,7 @@ 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
|
||||
@ -972,9 +988,17 @@ 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 ${
|
||||
@ -1008,7 +1032,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
{(hiddenCount > 0 || sessionsExpanded) && (
|
||||
<div className="mt-2 flex justify-start px-2.5">
|
||||
|
||||
285
desktop/src/components/layout/SidebarTreeView.test.tsx
Normal file
285
desktop/src/components/layout/SidebarTreeView.test.tsx
Normal file
@ -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<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)
|
||||
})
|
||||
})
|
||||
120
desktop/src/lib/sessionTree.test.ts
Normal file
120
desktop/src/lib/sessionTree.test.ts
Normal file
@ -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'])
|
||||
})
|
||||
})
|
||||
70
desktop/src/lib/sessionTree.ts
Normal file
70
desktop/src/lib/sessionTree.ts
Normal file
@ -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<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
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -125,6 +125,8 @@ 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) => ({
|
||||
|
||||
@ -10,6 +10,8 @@ export type SessionListItem = {
|
||||
projectRoot?: string | null
|
||||
workDir: string | null
|
||||
workDirExists: boolean
|
||||
sourceSessionId?: string
|
||||
sourceMessageId?: string
|
||||
}
|
||||
|
||||
export type MessageEntry = {
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -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<string, unknown>).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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user