feat: workspace select menu

This commit is contained in:
07akioni
2026-07-24 16:09:58 +08:00
parent 06143b1a86
commit 6f5321cb37
26 changed files with 890 additions and 187 deletions

View File

@@ -78,6 +78,7 @@ async function bench() {
cell: () => undefined,
scopeOf,
create: vi.fn(() => Promise.resolve(ROOT)),
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
@@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
b.sessionsFake.open.mockClear()
await injected.createWorkspaceSession('Fresh')
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {

View File

@@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
const noopCreate = () => Promise.resolve()
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -253,7 +259,11 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState useSessions={listHook([])} startSession={startSession} />,
<EmptyState
useSessions={listHook([])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -270,15 +280,17 @@ describe('EmptyState branches', () => {
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['Default directory', '/proj', 'New directory…'])
fireEvent.click(view.getByRole('menuitem', { name: '/proj' }))
.toEqual(['proj', 'New Workspace'])
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
const custom = view.container.querySelector('input')!
fireEvent.change(custom, { target: { value: '/typed/dir' } })
const textarea = view.container.querySelector('textarea')!
@@ -286,4 +298,20 @@ describe('EmptyState branches', () => {
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
it('Create modal surfaces inject failures inline', async () => {
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
const view = render(
<EmptyState
useSessions={listHook([])}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(view.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
})
})

View File

@@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
const noopCreate = () => Promise.resolve()
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
@@ -94,14 +96,20 @@ describe('EmptyState', () => {
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
render(
<EmptyState
useSessions={useSessions}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const trigger = screen.getByRole('button', { name: '项目目录' })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…'])
fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' }))
.toEqual(['app', 'lib', 'New Workspace'])
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
@@ -113,14 +121,64 @@ describe('EmptyState', () => {
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
})
it('new-directory option swaps the chip for a free-form input', () => {
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' }))
const custom = screen.getByPlaceholderText(/Directory path/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
const path = screen.getByLabelText('Folder path') as HTMLInputElement
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
})
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
expect(name.value).toBe('New WorkSpace')
fireEvent.change(name, { target: { value: 'My Proj' } })
fireEvent.keyDown(name, { key: 'Enter' })
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
})
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(createWorkspaceSession).not.toHaveBeenCalled()
})
})