mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The sidebar now groups conversations by project, so the embedded project picker in the search box duplicated the main navigation model and hid state inside a compact icon. Remove that picker and its store state, leaving search as plain text filtering and keeping the project header menu focused on organization and sorting. The archive-all entry was also hidden because a broad destructive action does not belong in the lightweight project menu. Constraint: Project grouping is now the primary project navigation surface. Rejected: Keep the embedded project picker hidden in place | it would leave dead filtering state and a stale recovery path. Rejected: Keep archive-all in the header menu | broad destructive actions are too risky for this surface. Confidence: high Scope-risk: moderate Directive: Do not reintroduce broad session deletion or hidden project filters into the sidebar header without a dedicated reviewed management flow. Tested: cd desktop && bun run test src/components/layout/Sidebar.test.tsx src/stores/sessionStore.test.ts src/components/layout/TabBar.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const { createMock, listMock } = vi.hoisted(() => ({
|
||
createMock: vi.fn(),
|
||
listMock: vi.fn(),
|
||
}))
|
||
|
||
vi.mock('../api/sessions', () => ({
|
||
sessionsApi: {
|
||
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(() => {
|
||
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.mockResolvedValue({ sessions: [], total: 0 })
|
||
|
||
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')
|
||
})
|
||
})
|