feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-workspace
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
## Model Experience
None, as the picker is browser chrome; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.

View File

@@ -0,0 +1,65 @@
{
"name": "@deepseek-ai/dsh-client-ui-workspace",
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,46 @@
/* Modal form styles mirror the empty state's path/create modals (same figma
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
* entries stay visually identical. */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError,
.modalStatus,
.menuStatus {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
}
.modalError {
color: var(--dsw-alias-state-error-primary);
}
.modalStatus,
.menuStatus {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,196 @@
/** Shared Workspace picker for the sidebar and New Session hero. */
import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const CREATE_WORKSPACE = '::create-workspace'
const USE_EXISTING = '::use-existing'
const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId as string,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{
id: CREATE_WORKSPACE,
label: 'Create workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use an existing folder' },
{ id: CREATE_NEW, label: 'Create a new workspace' },
],
},
]
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const handleSelect = (id: string): void => {
if (id === USE_EXISTING) {
onClose()
setPathDraft('')
setModalError(null)
setModalKind('path')
return
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('workspace')
setModalError(null)
setModalKind('create')
return
}
onPick(id as WorkspaceId)
}
const create = (input: { name: string } | { path: string }): void => {
if (creating) return
setCreating(true)
setModalError(null)
void createWorkspace(input).then((workspace) => {
setCreating(false)
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
setModalError(`Workspace creation failed: ${message}`)
setCreating(false)
})
}
const confirmPath = (): void => {
const path = pathDraft.trim()
if (path !== '') create({ path })
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
}
}
return (
<>
<Menu
open={open}
anchor={null}
items={items}
onSelect={handleSelect}
onClose={onClose}
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
<Modal
open={modalKind === 'path'}
onClose={closeModal}
title="Use an existing folder"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || pathDraft.trim() === ''}
onClick={confirmPath}
>
Use folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Existing folder path"
autoFocus
disabled={creating}
placeholder="/path/to/project"
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmPath()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
title="Create a new workspace"
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
Create workspace
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmCreate()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">A workspace named “{normalizedWorkspaceName}” already exists.</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</>
)
}

View File

@@ -0,0 +1,30 @@
/**
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
* slots. Each runtime share provides its owner's popover controls plus the
* global useWorkspaces hook; this package adds the injected Host Workspace
* creation callback.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull both owner SlotMap merges into programs that resolve the
// picker runtime union below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Registrant-private injected share. Pick semantics remain in each owner's
* onPick callback; this callback creates only the real Host Workspace. A type
* alias supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
}
/**
* Full picker props: either owner's runtime share, including useWorkspaces,
* plus this package's injected creation callback.
*/
export type WorkspacePickerProps =
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
& WorkspacePickerInjected

View File

@@ -0,0 +1,54 @@
/**
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
* through the global useWorkspaces hook, and delegates selection semantics to
* each owner. Its injected share creates a Workspace without creating a
* Session. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerInjected } from './contract/slots.ts'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* the ui-sidebar apply, whose activation order relative to this one is NOT
* constrained: dshClient.inject edges are informational (loading/prefetch
* metadata, never apply sequencing) and the sidebar provides no waitable
* service. apply therefore registers via declaration-aware deferral instead
* of assuming order.
*/
export const inject = ['slots', 'workspaces']
/**
* Register WorkspacePicker in both owner slots once their declarations are on
* the ledger. The inject factory returns a plain Workspace creation callback;
* data reads use the framework's global useWorkspaces hook.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: the sidebar's declaring apply may
// activate after this one (entry activation order is unconstrained), and a
// register into an undeclared slot throws. Register once the declaration
// is on the ledger; the subscription also re-registers after an HMR
// collapse re-declares the slot (the cascade disposed our entry with it).
ctx.effect(() => {
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
const disposers = new Map<(typeof slotNames)[number], () => void>()
const tryRegister = (name: (typeof slotNames)[number]): void => {
if (ctx.slots.spec(name) === undefined) return
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
}
const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
for (const name of slotNames) tryRegister(name)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
}, 'ui-workspace: picker registrations')
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration).
*/
/** Host plugin body — no host-side behavior for the workspace picker plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-workspace`.
* @module @deepseek-ai/dsh-client-ui-workspace/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace'
/** Cordis companion plugin name. */
export const name = 'client-ui-workspace-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin registering one presentational
* component into two host-declared slots — its inject face is two stateless
* RPC wrappers plus a create-and-open call; it emits no cordis events and
* owns no cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View 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)
})
})

View 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
})
})

View 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…')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
},
{
"path": "../runtime"
},
{
"path": "../ui-sidebar"
},
{
"path": "../ui-conversation"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js', 'lib/types/invariant.js'])