From 0d949cfe6788cace1736d1f61482a061f94a1988 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:47:54 +0800 Subject: [PATCH 1/7] feat(install): choose web or tui after setup --- README.i18n.yaml | 4 +- README.md | 8 +- README.zh.md | 8 +- apps/cli/tests/install-script.spec.ts | 147 ++++++++++++++++++++++++++ scripts/install.sh | 44 ++++++-- 5 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 apps/cli/tests/install-script.spec.ts diff --git a/README.i18n.yaml b/README.i18n.yaml index b492ed9c37..c37e24e075 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: b447c9634189353854e8be9d0bf597a8b0c7e371 -README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 +README.md: f5367578570a7b4ec7bad13be3b801ac7870de6c +README.zh.md: b9f593857d2a65b5df61983a640022be605ee93b diff --git a/README.md b/README.md index b447c96341..f536757857 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install `dsh` with one command: curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds its frontend first. The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. @@ -30,14 +30,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~ ### Web UI -For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI: +For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the frontend and run: ```sh -(cd ~/.dsh/source/current && pnpm run build) +(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend run build) dsh web ``` -The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. +The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. ### TUI diff --git a/README.zh.md b/README.zh.md index f8bbbc36bc..b9f593857d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建其前端。 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 @@ -30,14 +30,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m ### Web UI -推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI: +推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建前端并运行: ```sh -(cd ~/.dsh/source/current && pnpm run build) +(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend run build) dsh web ``` -完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 ### TUI diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts new file mode 100644 index 0000000000..63bb0c307f --- /dev/null +++ b/apps/cli/tests/install-script.spec.ts @@ -0,0 +1,147 @@ +import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { afterEach, describe, expect, it } from 'vitest' + +const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url)) +const fixtures: string[] = [] + +const PTY_DRIVER = String.raw` +import errno, json, os, pty, select, signal, sys, time +script, cwd, env_json, actions_json = sys.argv[1:] +env = os.environ.copy() +env.update(json.loads(env_json)) +actions = json.loads(actions_json) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe("sh", ["sh", script], env) + +output = bytearray() +action_index = 0 +deadline = time.monotonic() + 15 +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + output.extend(chunk) + while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: + os.write(fd, actions[action_index]["send"].encode()) + action_index += 1 + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if action_index != len(actions): + sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n") + sys.exit(124) +sys.exit(os.waitstatus_to_exitcode(status)) +` + +interface Action { + readonly waitFor: string + readonly send: string +} + +interface Fixture { + readonly binDirectory: string + readonly launchLog: string + readonly pnpmLog: string + readonly root: string + readonly script: string +} + +afterEach(async () => { + await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) })) +}) + +function executable(path: string, content: string): void { + writeFileSync(path, content) + chmodSync(path, 0o755) +} + +async function createFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-install-')) + fixtures.push(root) + const scriptsDirectory = join(root, 'scripts') + const sourceBinDirectory = join(root, 'bin') + const fakeBinDirectory = join(root, 'fake-bin') + const binDirectory = join(root, 'path-bin') + for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) { + mkdirSync(directory, { recursive: true }) + } + const script = join(scriptsDirectory, 'install.sh') + copyFileSync(installer, script) + const launchLog = join(root, 'launch.log') + const pnpmLog = join(root, 'pnpm.log') + executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n') + executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh +if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi +printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG" +`) + writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n') + return { binDirectory, launchLog, pnpmLog, root, script } +} + +async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise { + const result = await execa('python3', [ + '-c', + PTY_DRIVER, + fixture.script, + fixture.root, + JSON.stringify({ + DSH_BIN_DIR: fixture.binDirectory, + DSH_HOME: join(fixture.root, 'home/.dsh'), + DSH_TEST_LAUNCH_LOG: fixture.launchLog, + DSH_TEST_PNPM_LOG: fixture.pnpmLog, + HOME: join(fixture.root, 'home'), + PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`, + }), + JSON.stringify(actions), + ], { reject: false, stripFinalNewline: false, timeout: 20_000 }) + expect(result.exitCode, result.stderr).toBe(0) + return result.stdout +} + +describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => { + it('builds and launches the Web UI when the default choice is accepted', async () => { + const fixture = await createFixture() + + const output = await runInstaller(fixture, [ + { waitFor: 'Replace it?', send: '\n' }, + { waitFor: 'Choose an interface [1/2]:', send: '\n' }, + ]) + + expect(output).toContain('launching Web UI') + expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n--filter @deepseek-ai/dsh-frontend run build\n') + expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n') + }) + + it('rejects an unknown choice, then launches the TUI without building', async () => { + const fixture = await createFixture() + + const output = await runInstaller(fixture, [ + { waitFor: 'Replace it?', send: '\n' }, + { waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' }, + { waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' }, + ]) + + expect(output).toContain('launching TUI') + expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n') + expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n') + }) +}) diff --git a/scripts/install.sh b/scripts/install.sh index 41d5c749c1..9e110e32cf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,15 +7,16 @@ # ~/.dsh/source/master), adds a per-install staging worktree at # ~/.dsh/source/staging- on branch dsh-staging/, checks # host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs -# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source -# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink +# `pnpm install`, points the stable `~/.dsh/source/current` symlink # at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, # records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, -# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps -# successive upgrades in one place instead of scattered sibling clones, and lets -# staging worktrees share the master clone's object store. The PATH symlink -# resolves through `current`, so an upgrade repoints one stable symlink instead -# of relinking PATH: the `dsh` on PATH never moves and can never dangle. +# and lets you launch the Web UI or TUI. The Web choice builds the frontend +# artifact first; the TUI runs directly from TypeScript source through the repo's +# own tsx. Keeping every checkout under ~/.dsh/source keeps successive upgrades +# in one place instead of scattered sibling clones, and lets staging worktrees +# share the master clone's object store. The PATH symlink resolves through +# `current`, so an upgrade repoints one stable symlink instead of relinking PATH: +# the `dsh` on PATH never moves and can never dangle. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather # than `curl ... | sh`) it reuses that checkout in place and skips the @@ -343,12 +344,33 @@ if [ "${SKIP_CREDS:-0}" != 1 ]; then fi fi -# --- 6. launch ----------------------------------------------------------------- +# --- 6. choose and launch an interface ----------------------------------------- step "Done" if [ "$HAS_TTY" = 1 ]; then - info "launching dsh — run 'dsh' anytime to start again" - exec "$DSH_BIN_DIR/dsh" Date: Fri, 31 Jul 2026 19:57:51 +0800 Subject: [PATCH 2/7] fix(install): build workspace artifacts before web --- README.i18n.yaml | 4 ++-- README.md | 6 +++--- README.zh.md | 6 +++--- apps/cli/tests/install-script.spec.ts | 2 +- scripts/install.sh | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index c37e24e075..c999eac01a 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: f5367578570a7b4ec7bad13be3b801ac7870de6c -README.zh.md: b9f593857d2a65b5df61983a640022be605ee93b +README.md: e297ff32c4086df0fcc17310dabbebfaae2691ca +README.zh.md: 5995acdf8d1c5a9aab5dcae39f3ab1e3c8b0a679 diff --git a/README.md b/README.md index f536757857..e297ff32c4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install `dsh` with one command: curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds its frontend first. +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds the required repository artifacts first. The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. @@ -30,10 +30,10 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~ ### Web UI -For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the frontend and run: +For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run: ```sh -(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend run build) +(cd ~/.dsh/source/current && pnpm run build) dsh web ``` diff --git a/README.zh.md b/README.zh.md index b9f593857d..5995acdf8d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建其前端。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建所需的仓库产物。 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 @@ -30,10 +30,10 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m ### Web UI -推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建前端并运行: +推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行: ```sh -(cd ~/.dsh/source/current && pnpm --filter @deepseek-ai/dsh-frontend run build) +(cd ~/.dsh/source/current && pnpm run build) dsh web ``` diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts index 63bb0c307f..8c25ff6891 100644 --- a/apps/cli/tests/install-script.spec.ts +++ b/apps/cli/tests/install-script.spec.ts @@ -127,7 +127,7 @@ describe.runIf(process.platform !== 'win32')('one-line installer interface choic ]) expect(output).toContain('launching Web UI') - expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n--filter @deepseek-ai/dsh-frontend run build\n') + expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n') expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n') }) diff --git a/scripts/install.sh b/scripts/install.sh index 9e110e32cf..0525da9e34 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,11 +10,11 @@ # `pnpm install`, points the stable `~/.dsh/source/current` symlink # at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, # records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, -# and lets you launch the Web UI or TUI. The Web choice builds the frontend -# artifact first; the TUI runs directly from TypeScript source through the repo's -# own tsx. Keeping every checkout under ~/.dsh/source keeps successive upgrades -# in one place instead of scattered sibling clones, and lets staging worktrees -# share the master clone's object store. The PATH symlink resolves through +# and lets you launch the Web UI or TUI. The Web choice builds the repository +# artifacts first; the TUI runs directly from TypeScript source through the +# repo's own tsx. Keeping every checkout under ~/.dsh/source keeps successive +# upgrades in one place instead of scattered sibling clones, and lets staging +# worktrees share the master clone's object store. The PATH symlink resolves through # `current`, so an upgrade repoints one stable symlink instead of relinking PATH: # the `dsh` on PATH never moves and can never dangle. # @@ -353,8 +353,8 @@ if [ "$HAS_TTY" = 1 ]; then LAUNCH_INTERFACE=$(ask "Choose an interface [1/2]:" 1) case "$LAUNCH_INTERFACE" in 1|web|Web|WEB) - step "Building Web UI" - ( cd "$DSH_STAGING" && pnpm --filter @deepseek-ai/dsh-frontend run build ) + step "Building DeepSeek Harness for Web UI" + ( cd "$DSH_STAGING" && pnpm run build ) info "launching Web UI — run 'dsh web' anytime to start again" exec "$DSH_BIN_DIR/dsh" web Date: Fri, 31 Jul 2026 20:54:23 +0800 Subject: [PATCH 3/7] fix(web): single-flight goal clear --- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 4 +- .../feature/2026-07-22-docked-web-goal-bar.md | 5 +- .../2026-07-22-docked-web-goal-bar.zh.md | 5 +- apps/web/tests/goal-bar.e2e.ts | 71 +++++++++++++++++++ apps/web/tests/goal-bar.overlay.yml | 5 ++ .../snapshots/goal-bar/active.expected.md | 8 +++ packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 3 +- packages/client/ui-goal/README.zh.md | 3 +- .../client/ui-goal/src/client/GoalBar.tsx | 47 ++++++------ .../client/ui-goal/tests/goalbar.spec.tsx | 27 ++++++- 11 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 apps/web/tests/goal-bar.e2e.ts create mode 100644 apps/web/tests/goal-bar.overlay.yml create mode 100644 apps/web/tests/snapshots/goal-bar/active.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index 176211d25b..7a233e76ec 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: f014da61d2fa0bf25121c040dae99354ab15de9d -2026-07-22-docked-web-goal-bar.zh.md: f62c6efbb2d330fb7d5ab74138eb781f1a1bc06c +2026-07-22-docked-web-goal-bar.md: 30f1d45e80cb2759175948f5683b499720ab50f0 +2026-07-22-docked-web-goal-bar.zh.md: 4c8481e64d9e5177a962f10ab1d043e761d07545 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index f014da61d2..30f1d45e80 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -12,7 +12,7 @@ The web UI had no goal surface at all: the goal stack shipped with model tools, `GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. -Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. +Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. Every mutation first acquires a synchronous component-local single-flight latch because React's pending-state render cannot close the same-frame click window. A successful clear also suppresses that exact goal id immediately while the authoritative null projection catches up, so an acknowledged tombstone cannot leave a stale clear control that submits `GOAL_NOT_FOUND`; a failure releases the latch and remains retryable. An effect keyed on the goal's id resets this transient state and drops the edit form when the goal's identity changes, so neither a cleared marker nor a surviving draft can affect the replacement goal. `GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. @@ -22,7 +22,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc ## Testing -`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. +`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, rapid same-frame clear clicks dispatch once and a successful clear hides before projection convergence, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible and retryable in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. ## Alternatives considered @@ -34,6 +34,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc ## Consequences - Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. +- Goal mutations are single-flight within the component; a successful clear hides its exact goal immediately while projection delivery converges, preventing duplicate CAS errors without making transient UI state authoritative. - The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). - Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index f62c6efbb2..4c8481e64d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -12,7 +12,7 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T `GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 -可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 +可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。每次变更都会先取得一个同步的组件内 single-flight 锁,因为 React 的 pending 状态渲染无法关闭同一帧内的点击窗口。清除成功后还会立即抑制该 goal id,直到权威的 null 投影追上,因此已确认的墓碑不会留下陈旧的清除控件并再次提交 `GOAL_NOT_FOUND`;失败则释放锁,并且仍可重试。一个以目标 id 为键的 effect 会在目标身份变化时重置瞬态状态并丢弃编辑表单,因此无论已清除标记还是存留草稿,都不会影响替换目标。 `GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 @@ -22,7 +22,7 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T ## 测试 -`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 +`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;同一帧内快速连续点击清除只会分发一次,清除成功后横条会在投影收敛前隐藏;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中且可重试。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 ## 考虑过的替代方案 @@ -34,6 +34,7 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T ## 后果 - Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 +- 目标变更在组件内走 single-flight;清除成功后会在投影投递收敛期间立即隐藏与其 id 完全匹配的目标,既防止重复 CAS 错误,又不会把瞬态 UI 状态视为权威。 - 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 - 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts new file mode 100644 index 0000000000..00260a79ee --- /dev/null +++ b/apps/web/tests/goal-bar.e2e.ts @@ -0,0 +1,71 @@ +// Keyless assembled-browser coverage for the goal bar over the shipped Web +// bundles and FixtureApiClient wire. The command creates a real projected +// goal in the fixture session; the golden pins the active strip, while the +// clear gesture proves the acknowledged tombstone leaves neither stale chrome +// nor a duplicate-mutation error. +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 { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url)) +const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md') +const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: goal bar clear convergence', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('renders one active goal and clears it without exposing a stale error', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) + // Startup reuses the fixture workspace's blank session, keeping this + // command independent of alpha's running replay and pending question. + const input = page.getByPlaceholder('Describe what you want to build') + await input.waitFor({ timeout: 10_000 }) + await input.fill('/goal guard rapid clear clicks') + await input.press('Enter') + + const bar = page.locator('[data-goal-bar]') + await bar.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE) + + const clear = bar.getByRole('button', { name: 'Clear goal' }) + await clear.evaluate((button) => { + const control = button as HTMLButtonElement + control.click() + control.click() + }) + await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0) + expect(await page.getByText(/no current goal/iu).count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md']) + }) +}) diff --git a/apps/web/tests/goal-bar.overlay.yml b/apps/web/tests/goal-bar.overlay.yml new file mode 100644 index 0000000000..2594d6a3e9 --- /dev/null +++ b/apps/web/tests/goal-bar.overlay.yml @@ -0,0 +1,5 @@ +# The client-side FixtureApiClient intentionally rejects settings writes, so +# this goal-only scenario omits the durable welcome step that would otherwise +# cover the page. Onboarding owns separate assembled-browser coverage. +- id: ui-settings-general + disabled: true diff --git a/apps/web/tests/snapshots/goal-bar/active.expected.md b/apps/web/tests/snapshots/goal-bar/active.expected.md new file mode 100644 index 0000000000..e6146d8a54 --- /dev/null +++ b/apps/web/tests/snapshots/goal-bar/active.expected.md @@ -0,0 +1,8 @@ +- img +- text: Ongoing Goal guard rapid clear clicks +- button "Pause goal": + - img +- button "Edit goal": + - img +- button "Clear goal": + - img diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 191dade4fa..d8611b7695 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2 -README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9 +README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46 +README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 2c109ab1fb..cfb54fd280 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. @@ -17,4 +17,3 @@ None beyond the goal mutation's own context event, which appends to the log tail ## Known Limitations and Deferred Work - **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it. -- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index b81113c675..fd999cf1c4 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 +Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 @@ -17,4 +17,3 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input ## Known Limitations and Deferred Work - **只反映持久 phase** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。 -- **暂缺 keyless 快照** —— 组装应用级 transcript(boot → 投影 → GoalBar)推迟到落地 PR 记录的评审后收口批次。 diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 584b6a5a1a..ca569dff6d 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -8,7 +8,7 @@ * the injected face. */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, @@ -35,6 +35,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar const [draft, setDraft] = useState('') const [pending, setPending] = useState(false) const [actionError, setActionError] = useState(null) + const [clearedGoalId, setClearedGoalId] = useState(null) + const pendingRef = useRef(false) // A new goal identity (cleared/completed/replaced externally) invalidates the local edit // state: without the reset a surviving draft's Enter would write over the NEW goal. @@ -42,32 +44,37 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar useEffect(() => { setEditing(false) setActionError(null) + setClearedGoalId(null) }, [goalId]) + // React state disables the controls on the next render; the ref closes the + // same-render window so rapid clicks cannot submit the same CAS twice. + const runAction = useCallback(async (action: () => Promise): Promise => { + if (pendingRef.current) return undefined + pendingRef.current = true + setPending(true) + setActionError(null) + const result = await action() + pendingRef.current = false + setPending(false) + if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`) + return result + }, []) + const handleEdit = useCallback(async () => { const trimmed = draft.trim() if (trimmed === '') return - setPending(true) - setActionError(null) - const result = await onEdit(trimmed) - setPending(false) - if (result.ok) { - setEditing(false) - } else { - setActionError(`${result.error.message} (${result.error.code})`) - } - }, [draft, onEdit]) + const result = await runAction(() => onEdit(trimmed)) + if (result?.ok) setEditing(false) + }, [draft, onEdit, runAction]) - const runAction = useCallback(async (action: () => Promise) => { - setPending(true) - setActionError(null) - const result = await action() - setPending(false) - if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`) - }, []) + const handleClear = useCallback(async (clearedId: GoalSnapshot['id']) => { + const result = await runAction(onClear) + if (result?.ok) setClearedGoalId(clearedId) + }, [onClear, runAction]) // Loading, absent, and complete goals have no strip at all. - if (goal === undefined || goal === null || goal.phase === 'complete') return null + if (goal === undefined || goal === null || goal.phase === 'complete' || goal.id === clearedGoalId) return null if (editing) { return ( @@ -142,7 +149,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar > - diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index c1a0b2c83e..efed54e19e 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -3,13 +3,13 @@ // inline edit form, and resume/clear icon actions — driven purely through // props, no wire. Loading, absent, and complete goals render nothing. -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GoalBar } from '../src/client/GoalBar.tsx' -import type { GoalBarActions } from '../src/client/slots.ts' +import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts' import { zh } from '../src/client/locales.ts' // The framework-injected t seat, stubbed over the zh dictionaries (the default locale). @@ -61,6 +61,27 @@ describe('GoalBar', () => { expect(actions.onClear).toHaveBeenCalledTimes(1) }) + it('single-flights rapid clear clicks and hides the committed goal before its projection catches up', async () => { + const actions = makeActions() + let resolveClear!: (result: GoalActionResult) => void + actions.onClear.mockImplementation(() => new Promise((resolve) => { resolveClear = resolve })) + const { container, rerender } = render() + const clear = screen.getByRole('button', { name: '清除目标' }) + + act(() => { + clear.click() + clear.click() + }) + expect(actions.onClear).toHaveBeenCalledTimes(1) + expect(clear.disabled).toBe(true) + + await act(async () => { resolveClear({ ok: true }) }) + expect(container.firstChild).toBeNull() + + rerender() + expect(screen.getByText('Next goal')).toBeTruthy() + }) + it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => { const actions = makeActions() render() @@ -180,5 +201,7 @@ describe('GoalBar', () => { fireEvent.click(screen.getByRole('button', { name: '清除目标' })) expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') expect(screen.getByText('Ship the redesign')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '清除目标' })) + await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) }) }) }) From 690ca800b47c1e319570014b99551b36c5e4b659 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 20:55:43 +0800 Subject: [PATCH 4/7] fix(test): assign goal e2e to host program --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c795dc7aef..2bdac5a0d7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -50,6 +50,7 @@ "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", + "tests/goal-bar.e2e.ts", "tests/startup-auto-selection.e2e.ts" ], "references": [ diff --git a/tsconfig.host.json b/tsconfig.host.json index bae800f3ff..76719650f0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -37,6 +37,7 @@ "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", + "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", From 277c540a9235dd8d794473ee166cb63dda2d45ae Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 22:40:22 +0800 Subject: [PATCH 5/7] test(web): refresh preserved queue snapshot --- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 008fa0fb66..335610097d 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" From 90c56353556deb8db48461064c34f57fa368fac7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:12:22 -0700 Subject: [PATCH 6/7] fix(cli): point missing frontend dist at full build --- apps/cli/src/app-cli-entry.ts | 2 +- apps/web/tests/support.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 54ade122c7..218df73530 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -324,7 +324,7 @@ export class AppCLIEntry { try { return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') } catch { - throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first') } } } diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 4ccc18de9f..e42628c15b 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -35,7 +35,7 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise

Date: Sat, 1 Aug 2026 19:35:43 +0800 Subject: [PATCH 7/7] test: refresh translation prompt for installer docs --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d6aa4708ea..6b5062c0bd 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds the required repository artifacts first.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建所需的仓库产物。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user",