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

@@ -10,7 +10,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)

View File

@@ -72,6 +72,22 @@
config:
root: './.sessions'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: workspace
name: '@deepseek-ai/dsh-workspace'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
@@ -217,6 +233,9 @@
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'

View File

@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
@@ -49,6 +50,9 @@
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
@@ -68,6 +72,7 @@
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"

View File

@@ -77,6 +77,8 @@ export interface AppCLIEntryOptions {
* browser).
*/
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
}
/**
@@ -141,6 +143,7 @@ export class AppCLIEntry {
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.

View File

@@ -21,6 +21,7 @@ export async function runWeb(argv: string[]): Promise<void> {
host: { type: 'string' },
port: { type: 'string' },
dev: { type: 'boolean', default: false },
'workspace-root': { type: 'string' },
},
allowPositionals: false,
})
@@ -44,6 +45,7 @@ export async function runWeb(argv: string[]): Promise<void> {
dev: values.dev,
...values.host !== undefined ? { host: values.host } : {},
...port !== undefined ? { port } : {},
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
})
const { ctx, port: boundPort } = await entry.run()

View File

@@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -72,12 +73,12 @@ afterEach(() => {
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
const tree = screen.getByRole('tree', { name: 'Sessions' })
const sidebar = within(tree).getByText(label).textContent ?? ''
const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' }))
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
.getByRole('button', { name: label }).textContent ?? ''
return { sidebar, breadcrumb, documentTitle: document.title }
}
it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {

View File

@@ -0,0 +1,317 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
]))
interface FixtureWindow extends Window {
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
__ModuleLoader__?: unknown
}
class ResizeObserverStub {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
}
const win = window as FixtureWindow
let unmount: (() => void) | undefined
beforeEach(() => {
localStorage.clear()
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => { callback(0) }, 0) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
})
afterEach(() => {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
/** Boot the complete built client graph against one keyless fixture branch. */
function boot(search: string): void {
history.replaceState(null, '', `/${search}`)
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
})
}
/** Recreate the built client graph while preserving browser-persistent state. */
function refresh(search: string): void {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
boot(search)
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** The labelled chip and its adjacent plus button intentionally share a label. */
function workspaceChip(): HTMLElement {
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
.find(element => element.getAttribute('aria-haspopup') === 'menu')
if (chip === undefined) throw new Error('Workspace chip missing')
return chip
}
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const tree = screen.getByRole('tree', { name: 'Sessions' })
fireEvent.change(composer, { target: { value: 'keep this local' } })
expect({
headline: visibleText(screen.getByText("Let's start building")),
workspaceDraft: visibleText(workspaceChip()),
sidebar: visibleText(tree),
composerDisabled: (composer as HTMLTextAreaElement).disabled,
prompt: (composer as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"composerDisabled": false,
"headline": "Let's start building",
"prompt": "keep this local",
"sidebar": "No sessions yet",
"workspaceDraft": "workspace",
}
`)
})
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
boot('?fixture=empty')
await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const workspaceSection = screen.getByText('Workspaces').parentElement
if (workspaceSection === null) throw new Error('Workspace section missing')
fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
target: { value: 'nova' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
const tree = await screen.findByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
if (group === null || draft === null) throw new Error('created Workspace projection missing')
expect({
workspace: visibleText(group),
draft: visibleText(draft),
draftSelected: draft.getAttribute('aria-selected'),
composerWorkspace: visibleText(workspaceChip()),
}).toMatchInlineSnapshot(`
{
"composerWorkspace": "nova",
"draft": "New session",
"draftSelected": "true",
"workspace": "nova1 session",
}
`)
})
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
boot('?fixture')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const tree = screen.getByRole('tree', { name: 'Sessions' })
fireEvent.change(composer, { target: { value: 'discard this page-local draft' } })
const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
const before = {
workspace: visibleText(beforeGroup),
draft: visibleText(within(tree).getByText('New session')),
prompt: (composer as HTMLTextAreaElement).value,
}
refresh('?fixture')
const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
expect({
before,
after: {
workspace: visibleText(afterGroup),
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
prompt: (refreshedComposer as HTMLTextAreaElement).value,
},
}).toMatchInlineSnapshot(`
{
"after": {
"prompt": "",
"replacementDraft": "New session",
"workspace": "fixture4 sessions",
},
"before": {
"draft": "New session",
"prompt": "discard this page-local draft",
"workspace": "fixture4 sessions",
},
}
`)
})
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
boot('?fixture&fixtureAttach=fail')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
fireEvent.change(composer, { target: { value: 'keep this cwd-only session' } })
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
const ungroupedSection = ungroupedGroup?.parentElement
if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
throw new Error('Workspace or Ungrouped projection missing')
}
const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
const retained = screen.getByDisplayValue('keep this cwd-only session')
expect({
workspace: visibleText(workspaceGroup),
ungrouped: visibleText(ungroupedGroup),
session: within(session).getByText('fixture', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
prompt: (retained as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"prompt": "keep this cwd-only session",
"session": "fixture",
"sessionSelected": "true",
"ungrouped": "Ungrouped1 session",
"workspace": "fixture3 sessions",
}
`)
})
it('materializes the automatic Workspace and Session on the first successful send', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
fireEvent.change(composer, { target: { value: 'build a lighthouse' } })
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('materialized Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
"promptVisible": "build a lighthouse",
"session": "workspace",
"sessionSelected": "true",
"workspace": "workspace1 session",
}
`)
})
it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
boot('?fixture=empty&fixturePrompt=reject')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
fireEvent.change(composer, { target: { value: 'do not lose this' } })
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
const retained = screen.getByDisplayValue('do not lose this')
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('rejected-send Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
error: visibleText(alert),
prompt: (retained as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
"prompt": "do not lose this",
"session": "workspace",
"workspace": "workspace1 session",
}
`)
})