cc-haha/desktop/src/stores/sessionStore.test.ts
程序员阿江(Relakkes) 73e915ac6b Enable desktop branching from transcript messages
Desktop users need the same branch-from-here workflow that the CLI already exposed, so the branch creation logic now lives in a shared transcript utility and the desktop app routes completed message actions through the server API. The UI hydrates transcript ids after live completions so newly generated turns can be branched immediately without a refresh.

Constraint: Source sessions must remain unmodified while branch sessions inherit the active transcript chain and persistence metadata.
Rejected: Keep a desktop-only branch implementation | it would drift from CLI /branch semantics and duplicate transcript filtering rules
Confidence: high
Scope-risk: moderate
Directive: Do not remove the post-completion transcript hydration without a real-model desktop E2E for just-finished messages
Tested: bun run verify; Chrome Web UI E2E with real gpt-5.5 provider on ports 45678/45679
Not-tested: Provider-specific behavior beyond the configured Sub2API-ChatGPT route
2026-05-19 18:31:24 +08:00

268 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { branchMock, createMock, listMock } = vi.hoisted(() => ({
branchMock: vi.fn(),
createMock: vi.fn(),
listMock: vi.fn(),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
branch: branchMock,
create: createMock,
list: listMock,
delete: vi.fn(),
rename: vi.fn(),
},
}))
import { useSessionStore } from './sessionStore'
import { useTabStore } from './tabStore'
const initialState = useSessionStore.getState()
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('sessionStore', () => {
beforeEach(() => {
branchMock.mockReset()
createMock.mockReset()
listMock.mockReset()
useSessionStore.setState({
...initialState,
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
})
useTabStore.setState({ tabs: [], activeTabId: null })
})
afterEach(() => {
useSessionStore.setState(initialState)
useTabStore.setState({ tabs: [], activeTabId: null })
})
it('returns a new session id before the background refresh completes', async () => {
createMock.mockResolvedValue({ sessionId: 'session-optimistic-1' })
listMock.mockImplementation(() => new Promise(() => {}))
const result = await Promise.race([
useSessionStore.getState().createSession('D:/workspace/code/myself_code/cc-haha'),
delay(100).then(() => 'timed-out'),
])
expect(result).toBe('session-optimistic-1')
expect(useSessionStore.getState().activeSessionId).toBe('session-optimistic-1')
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-optimistic-1',
title: 'New Session',
workDir: 'D:/workspace/code/myself_code/cc-haha',
workDirExists: true,
})
expect(createMock).toHaveBeenCalledWith({
workDir: 'D:/workspace/code/myself_code/cc-haha',
})
expect(listMock).toHaveBeenCalledOnce()
})
it('keeps an optimistic local title when a background refresh still returns a placeholder', async () => {
const refresh = createDeferred<{
sessions: Array<{
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}>
total: number
}>()
createMock.mockResolvedValue({ sessionId: 'session-title-1', workDir: '/workspace/project' })
listMock.mockReturnValue(refresh.promise)
await useSessionStore.getState().createSession('/workspace/project')
useSessionStore.getState().updateSessionTitle('session-title-1', '开始优化UI')
refresh.resolve({
sessions: [{
id: 'session-title-1',
title: 'Untitled Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:01.000Z',
messageCount: 0,
projectPath: '',
workDir: '/workspace/project',
workDirExists: true,
}],
total: 1,
})
await refresh.promise
await delay(0)
expect(useSessionStore.getState().sessions[0]?.title).toBe('开始优化UI')
})
it('syncs refreshed session titles into already-open tabs', async () => {
useTabStore.getState().openTab('session-title-2', '```json {"title":')
listMock.mockResolvedValue({
sessions: [{
id: 'session-title-2',
title: '使用bash写一个shell随便写点什么东西',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:01.000Z',
messageCount: 3,
projectPath: '',
workDir: '/workspace/project',
workDirExists: true,
}],
total: 1,
})
await useSessionStore.getState().fetchSessions()
expect(useTabStore.getState().tabs[0]?.title).toBe('使用bash写一个shell随便写点什么东西')
})
it('forwards direct branch switch repository options when creating a session', async () => {
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: false },
})
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
repository: { branch: 'feature/rail', worktree: false },
})
})
it('forwards isolated worktree repository options when creating a session', async () => {
createMock.mockResolvedValue({
sessionId: 'session-worktree-launch',
workDir: '/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678',
})
listMock.mockImplementation(() => new Promise(() => {}))
await useSessionStore.getState().createSession('/workspace/repo', {
repository: { branch: 'feature/rail', worktree: true },
})
expect(createMock).toHaveBeenCalledWith({
workDir: '/workspace/repo',
repository: { branch: 'feature/rail', worktree: true },
})
expect(useSessionStore.getState().sessions[0]?.workDir)
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
})
it('returns the branched session before the background refresh completes', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-1',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
}],
})
const result = await Promise.race([
useSessionStore.getState().branchSession('session-source-1', 'transcript-message-1'),
delay(100).then(() => 'timed-out'),
])
expect(result).toMatchObject({
sessionId: 'session-branch-1',
title: 'Branch from here',
workDir: '/workspace/repo/branches/session-branch-1',
})
expect(branchMock).toHaveBeenCalledWith('session-source-1', {
targetMessageId: 'transcript-message-1',
})
expect(useSessionStore.getState().activeSessionId).toBe('session-branch-1')
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-1',
title: 'Branch from here',
projectPath: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-1',
projectRoot: '/workspace/repo',
workDirExists: true,
})
expect(listMock).toHaveBeenCalledOnce()
})
it('updates an existing optimistic branch row when the branch session id is already present', async () => {
branchMock.mockResolvedValue({
sessionId: 'session-branch-existing',
title: 'Updated branch',
workDir: '/workspace/repo/branches/session-branch-existing',
sourceSessionId: 'session-source-1',
targetMessageId: 'transcript-message-2',
})
listMock.mockImplementation(() => new Promise(() => {}))
useSessionStore.setState({
sessions: [
{
id: 'session-branch-existing',
title: 'Old branch title',
createdAt: '2026-05-18T00:00:00.000Z',
modifiedAt: '2026-05-18T00:00:00.000Z',
messageCount: 3,
projectPath: '/workspace/old',
projectRoot: '/workspace/old',
workDir: '/workspace/old',
workDirExists: true,
},
{
id: 'session-source-1',
title: 'Source session',
createdAt: '2026-05-19T00:00:00.000Z',
modifiedAt: '2026-05-19T00:00:00.000Z',
messageCount: 4,
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo',
workDirExists: true,
},
],
})
await useSessionStore.getState().branchSession('session-source-1', 'transcript-message-2')
expect(useSessionStore.getState().sessions).toHaveLength(2)
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-branch-existing',
title: 'Updated branch',
projectPath: '/workspace/repo',
projectRoot: '/workspace/repo',
workDir: '/workspace/repo/branches/session-branch-existing',
})
})
})