feat: implement new session behavior to clear selection and show empty state
- Added bilingual notes for the new session feature, detailing the transition to an empty state upon session creation. - Updated `SessionsService` to include a `clear()` method that resets the current selection and persists the empty state. - Enhanced the `EmptyState` component to reflect the new design, including workspace selection and input handling. - Modified CSS styles for improved layout and visual consistency in the empty state. - Updated tests to cover the new session clearing functionality and its effects on the UI.
This commit is contained in:
@@ -19,7 +19,10 @@ function setup(over?: Partial<InputBarProps>) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const button = view.container.querySelector('button')!
|
||||
// aria-label (not role name): title also contains 发送/停止 and would double-match.
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props }
|
||||
}
|
||||
|
||||
@@ -97,7 +100,7 @@ describe('running lock and primary button', () => {
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(view.container.querySelector('button')!)
|
||||
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
@@ -129,3 +132,38 @@ describe('error strip and variants', () => {
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder chrome', () => {
|
||||
it('renders attach / Plan / Read-only / model controls', () => {
|
||||
const { view } = setup()
|
||||
expect(view.getByLabelText('添加')).toBeTruthy()
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
|
||||
})
|
||||
|
||||
it('native select change updates the selected option', () => {
|
||||
const { view } = setup()
|
||||
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
|
||||
fireEvent.change(plan, { target: { value: 'agent' } })
|
||||
expect(plan.value).toBe('agent')
|
||||
const access = view.getByLabelText('Access mode') as HTMLSelectElement
|
||||
fireEvent.change(access, { target: { value: 'readwrite' } })
|
||||
expect(access.value).toBe('readwrite')
|
||||
})
|
||||
|
||||
it('model select can drop the High option', () => {
|
||||
const { view } = setup()
|
||||
const model = view.getByLabelText('Model') as HTMLSelectElement
|
||||
fireEvent.change(model, { target: { value: 'v4-pro' } })
|
||||
expect(model.value).toBe('v4-pro')
|
||||
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
|
||||
})
|
||||
|
||||
it('running locks the chrome selects and attach control', () => {
|
||||
const { view } = setup({ running: true })
|
||||
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('EmptyState branches', () => {
|
||||
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
|
||||
it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => {
|
||||
const startSession = vi.fn(() => Promise.resolve())
|
||||
const view = render(
|
||||
<EmptyState
|
||||
@@ -272,12 +272,13 @@ describe('EmptyState branches', () => {
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const select = view.container.querySelector('select')!
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/proj', '::new-directory'])
|
||||
fireEvent.change(select, { target: { value: '/proj' } })
|
||||
expect((select as HTMLSelectElement).value).toBe('/proj')
|
||||
fireEvent.change(select, { target: { value: '::new-directory' } })
|
||||
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' }))
|
||||
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' }))
|
||||
const custom = view.container.querySelector('input')!
|
||||
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
|
||||
@@ -28,7 +28,8 @@ const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
|
||||
globalThis.localStorage?.clear()
|
||||
})
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
@@ -95,11 +96,13 @@ describe('EmptyState', () => {
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
|
||||
|
||||
const select = screen.getByRole('combobox', { name: '项目目录' })
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
|
||||
fireEvent.change(select, { target: { value: '/w/app' } })
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build')
|
||||
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' }))
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
||||
@@ -110,11 +113,12 @@ describe('EmptyState', () => {
|
||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
||||
})
|
||||
|
||||
it('new-directory option swaps the select for a free-form input', () => {
|
||||
it('new-directory option swaps the chip for a free-form input', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user