Merge remote-tracking branch 'origin/master' into worktree/pr682-retarget-20260727

# Conflicts:
#	.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-27 17:55:56 +08:00
554 changed files with 24869 additions and 4081 deletions

View File

@@ -145,6 +145,30 @@
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# Host command registry: the single source of truth behind command.list /
# command.execute; the web '/' menu is a pure projection of this registry.
- id: commands
name: '@deepseek-ai/dsh-commands'
# Plan mode registers /plan (the first real command on the web surface).
# Section text mirrors examples/tui-agent/cordis.yml (the reference
# deployment); plan-mode throws at load on an empty section.
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# token-meter rejects unknown config keys — keep this row bare.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
@@ -262,6 +286,20 @@
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over
# it (ui-command), and the two reference sources (ui-skill / ui-subagent).
- id: ui-slash
name: '@deepseek-ai/dsh-client-ui-slash'
- id: ui-command
name: '@deepseek-ai/dsh-client-ui-command'
- id: ui-skill
name: '@deepseek-ai/dsh-client-ui-skill'
- id: ui-subagent
name: '@deepseek-ai/dsh-client-ui-subagent'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'

View File

@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
@@ -33,10 +34,14 @@
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-subagent": "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-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
@@ -47,6 +52,7 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",

View File

@@ -144,7 +144,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
"errorSubRow": true,
"parentRow": "CodeRead the notes files and summarize",
"subRows": [
"$List notes",
"BashList notes",
"Readnotes/demo.txt",
"Readnotes/missing.txt",
],
@@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
}).toMatchInlineSnapshot(`
{
"subCells": [
"#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
"#54Subread · {"path":"notes/demo.txt"}+0.8s",
"#55Subread · {"path":"notes/missing.txt"}+0.8s",
"#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
"#52Subread · {"path":"notes/demo.txt"}+0.8s",
"#53Subread · {"path":"notes/missing.txt"}+0.8s",
],
}
`)

View File

@@ -18,7 +18,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
@@ -48,6 +48,8 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {

View File

@@ -20,7 +20,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -47,6 +47,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {

View File

@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -94,6 +94,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}
/**
@@ -134,9 +136,11 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
// Composer recovered; no streaming node lingers.
// Composer recovered; no streaming node lingers. The host settled first
// (awaited above), but the abort frame reaches the browser over SSE — the
// frozen-partial swap is eventually consistent, so poll rather than count.
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
// Golden of the aborted end-state: the prompt bubble plus the frozen
// partial ('partial' is the hang entry's replayed prefix) and no more.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)

View File

@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -45,6 +45,8 @@ describe('web e2e: resident question composer round trip', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {

View File

@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
@@ -47,6 +47,8 @@ describe('web e2e: fresh round trip through the real assembly', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {

View File

@@ -64,7 +64,7 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
// catch-all would leave resolveModelContext unroutable and compact-basic's
// catch-all would leave resolveModelInfo unroutable and compact-basic's
// post-step pressure check would warn every step). The published
// contextWindow keeps that pressure path provably inert for small fixtures.
const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]

View File

@@ -0,0 +1,191 @@
// @vitest-environment jsdom
// Assembled keyless snapshot of the slash/input/session convergence under the
// agent-parity model: the New Session view state locks the composer until a
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
// the '/' menu serves the session's wire command catalog (sessions are always
// agent-backed — no draft/materialized split), a leadingInput command claims,
// submits over the wire, and notices its result, and the SAME composer
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
// flips blank and surfaces the session in lists. This is the user-visible
// acceptance anchor — package mocks do not substitute for the assembled
// application transcript.
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-locale', dir: 'locale', url: '/plugins/locale.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-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
{
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',
],
},
]
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() }
})
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Type into the machine-driven composer and let the change echo back. */
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
fireEvent.change(composer, { target: { value } })
await waitFor(() => { expect(composer.value).toBe(value) })
}
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
boot('?fixture=empty')
// View state: no session entity — the composer renders locked; only the
// workspace picker is live.
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
'Choose a workspace to start', {}, { timeout: 10_000 },
)
expect(locked.disabled).toBe(true)
// Pick (create) a Workspace: connectWorkspace materializes the full
// Session+Agent and the provider swaps in the live blank-session hero.
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
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 composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
'Describe what you want to build', {}, { timeout: 10_000 },
)
expect(composer.disabled).toBe(false)
// '/' opens the menu with the session's wire command catalog (the session
// is agent-backed from birth — the catalog is the single-address list).
await typeComposer(composer, '/')
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
const menuText = visibleText(menu)
// Pick /echo (leadingInput): the claim token lands in the same textarea.
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
await waitFor(() => { expect(composer.value).toBe('/echo ') })
// Type args and submit: the claim executes over the wire and notices its
// result; the token is consumed and the draft returns to plain text.
await typeComposer(composer, '/echo hello parser')
fireEvent.keyDown(composer, { key: 'Enter' })
await screen.findByText('hello parser', {}, { timeout: 10_000 })
await waitFor(() => { expect(composer.value).toBe('') })
// Slash execution does not flip blank: the selected row remains New Session.
const tree = screen.getByRole('tree', { name: 'Sessions' })
expect(within(tree).getByText('1 session')).toBeDefined()
expect(within(tree).getByText('New Session')).toBeDefined()
// First plain send through the SAME textarea: acceptance logs the user
// message and converts the existing sidebar row out of blank.
const before = composer
await typeComposer(composer, 'build me a parser')
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => {
expect(screen.queryByText("Let's start building")).toBeNull()
}, { timeout: 10_000 })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const after = document.querySelector('textarea')
expect({
menuHadEcho: menuText.includes('echo'),
menuHadCompact: menuText.includes('compact'),
composerSurvivedConversion: after === before,
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
}).toMatchInlineSnapshot(`
{
"composerSurvivedConversion": true,
"menuHadCompact": true,
"menuHadEcho": true,
"sessionListed": "nova1 session",
}
`)
})

