feat(web): add workspace-aware session flow
This commit is contained in:
69
packages/client/ui-workspace/tests/apply.spec.ts
Normal file
69
packages/client/ui-workspace/tests/apply.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
|
||||
workspaceId: 'ws-new' as never,
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
ctx.provide('workspaces', { create })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
|
||||
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
|
||||
const entry = slots.entries(name)[0]!
|
||||
return (entry.inject as () => WorkspacePickerInjected)()
|
||||
}
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the independent Workspace service', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the shared picker for declarations that arrive before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots, 'sidebar.workspace')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
declare(after.slots, 'conversation.empty.workspace')
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
})
|
||||
|
||||
it('routes name and path creation to WorkspacesService', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots, 'sidebar.workspace')
|
||||
await injected.createWorkspace({ name: 'project' })
|
||||
await injected.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('unregisters picker entries on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
18
packages/client/ui-workspace/tests/invariant.spec.ts
Normal file
18
packages/client/ui-workspace/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-workspace')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
123
packages/client/ui-workspace/tests/workspace-picker.spec.tsx
Normal file
123
packages/client/ui-workspace/tests/workspace-picker.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
function workspace(id: string, title = id): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: [],
|
||||
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)
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
const element = document.createElement('button')
|
||||
element.getBoundingClientRect = () => ({
|
||||
top: 10, left: 20, width: 30, height: 40, right: 50, bottom: 50,
|
||||
x: 20, y: 10, toJSON: () => ({}),
|
||||
})
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<WorkspacePicker
|
||||
open
|
||||
anchorRef={anchor()}
|
||||
useSessions={hook(sessions)}
|
||||
useWorkspaces={hook(workspaceState(items))}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
/>,
|
||||
)
|
||||
return { view, onPick, onClose, createWorkspace }
|
||||
}
|
||||
|
||||
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)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
describe('WorkspacePicker', () => {
|
||||
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
|
||||
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
it('creates a real Workspace from a name and focuses its frontend Session target', async () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('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' }))
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('adopts an existing path through the same immediate create action', async () => {
|
||||
const created = workspace('adopted')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Use an existing folder')
|
||||
fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use folder' }))
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseCreateItem('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)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes creation phase and error text while retaining the modal for retry', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
|
||||
expect(b.view.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows list loading through a stable status surface', () => {
|
||||
const state: WorkspaceListState = {
|
||||
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
|
||||
}
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user