Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
creatixchu
2026-07-31 18:18:07 +08:00
252 changed files with 5337 additions and 2509 deletions

View File

@@ -87,8 +87,7 @@
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js >-
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
root: !!js dshHomePath('sessions')
# TUI consumes this shared session capability. Its launcher supplies a unique
# process-local path; other surfaces repoint or disable the row in their

View File

@@ -119,8 +119,7 @@
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: !!js >-
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'storages') })()
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'

View File

@@ -56,13 +56,13 @@ switch (invocation.mode) {
break
}
case 'meta': {
const { runMeta } = await import('./tui.ts')
await runMeta()
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(undefined, undefined, SOURCE_ROOT)
break
}
case 'upgrade': {
const { runSkillSession } = await import('./tui.ts')
await runSkillSession(`dsh-${invocation.mode}`)
const { runTui } = await import('./tui.ts')
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
break
}
default:

View File

@@ -8,12 +8,10 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta`
* ({@link runMeta}) is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
* session whose first turn auto-invokes a bundled skill. After boot, the
* agent's system prompt is told the path to this harness checkout so it can
* find its own source.
* `dsh meta` is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a
* bundled skill. After boot, the agent's system prompt is told the path to this
* harness checkout so it can find its own source.
* @module @deepseek-ai/dsh/tui
*/
@@ -61,30 +59,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the CLI PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI with this harness checkout as the workspace
* (`dsh meta`), whatever directory it was launched from.
*/
export async function runMeta(): Promise<void> {
return runTui(undefined, undefined, SOURCE_ROOT)
}
/**
* Run the interactive TUI as a guided fresh session whose first turn invokes a
* bundled skill (`dsh upgrade` → `dsh-upgrade`).
* Always mints a fresh session in the invoking directory; the skill is seeded
* only on this first launch, so a later `--resume` of the session is an ordinary
* TUI session with no re-injection.
* @param skill - the bundled skill name to auto-invoke as the first turn.
*/
export async function runSkillSession(skill: string): Promise<void> {
return runTui(undefined, undefined, undefined, skill)
}
/**
* Run the interactive TUI from the invoking directory.
* @param config - an overlay patch list applied over the shared base and the
@@ -99,9 +78,8 @@ export async function runSkillSession(skill: string): Promise<void> {
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
* on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume,
* so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.

View File

@@ -2,6 +2,7 @@
// the same locale-aware, in-page risk confirmation. Zero model calls: the
// scenario boots the shipped Web composition and exercises the real
// permission projection, client command path, HTTP RPC, and pushed update.
import { mkdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -11,7 +12,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
@@ -19,13 +20,16 @@ import { saveFailureShot } from './support.ts'
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
await page.getByRole('menuitem', { name: '新建工作区' }).click()
const dialog = page.getByRole('dialog', { name: '新建工作区' })
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('新工作区名称').fill(name)
await dialog.getByRole('button', { name: '创建工作区' }).click()
await dialog.getByRole('button', { name: '编辑路径' }).click()
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
@@ -49,11 +53,11 @@ describe('web e2e: Full access confirmation', () => {
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
// Keep the product default Chinese locale: the golden pins the actual
// registered dictionary rather than a test-local translation callback.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page)
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -123,11 +123,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
}, { timeout: 10_000 })
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
// trailing newline terminates its line, so the footer reads `+1` (not a
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
// it is absent from textContent — assert on the line body and the footer.
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/
// write row first. The write turn's `hello fixture\n` proves the terminator
// rule end to end: a trailing newline terminates its line, so the footer reads
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
// ::before, so it is absent from textContent — assert on the line body and the
// footer.
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
expect(mutationRows.length).toBeGreaterThan(0)
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
if (toggle !== null) act(() => { fireEvent.click(toggle) })
}
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
@@ -136,13 +144,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
// registration, wire projection, and card rendering survive the real bundle
// path (not just the per-package src benches). The selector pins the KEYED
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
// keyed-registration failure would still satisfy a bare `[data-web]` check.
// path (not just the per-package src benches). WebRow composes ToolRow, so the
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
// (ToolRow sets it from the wire tool name).
const webSearchRow = await waitFor(() => {
const row = document.querySelector('[data-tool="web_search"]')
expect(row).not.toBeNull()
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
return row!
}, { timeout: 10_000 })
// Expand the web_search row to prove its WebBlock card renders end to end.
const webToggle = webSearchRow.querySelector('[data-expandable]')
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
await waitFor(() => {
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
}, { timeout: 10_000 })
// Every bundle injected its plugin-owned style tag (the loader's CSS path).

View File

@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -232,7 +232,7 @@ describe('web e2e: composer draft scrolling', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, 'composer-draft-scroll')
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
await page.locator('textarea:enabled').first().fill(DRAFT)
}, 180_000)

View File

@@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -50,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
@@ -93,7 +93,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
try {
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage)
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
const input = activePage.locator('textarea').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
@@ -172,10 +172,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Host: the session's durable header cwd is the workspace flow's
// create-by-name target (<workspaceRoot>/workspace, the composer's
// default draft name) — the proof the send went through workspace
// materialization rather than a bare default-cwd session.
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
// default-cwd session.
const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')

View File

@@ -96,7 +96,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}
/**

View File

@@ -20,7 +20,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
@@ -37,7 +37,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -43,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
// the read targets must live in that session cwd (pre-creation is safe:
// create-by-name adopts an existing directory).
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')

View File

@@ -12,7 +12,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
@@ -34,7 +34,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
page.on('console', message => browserConsole.push(message.text()))
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })

View File

@@ -79,7 +79,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -0,0 +1,11 @@
# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
# in-browser directory picker. The shipped row is `-auto`, which resolves to
# the native OS chooser on a loopback bind with a local display — an
# interaction a Playwright page cannot drive, so the resolved backend would
# otherwise decide whether the smoke passes. The disable+insert pair mirrors
# apps/web/tests/scaffold.ts.
- id: directory-picker
disabled: true
- insert:
- id: directory-picker-browse
name: '@deepseek-ai/dsh-host-directory-picker-browse'

View File

@@ -54,7 +54,7 @@ describe('web e2e: plan review takeover round trip', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -49,7 +49,7 @@ describe('web e2e: resident question composer round trip', () => {
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -68,7 +68,7 @@ describe('web e2e: queue row actions', () => {
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('textarea').first()

View File

@@ -48,7 +48,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -11,13 +11,14 @@
// masking its credential, without making a model call.
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches after the shipped surface overlay: temp persistenceRoot; local skill
// roots confined to the temp workspace; workspace-context disabled (recorded
// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled
// (its fire-and-forget title call would race the loop for the session's replay
// cursor); webserver pinned to port 0 with the built dist; ordinary keyless
// modes disable llm-deepseek and fill the open llm seam post-boot with
// installLlmReplay on the settled root ctx
// patches after the shipped surface overlay, over the SAME tree (never a
// second yml): temp persistenceRoot; host-level skill roots confined to the
// temp workspace while project skill discovery remains real; workspace-context
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
// session-title-llm disabled (its fire-and-forget title call would race the
// loop for the session's replay cursor); webserver pinned to port 0 with the
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
// llm seam post-boot with installLlmReplay on the settled root ctx
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync } from 'node:fs'
@@ -32,6 +33,7 @@ import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
@@ -281,6 +283,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
// This direct Loader harness supplies the same root-path capability as app-boot.
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
// The shipped CLI deliberately has no dependency on this opt-in package.

View File

@@ -145,15 +145,21 @@ describe('assembled search card', () => {
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
// The grep turn's keyed SearchRow renders the card resident: wait for it.
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
// by default, so wait for the summary row, then expand it to reach the card.
await waitFor(() => {
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
}, { timeout: 10_000 })
// `data-tool` sits on the summary row; the card and recovery footer are its
// siblings inside the SearchRow wrapper, so shape the wrapper (its parent).
const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement!
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
// toggle. Click it so the card and its recovery footer mount, then shape the
// whole row (the card lives inside ToolRow's body wrapper).
const grepRow = document.querySelector('[data-tool="grep"]')!
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
await waitFor(() => {
expect(grepRow.querySelector('[data-search]')).not.toBeNull()
}, { timeout: 10_000 })
const shape = cardShape(grepRow)
if (refreshing) {
mkdirSync(dirname(EXPECTED), { recursive: true })

View File

@@ -1,11 +1,12 @@
// Web e2e scenario: seeded history. A recorded session seeded cold through
// the REAL persistence API renders purely from the log — the surface nothing
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client fold of historical
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
// host, so the settled row's copy has a golden here. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
@@ -34,6 +35,90 @@ const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* @param raw - the committed seed fixture text.
* @returns the fixture with a compacted turn appended.
*/
function withCompaction(raw: string): string {
const lines = raw.trimEnd().split('\n')
const events = lines.slice(1).map(line => JSON.parse(line) as {
type: string
seq: number
time: number
surfaceOp?: unknown
data?: { turn?: unknown }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
const tail = events.at(-1)
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const summarySeq = at({
type: 'compact/summary',
data: {
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
provider: 'snapshot',
model: 'snapshot-compactor',
},
})
at({
type: 'user/message',
data: {
content: [{
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
describe('web e2e: seeded history renders through cold resume', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -42,7 +127,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
// (the composer's default draft name); the read-tool targets must live in
// that session cwd. Pre-creating the directory is safe: create-by-name
// adopts an existing directory.
@@ -53,7 +138,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(raw), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -119,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
@@ -230,6 +319,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
timeout: 5_000,
}).toBe(1)
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
// Restore the shared page state for any later case.
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no

View File

@@ -18,7 +18,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
@@ -33,7 +33,9 @@ describe('web e2e: settings modal and General preferences', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
// Chinese browser: the shared page asserts the localized settings surface
// the client derives from it (the English default has its own spec below).
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -215,6 +217,30 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('opens an English browser in English without any stored preference', async () => {
// A second page under a different browser language: nothing is persisted
// for it, so the settings surface must follow the browser rather than the
// product fallback the shared zh page shows.
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
const enTripwire = watchConsole(enPage)
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
try {
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
// This page has no closing inventory spec to sweep its console, so the
// scenario clears both tripwire channels itself.
expect(enTripwire.pageErrors).toEqual([])
expect(enTripwire.warnings).toEqual([])
} finally {
await enPage.close()
}
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])

View File

@@ -84,7 +84,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -20,7 +20,7 @@ import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -472,7 +472,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
[
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
// Pin the in-browser picker: the shipped `-auto` row would resolve to
// the native OS chooser on this bind, and no page can drive that.
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
],
{
cwd: sessionsDir,
env: {
@@ -513,8 +518,18 @@ 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'))
// This scenario spawns its own server against a fresh $DSH_HOME, so the
// first-run welcome notice is unacknowledged and its overlay owns pointer
// events (the shared scaffold acknowledges it before boot instead). The
// notice is anchored structurally, not by its copy: this spec sits in the
// client TypeScript program, which does not reference the package that
// owns the strings.
const welcome = page.locator('[class*="onboardingOverlay"]')
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button').click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, sessionsDir)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')

View File

@@ -22,8 +22,10 @@
- img
- text: Code Run bash echo and catch missing file read
- img
- text: Bash Echo CODE_ROUND_OK 失败 Read
- button "missing.txt"
- text: Bash Echo CODE_ROUND_OK Failed
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
- img
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img

View File

@@ -14,12 +14,16 @@
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- img
- text: Read
- button "a.txt"
- img
- text: Read
- button "b.txt"
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img

View File

@@ -1,10 +1,9 @@
- region "内测声明":
- heading "内测声明" [level=2]
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。
- paragraph: 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
- paragraph:
- text: 我们尤其希望听见那些失败、困惑与不顺手的时刻——
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
- button "继续"

View File

@@ -5,7 +5,23 @@ file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
line=138: export function SearchBlock(props: SearchBlockProps) {
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
line=73: const search = searchCardModel(block)
line=90: <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
expand=… 其余 4 行
line=35: const search = searchCardModel(block)
line=52: search={search}
line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
recovery=Found 9 of 42 matches
packages/client/ui-primitives/src/SearchBlock.tsx
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
Line 138: export function SearchBlock(props: SearchBlockProps) {
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
packages/client/ui-conversation/src/client/contract/search-card-model.ts
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
Line 35: const search = searchCardModel(block)
Line 52: search={search}
Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)

View File

@@ -13,12 +13,16 @@
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- img
- text: Read
- button "a.txt"
- img
- text: Read
- button "b.txt"
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
@@ -29,6 +33,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img

View File

@@ -13,12 +13,16 @@
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- img
- text: Read
- button "a.txt"
- img
- text: Read
- button "b.txt"
- button "Read a.txt":
- img
- img
- text: Read
- button "a.txt"
- button "Read b.txt":
- img
- img
- text: Read
- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
@@ -29,6 +33,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img

View File

@@ -17,6 +17,7 @@
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- text: Running
- button "Ask question waiting":
- img
- img

View File

@@ -13,13 +13,10 @@
- img
- img
- text: Context injection
- img
- text: Search DeepSeek Harness snapshot search
- list:
- listitem:
- link "Snapshot Search Result":
- /url: https://docs.example.test/search
- text: Snapshot search excerpt. 2026-07-31
- button "Search DeepSeek Harness snapshot search":
- img
- img
- text: Search DeepSeek Harness snapshot search
- paragraph: SEARCH_DONE
- button "Copy":
- img

View File

@@ -72,7 +72,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
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)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -1,6 +1,7 @@
// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
import { existsSync, mkdirSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -9,11 +10,18 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/**
* Browser language a page must advertise to boot into the product's Chinese
* surface: with no stored preference the client derives its initial locale
* from the browser, and Playwright's default browser asks for English.
*/
export const ZH_BROWSER_LOCALE = 'zh-CN'
/**
* Open the standard browser-test page with English selected before client
* boot. This keeps role locators and goldens deterministic across localized
* component migrations; the settings locale scenario deliberately bypasses
* this helper to cover the product's default Chinese state.
* component migrations; the scenarios asserting the Chinese surface bypass
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
* @param browser - Playwright browser owning the page.
* @param height - Viewport height; width is fixed to the lane baseline.
* @returns the initialized page.
@@ -48,23 +56,32 @@ 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
* Drive the hero's workspace picker through the composed directory 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
* first. With nothing to list, the chip gesture raises the dialog directly —
* adding a workspace is the picker's only entry. The directory is staged here
* and adopted through the path editor, which is idempotent across the repeated
* connects a scenario may make; creating a folder from inside the dialog (the
* product's other half of the same route) is covered by
* workspace-management.e2e.ts. The default name 'workspace' keeps the session
* header cwd at <root>/workspace, the materialization proof several scenarios
* assert.
* @param page - the page under test.
* @param name - workspace name typed into the create dialog.
* @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
* @param name - folder name staged and adopted as the workspace.
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
await dialog.getByRole('button', { name: 'Edit path' }).click()
const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: 'Open', exact: true }).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"]')

View File

@@ -109,7 +109,7 @@ describe('web e2e: shipped default web search', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {

View File

@@ -1,17 +1,18 @@
// Web e2e scenarios: workspace management — the create-by-name dialog, the
// rename round trip over the real wire (workspace.rename RPC + durable
// registry), duplicate-name pre-check, the flat "In one list" view with its
// persisted group-by preference, the session hover card, and the session
// archive round trip (row menu → workspace.archiveSession RPC → durable
// global set → row hidden across reload). Zero model calls:
// workspace.create/rename/archiveSession are host RPCs with no model
// involvement, and the one session row the flat/hover/archive scenarios need
// comes from a seeded fixture (the seeded-history seed reused verbatim — no
// new recording).
// Web e2e scenarios: workspace management — adding a workspace through the
// composed directory dialog (its own New folder affordance is the product's
// one creation route), the rename round trip over the real wire
// (workspace.rename RPC + durable registry), duplicate-name pre-check, the
// flat "In one list" view with its persisted group-by preference, the session
// hover card and row action menu, and the session archive round trip (row
// menu → workspace.archiveSession RPC → durable global set → row hidden
// across reload). Zero model calls: workspace.create/rename/archiveSession
// are host RPCs with no model involvement, and the one session row the
// flat/hover/menu/archive scenarios need comes from a seeded fixture (the
// seeded-history seed reused verbatim — no new recording).
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 type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -28,29 +29,59 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo
const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled
// to that contract if the shared grace tuning changes.
const POINTER_TRANSIT_MS = 300
const POINTER_HOLD_MS = 600
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
/**
* Drive the in-app browser to a directory via its path-edit affordance,
* confirm it, and wait for the adoption to settle host-side (workspace
* registered + the flow's New-Session agent up), so later test steps can't
* race the in-flight blank-session attach.
* Raise the region header's directory dialog and drive it to a directory via
* the path-edit affordance. Adding is the header button's only action, so
* the click lands in the dialog with no menu in between.
*/
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
async function browseTo(path: string): Promise<Locator> {
await page.getByRole('button', { name: 'Add workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(path)
await dialog.getByLabel('Edit path').press('Enter')
await dialog.getByRole('button', { name: 'Open' }).click()
return dialog
}
/**
* Create a folder inside `parent` through the dialog and adopt it — the
* product's only route to a brand-new workspace directory.
*/
async function addNewFolderWorkspace(parent: string, name: string): Promise<void> {
const dialog = await browseTo(parent)
await dialog.getByRole('button', { name: 'New folder' }).click()
await page.getByLabel('Folder name').fill(name)
await page.getByRole('button', { name: 'Create', exact: true }).click()
// Creating selects the new folder in the listing; Open adopts it.
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(join(parent, name)),
{ timeout: 10_000 },
).not.toBeUndefined()
}
/**
* Adopt an existing directory, waiting for the adoption to settle host-side
* (workspace registered + the flow's New-Session agent up), so later test
* steps can't race the in-flight blank-session attach.
*/
async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
const dialog = await browseTo(path)
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
@@ -86,22 +117,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
await scaffold?.close()
})
it('creates two workspaces by name through the region-header dialog', async () => {
it('adds two workspaces through the dialog, each on a folder it created', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
const createByName = async (name: string): Promise<void> => {
await page.getByRole('button', { name: 'Create workspace' }).click()
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()
await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
const add = async (name: string): Promise<void> => {
await addNewFolderWorkspace(scaffold.workspaceCwd, name)
// The real workspace materializes in the tree as a group row.
await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
}
await createByName('alpha-ws')
await createByName('beta-ws')
// Durable on the host: both registered, newest first (create prepends).
await add('alpha-ws')
await add('beta-ws')
// Durable on the host: both registered, newest first (create prepends),
// each titled after the folder the dialog made.
const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
expect(tripwire.pageErrors).toEqual([])
@@ -167,7 +193,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
// Register the scaffold's existing project directory through the real UI.
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
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))
@@ -225,7 +251,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// 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 openLocalFolder(scaffold.workspaceCwd)
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
@@ -295,7 +321,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
await openLocalFolder(oldPath)
await adoptDirectory(oldPath)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(oldPath),
{ timeout: 10_000 },
@@ -311,12 +337,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
.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 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)
await addNewFolderWorkspace(scaffold.workspaceCwd, title)
const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
expect(fresh?.id).toBeDefined()
expect(fresh?.id).not.toBe(oldWorkspace.id)
@@ -366,13 +387,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
process.env.HOME = scaffold.workspaceCwd
process.env.USERPROFILE = scaffold.workspaceCwd
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(staged)
await dialog.getByLabel('Edit path').press('Enter')
const dialog = await browseTo(staged)
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
@@ -387,14 +402,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows the session hover card after a dwell on the row', async () => {
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).
/**
* Expand Ungrouped and return its seeded session row. The only visible child
* is the non-blank persisted Session; the blank Session created while
* adopting the Workspace stays hidden.
* @returns the session row locator, already present.
*/
async function seededSessionRow() {
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.
// Initial-current auto-expansion can race this 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()
@@ -402,17 +420,72 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
}
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 })
const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
await row.waitFor({ timeout: 10_000 })
return row
}
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Dwell on the seeded row; the card opens after a 500ms hover delay,
// portaled to body.
const sessionRow = await seededSessionRow()
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only
// card; no aria role — text anchors are the stable selector).
// Card content: the full title plus the Idle status line (no aria role —
// text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
// The card is REACHABLE: it sits 8px off the row, so getting to it means
// crossing ground that belongs to neither. Hovering it must not dismiss
// it — the regression this scenario guards.
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
await card.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
// The full title is the card's primary value: activating anywhere on the
// card writes it through the browser clipboard and localizes the success
// feedback through the English locale seat.
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
const cardHeight = (await card.boundingBox())?.height
await card.click()
const copied = page.getByRole('status').getByText('Copied', { exact: true })
await copied.waitFor({ timeout: 5_000 })
await page.waitForTimeout(POINTER_HOLD_MS)
expect((await card.boundingBox())?.height).toBe(cardHeight)
expect(await copied.isVisible()).toBe(true)
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
// Leaving anchor and card together closes it after the grace.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
const sessionRow = await seededSessionRow()
// The trigger is display:none until its row hovers.
await sessionRow.hover()
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
await trigger.click()
const item = page.getByRole('menuitem', { name: 'Rename' })
await item.waitFor({ timeout: 5_000 })
// Into the list, then back up to the trigger across the 4px gap below it:
// that return trip used to fire the list's pointerleave and close the
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
// the pointer ON the trigger, so entering the list has to come first for
// the return to be a real departure.
await item.hover()
await page.waitForTimeout(POINTER_TRANSIT_MS)
await trigger.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// ...and back down into the list, which must still be there to enter.
await item.hover()
await page.waitForTimeout(POINTER_HOLD_MS)
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
// Pointer-leave dismissal still applies once the pointer genuinely leaves.
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)