View File

@@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
@@ -393,6 +393,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')

View File

@@ -23,13 +23,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -19,13 +19,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -11,7 +11,11 @@
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions": No sessions yet
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "设置":
- img
- text: 设置
@@ -23,13 +27,10 @@
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
- text: 详情
- button "关闭详情"
- text: 点击消息流中的工具行查看详情

View File

@@ -15,13 +15,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -12,13 +12,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -10,13 +10,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -15,13 +15,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -1,3 +1,5 @@
- text: bash
- button "关闭详情"
- text: "Input { \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" } Output NAVIGATION_OK"
- text: Input
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
- text: Output NAVIGATION_OK

View File

@@ -21,13 +21,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -24,13 +24,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -21,13 +21,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]

View File

@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -71,6 +71,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {

View File

@@ -32,6 +32,31 @@ export function probeFreePort(): Promise<number> {
})
}
/**
* Drive the hero's workspace picker through its create-by-name dialog until
* the live composer unlocks. A fresh world has no Workspace, so the boot
* lands in the locked view state (startup auto-selection has nothing to
* select); every scenario that types into the composer must connect one
* first. The default name 'workspace' keeps the session header cwd at
* <workspaceRoot>/workspace — the materialization proof several scenarios
* assert.
* @param page - the page under test.
* @param name - workspace name typed into the create dialog.
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))

View File

@@ -0,0 +1,191 @@
// @vitest-environment jsdom
// Todo display snapshot over the BUILT client graph (the code-mode-fixture
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
// Opens the fixture history session and pins the todo_write turn's two
// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
// derived from the call args) and the TodoPanel plan strip riding the
// 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded
// by the tail history page), including the collapse interaction.
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-locale', dir: 'locale', url: '/plugins/locale.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',
],
},
]
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 the populated fixture branch. */
function boot(): void {
history.replaceState(null, '', '/?fixture')
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() }
})
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
async function openFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
// Anchor on the expandable Workspace group row: the title and the blank
// session row can both read "fixture", and the session-count meta shifts
// when a blank session joins the group.
const group = (await within(tree).findAllByText('fixture'))
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
.find(el => el?.getAttribute('aria-expanded') !== null)
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
if (group.getAttribute('aria-expanded') === 'false') {
fireEvent.click(within(group).getByText('fixture'))
await waitFor(() => {
expect(group.getAttribute('aria-expanded')).toBe('true')
})
}
const session = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(session)
await waitFor(() => {
expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
}, { timeout: 10_000 })
}
it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
boot()
await openFixtureSession()
const row = document.querySelector('[data-sample="todo-row"]')
if (row === null) throw new Error('todo row missing')
const panel = document.querySelector('[data-testid="todo-panel"]')
if (panel === null) throw new Error('todo panel missing from the input dock')
// Header spans are adjacent inline nodes; textContent joins "To-dos" +
// "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
expect({
row: visibleText(row),
rowState: row.getAttribute('data-state'),
panelHeader: visibleText(panel.querySelector('button') ?? panel),
panelItems: [...panel.querySelectorAll('li')].map(item => ({
status: item.getAttribute('data-status'),
text: visibleText(item),
})),
}).toMatchInlineSnapshot(`
{
"panelHeader": "To-dos1/3 tasks · 1 in progress",
"panelItems": [
{
"status": "completed",
"text": "梳理需求",
},
{
"status": "in_progress",
"text": "实现 fixture 样本",
},
{
"status": "pending",
"text": "浏览器验收",
},
],
"row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
"rowState": "ok",
}
`)
})
it('collapses the plan strip to the count summary and restores it', async () => {
boot()
await openFixtureSession()
const panel = document.querySelector('[data-testid="todo-panel"]')
if (panel === null) throw new Error('todo panel missing from the input dock')
const header = panel.querySelector('button')
if (header === null) throw new Error('todo panel header missing')
fireEvent.click(header)
expect({
collapsedHeader: visibleText(header),
listGone: panel.querySelector('ul') === null,
}).toMatchInlineSnapshot(`
{
"collapsedHeader": "To-dos1/3 tasks · 1 in progress",
"listGone": true,
}
`)
fireEvent.click(header)
expect(panel.querySelectorAll('li')).toHaveLength(3)
})

