mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +08:00
Merge pull request #584 from moyu12-ae/fix/561-session-branch-visibility-and-tree-view
fix: session branch visibility and parent-child tree display (#561)
This commit is contained in:
commit
98a0b5a7dc
@ -2768,27 +2768,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({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
[ACTIVE_TAB]: makeSessionState({
|
[ACTIVE_TAB]: makeSessionState({
|
||||||
chatState: 'streaming',
|
chatState: 'streaming',
|
||||||
streamingText: 'partial',
|
streamingText: 'partial',
|
||||||
messages: [
|
messages: [
|
||||||
|
// Completed turn — should still show branch buttons
|
||||||
{
|
{
|
||||||
id: 'local-user-1',
|
id: 'local-user-1',
|
||||||
transcriptMessageId: 'transcript-user-1',
|
transcriptMessageId: 'transcript-user-1',
|
||||||
type: 'user_text',
|
type: 'user_text',
|
||||||
content: '从这里开始',
|
content: 'Starting point',
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'local-assistant-1',
|
id: 'local-assistant-1',
|
||||||
transcriptMessageId: 'transcript-assistant-1',
|
transcriptMessageId: 'transcript-assistant-1',
|
||||||
type: 'assistant_text',
|
type: 'assistant_text',
|
||||||
content: '这是完成的答复。',
|
content: 'This is a completed reply.',
|
||||||
timestamp: 2,
|
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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -2796,7 +2805,19 @@ describe('MessageList nested tool calls', () => {
|
|||||||
|
|
||||||
render(<MessageList />)
|
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 () => {
|
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
|
||||||
|
|||||||
@ -1261,13 +1261,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
|||||||
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
||||||
})
|
})
|
||||||
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
|
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
|
||||||
const branchActionsDisabled =
|
const branchActionsDisabled = isMemberSession
|
||||||
isMemberSession ||
|
|
||||||
chatState !== 'idle' ||
|
|
||||||
streamingText.trim().length > 0 ||
|
|
||||||
Boolean(activeThinkingId) ||
|
|
||||||
Boolean(sessionState?.activeToolUseId) ||
|
|
||||||
Boolean(sessionState?.activeToolName)
|
|
||||||
const hasCompactingDivider = messages.some((message) =>
|
const hasCompactingDivider = messages.some((message) =>
|
||||||
message.type === 'compact_summary' && message.phase === 'compacting')
|
message.type === 'compact_summary' && message.phase === 'compacting')
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useUIStore } from '../../stores/uiStore'
|
|||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||||
import type { SessionListItem } from '../../types/session'
|
import type { SessionListItem } from '../../types/session'
|
||||||
|
import { buildSessionTree } from '../../lib/sessionTree'
|
||||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||||
@ -834,6 +835,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
const visibleItems = projectCollapsed
|
const visibleItems = projectCollapsed
|
||||||
? []
|
? []
|
||||||
: getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId)
|
: getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId)
|
||||||
|
const treeItems = buildSessionTree(visibleItems)
|
||||||
const hiddenCount = project.sessions.length - visibleItems.length
|
const hiddenCount = project.sessions.length - visibleItems.length
|
||||||
const groupIds = project.sessions.map((session) => session.id)
|
const groupIds = project.sessions.map((session) => session.id)
|
||||||
const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length
|
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}
|
className={hasInternalScroll ? 'max-h-[420px] overflow-y-auto pr-1' : undefined}
|
||||||
data-testid={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`}
|
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">
|
<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 ? (
|
{renamingId === session.id ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
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"
|
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
|
<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)]'
|
: '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}
|
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<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 ? (
|
{isBatchMode ? (
|
||||||
<span
|
<span
|
||||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
{(hiddenCount > 0 || sessionsExpanded) && (
|
{(hiddenCount > 0 || sessionsExpanded) && (
|
||||||
<div className="mt-2 flex justify-start px-2.5">
|
<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',
|
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,
|
projectRoot: sourceSession?.projectRoot ?? sourceSession?.workDir ?? result.workDir ?? null,
|
||||||
workDir: result.workDir ?? sourceSession?.workDir ?? null,
|
workDir: result.workDir ?? sourceSession?.workDir ?? null,
|
||||||
workDirExists: true,
|
workDirExists: true,
|
||||||
|
sourceSessionId: result.sourceSessionId ?? sourceSessionId,
|
||||||
|
sourceMessageId: result.targetMessageId ?? targetMessageId,
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export type SessionListItem = {
|
|||||||
projectRoot?: string | null
|
projectRoot?: string | null
|
||||||
workDir: string | null
|
workDir: string | null
|
||||||
workDirExists: boolean
|
workDirExists: boolean
|
||||||
|
sourceSessionId?: string
|
||||||
|
sourceMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageEntry = {
|
export type MessageEntry = {
|
||||||
|
|||||||
@ -581,6 +581,39 @@ describe('SessionService', () => {
|
|||||||
expect(resultA.sessions[0]!.id).toBe(id1)
|
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
|
// getSession
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export type SessionListItem = {
|
|||||||
projectRoot: string | null
|
projectRoot: string | null
|
||||||
workDir: string | null
|
workDir: string | null
|
||||||
workDirExists: boolean
|
workDirExists: boolean
|
||||||
|
sourceSessionId?: string
|
||||||
|
sourceMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeleteSessionFailure = {
|
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 {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
title,
|
title,
|
||||||
@ -1331,6 +1347,8 @@ export class SessionService {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
workDir,
|
workDir,
|
||||||
workDirExists,
|
workDirExists,
|
||||||
|
sourceSessionId,
|
||||||
|
sourceMessageId,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip unreadable files
|
// Skip unreadable files
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user