Merge remote-tracking branch 'origin/master' into codex/basic-session-search
# Conflicts: # apps/web/tests/snapshots/question-composer/answered.expected.md # packages/client/connection/tests/fake-api.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/tests/fake-api.ts # packages/client/ui-workspace/README.i18n.yaml # packages/client/ui-workspace/README.md # packages/client/ui-workspace/README.zh.md # packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx # packages/client/ui-workspace/tests/apply.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/apiproxy/src/fetch/client.ts # packages/host/apiproxy/src/fetch/handler.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/session-query/session-query/tests/search-helpers.spec.ts
This commit is contained in:
@@ -14,6 +14,7 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
@@ -24,13 +25,14 @@ async function bench() {
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.get('slots') as SlotsService,
|
||||
create,
|
||||
pickDirectory,
|
||||
startSession,
|
||||
rename,
|
||||
insertSessionBefore,
|
||||
@@ -93,10 +95,14 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
await browser.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await picker.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
|
||||
row.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||
|
||||
@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
@@ -59,6 +61,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -340,9 +343,9 @@ describe('WorkspaceBrowser', () => {
|
||||
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('first-hit', 2, { displayTitle: 'Old result' }),
|
||||
@@ -475,7 +478,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
@@ -488,7 +491,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
one.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(one, 'dragOver', 105)
|
||||
fireDrag(one, 'drop', 105)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
@@ -514,7 +517,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(two, 'drop', 155)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
||||
})
|
||||
@@ -531,7 +534,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
@@ -560,7 +563,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
@@ -582,13 +585,13 @@ describe('WorkspaceBrowser', () => {
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
@@ -651,13 +654,13 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -15,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
@@ -32,7 +35,11 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
@@ -45,20 +52,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace,
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
|
||||
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
|
||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
@@ -73,7 +79,7 @@ describe('WorkspacePicker', () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
@@ -81,34 +87,81 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('adopts an existing path through the same immediate create action', async () => {
|
||||
const created = workspace('adopted')
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Use an existing folder')
|
||||
const input = screen.getByLabelText('Existing folder path')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(createWorkspace).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('treats native picker cancellation as a silent no-op', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a name conflict and retries through the native picker', async () => {
|
||||
const pickDirectory = vi.fn()
|
||||
.mockResolvedValueOnce('/one/project')
|
||||
.mockResolvedValueOnce(null)
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the folder action while the native picker is already open', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(null); await pending })
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a creation modal when the user cancels', () => {
|
||||
mount([])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -118,7 +171,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
@@ -134,7 +187,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const createWorkspace = vi.fn(() => pending)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.change(input, { target: { value: 'broken' } })
|
||||
@@ -151,7 +204,7 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
@@ -163,7 +216,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -176,7 +229,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
|
||||
Reference in New Issue
Block a user