View File

@@ -1,4 +1,12 @@
// @vitest-environment jsdom
// Assembled keyless snapshots of the New Session flow under the agent-parity
// model: startup auto-connects the recent Workspace's blank session when one
// exists; without any Workspace the composer is locked in the pure view
// state until one is chosen. Picking one materializes the full Session+Agent
// (reuse-or-create of the workspace's blank session), the first ACCEPTED
// prompt flips blank and surfaces the session in lists, and failures leave
// no client-side transaction state: a failed attach keeps the view state
// locked, a rejected prompt keeps the session blank with the draft restored.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -93,25 +101,12 @@ function boot(search: string): void {
})
}
/** 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()
}
/** Identify the interactive Workspace chip by its menu contract. */
/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
function workspaceChip(): HTMLElement {
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
.find(element => element.getAttribute('aria-haspopup') === 'menu')
@@ -119,210 +114,242 @@ function workspaceChip(): HTMLElement {
return chip
}
/** Edit the runtime-owned controlled input and assert the same-tick echo:
* a deferred echo makes React roll the textarea back mid-IME-composition,
* committing partial keystrokes (e.g. Pinyin "nihao" leaking as "nnini h…"). */
/** The locked view-state composer (no session yet). */
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
return await screen.findByPlaceholderText(
'Choose a workspace to start', {}, { timeout: 10_000 },
)
}
/** The live blank-session hero composer (session materialized). */
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
return await screen.findByPlaceholderText(
'Describe what you want to build', {}, { timeout: 10_000 },
)
}
/** Edit the machine-owned controlled input and assert the same-tick echo. */
function setComposerText(composer: HTMLElement, value: string): void {
fireEvent.change(composer, { target: { value } })
expect((composer as HTMLTextAreaElement).value).toBe(value)
}
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
/** Drive the picker's create flow: chip → Create workspace → name dialog. */
async function createWorkspaceViaPicker(name: string): Promise<void> {
fireEvent.click(workspaceChip())
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: name },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
}
/** Pick an existing Workspace row from the chip menu. */
async function pickWorkspace(title: string): Promise<void> {
fireEvent.click(workspaceChip())
fireEvent.click(await screen.findByRole('menuitem', { name: title }))
}
it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const composer = await findLockedComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
setComposerText(composer, 'keep this local')
expect({
headline: visibleText(screen.getByText("Let's start building")),
workspaceDraft: visibleText(workspaceChip()),
chip: visibleText(workspaceChip()),
composerDisabled: composer.disabled,
sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
sidebar: visibleText(tree),
composerDisabled: (composer as HTMLTextAreaElement).disabled,
prompt: (composer as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"composerDisabled": false,
"chip": "New Workspace",
"composerDisabled": true,
"headline": "Let's start building",
"prompt": "keep this local",
"sendDisabled": true,
"sidebar": "No sessions yet",
"workspaceDraft": "workspace",
}
`)
})
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
boot('?fixture=empty')
it('selects the recent Workspace and opens its blank Session on first load', async () => {
boot('?fixture')
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')
const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
expect({
workspace: visibleText(group),
draft: visibleText(draft),
draftSelected: draft.getAttribute('aria-selected'),
composerWorkspace: visibleText(workspaceChip()),
chip: visibleText(workspaceChip()),
composerDisabled: composer.disabled,
blankRow: within(tree).getByText('New Session').textContent,
}).toMatchInlineSnapshot(`
{
"composerWorkspace": "nova",
"draft": "New session",
"draftSelected": "true",
"blankRow": "New Session",
"chip": "fixture",
"composerDisabled": false,
}
`)
})
it('creating a Workspace materializes and lists its selected blank Session', async () => {
boot('?fixture=empty')
await findLockedComposer()
await createWorkspaceViaPicker('nova')
// The pick connected the workspace: full Session+Agent exists, composer live.
const composer = await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
expect(within(tree).getByText('New Session')).toBeDefined()
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (group === null) throw new Error('created Workspace projection missing')
expect({
composerDisabled: composer.disabled,
chip: visibleText(workspaceChip()),
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"chip": "nova",
"composerDisabled": false,
"workspace": "nova1 session",
}
`)
})
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
boot('?fixture')
it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await findLockedComposer()
await createWorkspaceViaPicker('nova')
await findHeroComposer()
const tree = screen.getByRole('tree', { name: 'Sessions' })
setComposerText(composer, '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')
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const before = {
workspace: visibleText(beforeGroup),
draft: visibleText(within(tree).getByText('New session')),
prompt: (composer as HTMLTextAreaElement).value,
}
// New Session resolves through the recent Workspace and reuses its blank
// session in place: no locked interlude, no second entity.
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
const composer = await findHeroComposer()
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
refresh('?fixture')
setComposerText(composer, 'first light')
fireEvent.keyDown(composer, { key: 'Enter' })
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')
// Conversion: the accepted prompt flips blank without adding a second row.
await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (group === null) throw new Error('converted Session projection missing')
expect({
before,
after: {
workspace: visibleText(afterGroup),
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
prompt: (refreshedComposer as HTMLTextAreaElement).value,
},
workspace: visibleText(group),
promptVisible: screen.getByText('first light', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
"after": {
"prompt": "",
"replacementDraft": "New session",
"workspace": "fixture4 sessions",
},
"before": {
"draft": "New session",
"prompt": "discard this page-local draft",
"workspace": "fixture4 sessions",
},
"promptVisible": "first light",
"workspace": "nova1 session",
}
`)
})
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
it('a failed Workspace attach recovers by reusing the published blank session', async () => {
boot('?fixture&fixtureAttach=fail')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
setComposerText(composer, 'keep this cwd-only session')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
// The rejected startup connect surfaces the locked view state first: the
// failure leaves no client-side transaction state to unwind.
await findLockedComposer()
// The host published the session before rejecting attachment (blank, with
// the workspace cwd), so the next connect — retry or manual pick — reuses
// it instead of minting a duplicate, and the hero opens on it.
await pickWorkspace('fixture')
const composer = await findHeroComposer()
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')
const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
if (group === null) throw new Error('fixture Workspace projection missing')
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,
headline: visibleText(screen.getByText("Let's start building")),
composerDisabled: composer.disabled,
chip: visibleText(workspaceChip()),
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"prompt": "keep this cwd-only session",
"session": "fixture",
"sessionSelected": "true",
"ungrouped": "Ungrouped1 session",
"chip": "fixture",
"composerDisabled": false,
"headline": "Let's start building",
"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 })
setComposerText(composer, '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 () => {
it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
boot('?fixture=empty&fixturePrompt=reject')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await findLockedComposer()
await createWorkspaceViaPicker('nova')
const composer = await findHeroComposer()
setComposerText(composer, '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')
// Failure restore rides the machine (no pendingPrompt transaction): the
// draft returns to the same resident textarea one render later. The
// attempt flips the composer out of the hero (engaging = retry chrome),
// but acceptance never happened: the session row stays New Session.
const retained = await screen.findByDisplayValue('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,
blankRow: within(tree).getByText('New Session').textContent,
workspace: visibleText(group),
}).toMatchInlineSnapshot(`
{
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
"blankRow": "New Session",
"error": "fixture: prompt rejected before acceptance (agent-busy)",
"prompt": "do not lose this",
"session": "workspace",
"workspace": "workspace1 session",
"workspace": "nova1 session",
}
`)
})
it('switching Workspace before the first message carries the draft to the new blank session', async () => {
boot('?fixture')
const composer = await findHeroComposer()
setComposerText(composer, 'carry me')
// Switch = session switch: the new workspace's blank session takes over,
// the typed draft moves machine-to-machine, the old blank stays hidden.
await createWorkspaceViaPicker('nova')
await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
const carried = await screen.findByDisplayValue('carry me')
const tree = screen.getByRole('tree', { name: 'Sessions' })
const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
expect({
chip: visibleText(workspaceChip()),
prompt: (carried as HTMLTextAreaElement).value,
fixtureWorkspace: visibleText(fixtureGroup),
novaWorkspace: visibleText(novaGroup),
}).toMatchInlineSnapshot(`
{
"chip": "nova",
"fixtureWorkspace": "fixture3 sessions",
"novaWorkspace": "nova1 session",
"prompt": "carry me",
}
`)
})

View File

@@ -5,12 +5,13 @@
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover scenarios need comes from a seeded
// fixture (the seeded-history seed reused verbatim — no new recording).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
@@ -105,6 +106,203 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete'))
const slotConsoleErrors: string[] = []
const transientSlotErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
slotConsoleErrors.push(message.text())
}
})
await page.exposeFunction('recordDshSlotError', (key: string) => {
if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key)
})
await page.evaluate(() => {
const target = window as unknown as { recordDshSlotError(key: string): Promise<void> }
const seen = new Set<string>()
const collect = (): void => {
for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error]')) {
const key = node.dataset.slotError ?? ''
if (!seen.has(key)) {
seen.add(key)
void target.recordDshSlotError(key)
}
}
}
new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
collect()
})
// Register the scaffold's existing project directory through the real UI.
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await useFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0)
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
const header = (await scaffold.ctx.sessionPersistence.list())
.find(candidate => candidate.id === SEED_ID)
if (header === undefined) throw new Error('seeded Session log disappeared before deletion')
const logLocation = scaffold.ctx.sessionPersistence.locate(header)
if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path')
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
// Open the seeded (first/accounted) Session so deletion must preserve the
// current selection while it moves into Ungrouped.
const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
await groupRow.waitFor({ timeout: 10_000 })
const groupSection = groupRow.locator('..')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBeGreaterThanOrEqual(2)
const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
await seededRow.click()
await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
await groupRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
await dialog.waitFor({ timeout: 10_000 })
const copy = await dialog.textContent()
expect(copy).toContain('workspace list')
expect(copy).toContain('folder and session logs will be kept')
expect(copy).toContain('sessions will appear under Ungrouped')
await dialog.getByRole('button', { name: 'Delete workspace' }).click()
await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
await expect.poll(
() => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(),
{ timeout: 10_000 },
).toBe(0)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await reuseFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0)
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
// Restore the deleted-registry state so reload still verifies deletion
// persistence independently of the successful re-registration above.
if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
await scaffold.ctx.workspace.delete(reregistered.id)
await expect.poll(
() => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
{ timeout: 10_000 },
).toBe(0)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 15_000 },
).toBe(1)
expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
expect(transientSlotErrors).toEqual([])
expect(slotConsoleErrors).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('reuses a deleted title for a different new directory without any transient error surface', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title'))
const title = 'same-name'
const oldPath = join(scaffold.workspaceCwd, 'adopted', title)
await mkdir(oldPath, { recursive: true })
const transientErrors: string[] = []
const consoleErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => {
if (!transientErrors.includes(message)) transientErrors.push(message)
})
await page.evaluate(() => {
const target = window as unknown as {
recordDshTransientWorkspaceError(message: string): Promise<void>
}
const collect = (): void => {
for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error], [role="alert"]')) {
const message = node.dataset.slotError ?? node.textContent?.trim() ?? ''
if (message !== '') void target.recordDshTransientWorkspaceError(message)
}
}
new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
collect()
})
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const adopt = page.getByRole('dialog', { name: 'Use an existing folder' })
await adopt.getByLabel('Existing folder path').fill(oldPath)
await adopt.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0)
const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath)
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
await oldRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
await page.getByRole('dialog', { name: 'Delete workspace' })
.getByRole('button', { name: 'Delete workspace' }).click()
await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const create = page.getByRole('dialog', { name: 'Create a new workspace' })
await create.getByLabel('New workspace name').fill(title)
await create.getByRole('button', { name: 'Create workspace' }).click()
await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
expect(fresh?.id).toBeDefined()
expect(fresh?.id).not.toBe(oldWorkspace.id)
expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title))
expect(transientErrors).toEqual([])
expect(consoleErrors).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches to the flat "In one list" view and persists the preference', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat'))
// Grouped default: workspace group rows render (the seeded session sits
@@ -134,12 +332,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
// (the card opens after a 500ms hover delay, portaled to body).
await page.getByText('Ungrouped', { exact: true }).click()
// A cold summary carries no durable title, so the row falls back to a
// cwd-derived display title — anchored on the run-local workspace-root
// basename rather than a literal.
const wsBase = scaffold.workspaceCwd.split('/').pop()!
const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first()
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
// Initial-current auto-expansion can race this following test's gesture;
// converge on expanded rather than assuming which update wins first.
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
await page.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// The only visible child is the non-blank persisted Session; the blank
// Session created while adopting the Workspace remains hidden.
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only