From 747c98b0db7806119c28804ac5dee8fe30f152db Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 23 Sep 2026 19:14:19 +0800 Subject: [PATCH 01/14] fix(plugins): deny incompatible bundles and clarify compatibility refusals A bundle package is not a plugin row, so startup admission never read its own DSH peers: a bundle installed outside DSH or before a DSH upgrade still applied its patch layer. loadProfileDirectory now skips such a bundle like an unreadable one unless the profile exempts that exact pair. A post-install refusal in a profile without a lockfile reinstalls from the restored manifest instead of failing a frozen install and leaving the rejected packages in node_modules. Compatibility warnings now name the `dsh plugin allow-version` command for CLI-managed profiles, startup denials name the row id instead of a resolved module URL, and the CLI no longer reports "pnpm failed" when pnpm never ran. --- apps/cli/src/plugin.ts | 2 +- apps/cli/tests/plugin.spec.ts | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- .../app-boot/src/compatibility-preflight.ts | 6 +++-- packages/boot/app-boot/src/index.ts | 4 +-- .../boot/app-boot/src/plugin-compatibility.ts | 9 +++++-- packages/boot/app-boot/src/profile.ts | 13 +++++++--- .../tests/compatibility-preflight.spec.ts | 14 +++++++++++ .../tests/plugin-compatibility.spec.ts | 11 +++++++- packages/boot/app-boot/tests/profile.spec.ts | 25 +++++++++++++++++++ packages/boot/plugin-manager/README.i18n.yaml | 4 +-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- .../boot/plugin-manager/src/operations.ts | 11 +++++--- .../boot/plugin-manager/tests/manager.spec.ts | 12 ++++----- .../plugin-manager/tests/operations.spec.ts | 17 +++++++++++-- 18 files changed, 110 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index a246af7573..fcdfa58532 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -74,7 +74,7 @@ export async function runPlugin(profile: string, args: readonly string[]): Promi onOutput: (text, stream) => { process[stream].write(text) }, }) if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n') - if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\n`) + if (result.exitCode !== 0) process.stderr.write(`dsh: plugin command failed; diagnostics: ${result.logPath}\n`) if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) { process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`) } diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts index f3163d5d3d..4a3ccd10d4 100644 --- a/apps/cli/tests/plugin.spec.ts +++ b/apps/cli/tests/plugin.spec.ts @@ -78,7 +78,7 @@ it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitC expect(await runPlugin('test', ['list'])).toBe(exitCode) expect(stdout).toHaveBeenCalledWith('pnpm output') if (exitCode === 0) expect(stderr).not.toHaveBeenCalled() - else expect(stderr).toHaveBeenCalledWith('dsh: pnpm failed; diagnostics: /profile/log\n') + else expect(stderr).toHaveBeenCalledWith('dsh: plugin command failed; diagnostics: /profile/log\n') }) it('forwards all other commands unchanged and retains package diagnostics', async () => { diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 8a77721112..68ad416e0b 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 729a4b1552cd28e89698580860f8c9e31043a76c -README.zh.md: c401978fdf05651479c8c6b74b01d48287fa2b63 +README.md: c32f8e4dd9065897a7dcbe4b7a2fd2e067b25e46 +README.zh.md: 0bbdab2a5d51deeb6b63bf972e6550f9b4034db1 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 729a4b1552..c32f8e4dd9 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -51,7 +51,7 @@ A profile is how one dsh installation ships different app surfaces: `web`, `head Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code. -Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin still appears there, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust. +Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust. Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index c401978fdf..0bbdab2a5d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -51,7 +51,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。 -检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件仍会出现在其中;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。 +检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。 精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。 diff --git a/packages/boot/app-boot/src/compatibility-preflight.ts b/packages/boot/app-boot/src/compatibility-preflight.ts index 0827b5f54e..bd8fc2059c 100644 --- a/packages/boot/app-boot/src/compatibility-preflight.ts +++ b/packages/boot/app-boot/src/compatibility-preflight.ts @@ -77,7 +77,9 @@ export function prepareProfileEntries( // Admission runs before the composed tree mounts, so no plugin-owned logger exporter exists yet; // composition-stage diagnostics go to stderr like the profile launcher's skipped-bundle report. return preflight(ctx, entries, parentURL, (row, reason) => { - process.stderr.write(`${binName}: disabling profile plugin ${row.name}: ${reason}\n`) + // Preset rows may omit ids, and a bundle row's name is its resolved module URL. + const label = typeof row.id === 'string' ? `row ${JSON.stringify(row.id)}` : row.name + process.stderr.write(`${binName}: disabling profile plugin ${label}: ${reason}\n`) }).rows } @@ -101,7 +103,7 @@ function preflight( const manifest = manifestOf(ctx, row.name, base) if (manifest === undefined) return undefined const issue = evaluatePluginCompatibility(manifest, exemptions) - return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue) + return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue, profile.name) } catch (error) { // Peer metadata that cannot be read or validated is refused rather than silently admitted. /* v8 ignore next -- every reader and parser used here rejects with an Error. */ diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 68e6cc667c..00515b4ad2 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -401,8 +401,8 @@ export interface ConfigDumpLayer { * layer targeting a group child a plain config replacement introduced, which * the single-pass id index never sees) compose identically — then render the * result as YAML in the same dialect (`!!js` expressions print verbatim, - * unevaluated). Admission is a later stage: this dump reports what the profile - * configures, and a plugin the compatibility policy denies still appears here. + * unevaluated). Row admission is a later stage: a plugin row the compatibility + * policy denies still appears here, while a denied bundle contributes no layer. * * Every run of rows from the same file and patch layers is preceded by a `# ==` comment * naming the file that contributed the rows and any layers that patched them, diff --git a/packages/boot/app-boot/src/plugin-compatibility.ts b/packages/boot/app-boot/src/plugin-compatibility.ts index e2e90f10ab..52508e34cb 100644 --- a/packages/boot/app-boot/src/plugin-compatibility.ts +++ b/packages/boot/app-boot/src/plugin-compatibility.ts @@ -90,13 +90,18 @@ export function evaluatePluginCompatibility( /** * Describe incompatible peers, their risk, and the exact-version remedy. * @param issue - incompatible plugin/runtime result, including exempted mismatches. + * @param profile - profile whose composition met the plugin; a CLI-managed profile gets the `dsh plugin` grant command. * @returns a warning suitable for CLI and plugin-management diagnostics. */ -export function pluginCompatibilityWarning(issue: PluginCompatibility): string { +export function pluginCompatibilityWarning(issue: PluginCompatibility, profile?: string): string { const key = `${issue.name}@${issue.version}` + // The Electron application owns the desktop profile, and `dsh plugin` refuses to manage it. + const grant = profile === undefined || profile.toLowerCase() === 'desktop' + ? `grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} through the plugin manager` + : `run \`dsh plugin --profile ${profile} allow-version ${key} --dsh-version ${issue.runtimeVersion} --accept-risk\`` return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. ` + 'Running it may cause crashes or data loss. ' + 'Update the plugin or install a plugin version compatible with this dsh runtime. ' - + `To accept this risk explicitly, add the exact-version exemption ${JSON.stringify({ [key]: [issue.runtimeVersion] })}. ` + + `To accept this risk explicitly, ${grant}, then retry the installation or restart dsh. ` + `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.` } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 305e74aaff..b6257241d8 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -28,6 +28,8 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest' +import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts' +import { readProfileVersionExemptions } from './profile-compatibility.ts' import { loadOverlayPatches } from './index.ts' import { realModuleDirectory } from './profile-resolution/legacy-links.ts' @@ -629,7 +631,8 @@ export function resolveBundleDir( * Load an already initialized profile directory without resolving it through * the shared Harness home. This is used by application-owned profiles whose * package project and lifecycle belong to that application. - * Unreadable bundles are reported on stderr and skipped without changing the manifest. + * Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported + * on stderr and skipped without changing the manifest. * @param binName - the diagnostic prefix on thrown errors. * @param dir - absolute profile package directory. * @param installAnchor - absolute path of the owning dsh app's package.json. @@ -645,6 +648,7 @@ export function loadProfileDirectory( const manifest = readProfileManifest(binName, dir) const bundles = manifest.dsh?.profile?.bundles ?? [] const layers: ProfileLayer[] = [] + const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir) for (const packageName of bundles) { try { const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) @@ -653,6 +657,9 @@ export function loadProfileDirectory( if (bundle === undefined) { throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } + // A bundle is not a plugin row, so row admission never reads its own peers. + const issue = evaluatePluginCompatibility(bundleManifest, exemptions) + if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue, basename(dir))) const patchPaths = bundlePatchPaths(packageDir, bundle) const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath)) layers.push({ packageName, packageDir, patchPaths, patches }) @@ -669,8 +676,8 @@ export function loadProfileDirectory( /** * Load a profile: resolve every `dsh.profile.bundles` entry to its patch - * layer and parse the profile's own patch file. Unreadable bundles are reported - * on stderr and skipped; profile manifest and user patch errors still throw. + * layer and parse the profile's own patch file. Unreadable or incompatible bundles + * are reported on stderr and skipped; profile manifest and user patch errors still throw. * @param binName - the diagnostic prefix on thrown errors. * @param name - the profile name. * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). diff --git a/packages/boot/app-boot/tests/compatibility-preflight.spec.ts b/packages/boot/app-boot/tests/compatibility-preflight.spec.ts index b1836fee84..e753fa5b4d 100644 --- a/packages/boot/app-boot/tests/compatibility-preflight.spec.ts +++ b/packages/boot/app-boot/tests/compatibility-preflight.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { load } from 'js-yaml' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { @@ -289,6 +290,19 @@ describe('manifest resolution', () => { expect(f.warnings.join('\n')).toContain('cannot be validated') }) + it('labels a denied row by its id, or by its module when a preset row omits the id', () => { + const f = fixture() + f.plugin('denied-plugin', { peer: '^9.0.0' }) + const base = pathToFileURL(f.dir).href + '/' + prepareProfileEntries(context(f), [{ id: 'row', name: './node_modules/denied-plugin/index.mjs' }], base) + // A preset declaration may omit the id that the Loader type requires. + prepareProfileEntries(context(f), [{ name: 'denied-plugin' } as EntryOptions], base) + expect(f.warnings).toEqual([ + expect.stringMatching(/^dsh: disabling profile plugin row "row": Plugin denied-plugin@1\.0\.0 /), + expect.stringMatching(/^dsh: disabling profile plugin denied-plugin: Plugin denied-plugin@1\.0\.0 /), + ]) + }) + it('denies a group-shaped row and clears its group flag', () => { const f = fixture() f.plugin('denied-plugin', { peer: '^9.0.0' }) diff --git a/packages/boot/app-boot/tests/plugin-compatibility.spec.ts b/packages/boot/app-boot/tests/plugin-compatibility.spec.ts index b55187eeab..225bb873a6 100644 --- a/packages/boot/app-boot/tests/plugin-compatibility.spec.ts +++ b/packages/boot/app-boot/tests/plugin-compatibility.spec.ts @@ -170,8 +170,17 @@ describe('compatibility warning', () => { + 'peerDependencies {"@deepseek-ai/dsh":"^0.2.0","@deepseek-ai/dsh-session":"broken"}. ' + 'Running it may cause crashes or data loss. ' + 'Update the plugin or install a plugin version compatible with this dsh runtime. ' - + 'To accept this risk explicitly, add the exact-version exemption {"@example/plugin@2.0.0":["0.1.7-alpha.1"]}. ' + + 'To accept this risk explicitly, grant the exact-version exemption for @example/plugin@2.0.0 on dsh 0.1.7-alpha.1 ' + + 'through the plugin manager, then retry the installation or restart dsh. ' + `Exact-version exemption: ${exempted ? 'active' : 'not active'}.`, ) }) + + it('names the dsh plugin grant command for a CLI-managed profile only', () => { + const issue = { ...identity, runtimeVersion: runtime, peers: { '@deepseek-ai/dsh': '^0.2.0' }, exempted: false } + expect(pluginCompatibilityWarning(issue, 'web')).toContain( + 'run `dsh plugin --profile web allow-version @example/plugin@2.0.0 --dsh-version 0.1.7-alpha.1 --accept-risk`, then retry', + ) + expect(pluginCompatibilityWarning(issue, 'Desktop')).toContain('through the plugin manager') + }) }) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 3aec80e6e9..3674c1a2dd 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -15,9 +15,11 @@ import { afterAll, describe, expect, it, onTestFinished, vi } from 'vitest' import { composeEntries, createRuntimeResolution, + getDshRuntimeVersion, initProfile, loadProfile, loadProfileDirectory, + PROFILE_COMPATIBILITY_FILENAME, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, @@ -427,6 +429,29 @@ describe('loadProfile', () => { }, ) + it('skips a bundle whose own dsh peers are incompatible until the profile exempts that exact pair', () => { + const anchor = stageInstallation({ + guarded: { patch: '- insert: [{ id: a, name: pkg-a }]\n' }, + kept: { patch: '- insert: [{ id: b, name: pkg-b }]\n' }, + }) + const manifestPath = join(anchor, '..', 'node_modules', 'guarded', 'package.json') + writeFileSync(manifestPath, JSON.stringify({ + ...JSON.parse(readFileSync(manifestPath, 'utf8')) as object, peerDependencies: { '@deepseek-ai/dsh': '999.0.0' }, + })) + const dir = resolveProfileDir('demo', tmp()) + initProfile(dir, ['guarded', 'kept']) + const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + onTestFinished(() => { warn.mockRestore() }) + + expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['kept']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + `skipping profile bundle "guarded": Error: Plugin guarded@0.0.0 is incompatible with dsh ${getDshRuntimeVersion()}`, + )) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('dsh plugin --profile demo allow-version guarded@0.0.0')) + writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify({ 'guarded@0.0.0': [getDshRuntimeVersion()] })) + expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['guarded', 'kept']) + }) + it('still rejects invalid profile manifests and user patches', () => { const anchor = stageInstallation({}) const dir = tmp() diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 64798be732..9cb2f10b4d 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/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/boot/plugin-manager/README.md -README.md: 4977a59a1a3a313580cb2ddad8c9054c36da7af9 -README.zh.md: fcf4b6dc69689a6c71631bfd51ca3cdba5147f9e +README.md: 2bb8fa493f92a077c63bbefd281b89c1ea2d1be6 +README.zh.md: 005af225a880e4edabfe44e2c8c2df980779ae6e diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 4977a59a1a..2bb8fa493f 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -60,7 +60,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe ### Version compatibility and exemptions -An install command that names packages (`add`, or `install` with specs) is checked before pnpm runs: a local path is read from its own `package.json`, and a registry spec is resolved through pnpm's registry lookup for the version its range selects and the peers that version declares. An incompatible DSH peer rejects the operation before pnpm runs, so nothing is downloaded and no build script runs; a build approval the caller supplied with the request is recorded before this check and remains. A git or tarball spec needs the fetch itself, so it is judged after installation: the operation then restores the profile manifest and lockfile, reinstalls the restored lockfile, and reports whether that recovery succeeded; already permitted build-script effects may remain. A dependency the run did not change never blocks an unrelated operation: it stays installed, the run reports a warning naming it, and profile startup denies it. Installation requests with `enabled: false` are checked the same way. Startup checks run independently; see [App boot](../app-boot/README.md#profiles) for range semantics. Version exemptions do not authorize dependency scripts. +An install command that names packages (`add`, or `install` with specs) is checked before pnpm runs: a local path is read from its own `package.json`, and a registry spec is resolved through pnpm's registry lookup for the version its range selects and the peers that version declares. An incompatible DSH peer rejects the operation before pnpm runs, so nothing is downloaded and no build script runs; a build approval the caller supplied with the request is recorded before this check and remains. A git or tarball spec needs the fetch itself, so it is judged after installation: the operation then restores the profile manifest and lockfile, reinstalls the restored lockfile (or, when the profile had none, reinstalls from the restored manifest without creating one), and reports whether that recovery succeeded; already permitted build-script effects may remain. A dependency the run did not change never blocks an unrelated operation: it stays installed, the run reports a warning naming it, and profile startup denies it. Installation requests with `enabled: false` are checked the same way. Startup checks run independently; see [App boot](../app-boot/README.md#profiles) for range semantics. Version exemptions do not authorize dependency scripts. An exemption is an exact `package-name@version` mapped to a list of exact DSH runtime versions in the profile's own `compatibility.json`, beside `package.json` and `cordis.patch.yml`. Writing it changes no dependency, no bundle selection, and no patch layer. Neither plugin upgrades nor DSH upgrades inherit permission. Use `plugin_manager` with `list_version_exemptions` to obtain the runtime version and saved grants, then `set_version_exemption` with `target`, `runtimeVersion`, and `enabled`. Granting also requires `acceptRisk: true`, only after warning the user that incompatible plugins may cause crashes or data loss and receiving explicit permission for that pair. The service checks the acknowledgement and versions, not conversation history. Revocation can remove historical runtime grants. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index fcf4b6dc69..005af225a8 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -60,7 +60,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro ### 版本兼容性与豁免 -点名软件包的安装命令(`add`,或带 spec 的 `install`)会在 pnpm 运行前完成检查:本地路径直接读取其 `package.json`,registry spec 通过 pnpm 的 registry 查询得到该范围选中的版本及其声明的 peer。不兼容的 DSH peer 会在 pnpm 运行前使操作失败,因此不会下载任何内容、不会运行构建脚本;调用方随请求提交的构建批准在此检查之前记录,会保留下来。git 或 tarball spec 必须先抓取,因此在安装后才判定:此时操作会恢复 profile 清单与锁文件,并按恢复后的锁文件重新安装,同时报告该恢复是否成功;已获准构建脚本的副作用可能保留。本次运行未改动的依赖不会阻塞无关操作:它保持已安装状态,运行会输出点名它的警告,由 profile 启动拒绝加载。请求 `enabled: false` 的安装同样受检。启动检查独立执行;版本范围语义见 [App boot](../app-boot/README.zh.md#profiles)。版本豁免不授权依赖脚本。 +点名软件包的安装命令(`add`,或带 spec 的 `install`)会在 pnpm 运行前完成检查:本地路径直接读取其 `package.json`,registry spec 通过 pnpm 的 registry 查询得到该范围选中的版本及其声明的 peer。不兼容的 DSH peer 会在 pnpm 运行前使操作失败,因此不会下载任何内容、不会运行构建脚本;调用方随请求提交的构建批准在此检查之前记录,会保留下来。git 或 tarball spec 必须先抓取,因此在安装后才判定:此时操作会恢复 profile 清单与锁文件,并按恢复后的锁文件重新安装(profile 原本没有锁文件时,按恢复后的清单重新安装且不创建锁文件),同时报告该恢复是否成功;已获准构建脚本的副作用可能保留。本次运行未改动的依赖不会阻塞无关操作:它保持已安装状态,运行会输出点名它的警告,由 profile 启动拒绝加载。请求 `enabled: false` 的安装同样受检。启动检查独立执行;版本范围语义见 [App boot](../app-boot/README.zh.md#profiles)。版本豁免不授权依赖脚本。 豁免保存在 profile 自己的 `compatibility.json` 中(与 `package.json`、`cordis.patch.yml` 并列),将精确的 `package-name@version` 映射到精确 DSH 运行时版本列表。写豁免不改变依赖、组合包选择或 patch 层。插件升级和 DSH 升级都不继承授权。使用 `plugin_manager` 的 `list_version_exemptions` 获取运行时版本与已有授权,再通过 `set_version_exemption` 提交 `target`、`runtimeVersion` 和 `enabled`。授权还要求 `acceptRisk: true`;只能在警告用户不兼容插件可能导致崩溃或数据丢失,并获得用户对此版本组合的明确许可后传入。服务校验确认参数和版本,不核实对话历史。撤销可以移除历史运行时版本的授权。 diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 160e8714e1..1a2cd412c2 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -286,7 +286,7 @@ export async function runProfilePnpm( const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags) if (manifest === undefined) continue const issue = evaluatePluginCompatibility(manifest, exemptions) - if (issue !== undefined && !issue.exempted) preflight.push(pluginCompatibilityWarning(issue)) + if (issue !== undefined && !issue.exempted) preflight.push(pluginCompatibilityWarning(issue, context.profile)) } catch (error) { void error; continue } } if (preflight.length > 0) return rejected(preflight, 'nothing was installed') @@ -414,7 +414,7 @@ export async function runProfilePnpm( const manifest = readProfileManifest('dsh', packageDir) for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) { const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir)) - if (issue !== undefined && !issue.exempted) found.push(pluginCompatibilityWarning(issue)) + if (issue !== undefined && !issue.exempted) found.push(pluginCompatibilityWarning(issue, context.profile)) } } catch (error) { found.push(`Cannot validate installed package ${name}: ${String(error)}`) @@ -430,9 +430,12 @@ export async function runProfilePnpm( if (warnings.length > 0) { // A bundle component's peers need installed contents, so this rejection lands after pnpm // replaced the tree: restore the files, then reinstall the restored lockfile so the version - // that worked before this run keeps loading. + // that worked before this run keeps loading. A profile that had no lockfile is reinstalled + // from its restored manifest without creating one, which removes what this run added. await restore() - const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'install', '--frozen-lockfile'], { + const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined) + const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'] + const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], { cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore', ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs }, }) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index b064b95e64..24e317844c 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1332,8 +1332,8 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p }) const runtime = getDshRuntimeVersion() const managed = () => [...ctx.loader.entries()].find(entry => entry.id === 'include:managed') - // Admission disables the incompatible row where it is composed: the Loader keeps its entry and gives it no fiber. - expect(managed()?.disabled).toBe(true) + // The bundle's own peers are incompatible, so its whole layer is skipped and contributes no row. + expect(managed()).toBeUndefined() expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error?.diagnostic).toContain('crashes or data loss') expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' }) expect(await manager.setVersionExemption('extra@1.0.0', runtime, true)).toMatchObject({ changed: false, application: 'failed' }) @@ -1346,14 +1346,14 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p // The grant is persisted only in the profile's compatibility file, never in its package manifest. expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ 'extra@1.0.0': [runtime] }) expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) - // `setVersionExemption` reconciles the profile itself, so a live tree re-admits the row here; - // no manifest watch or Loader instrumentation participates. A startup-only profile keeps it disabled until restart. + // `setVersionExemption` reconciles the profile itself, so a live tree re-admits the bundle here; + // no manifest watch or Loader instrumentation participates. A startup-only profile keeps it out until restart. if (mode === 'live') expect(managed()?.fiber?.state).toBe(2) - else expect(managed()?.disabled).toBe(true) + else expect(managed()).toBeUndefined() expect(await manager.setVersionExemption('extra@1.0.0', runtime, false)).toMatchObject({ changed: true }) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({}) - if (mode === 'live') expect(managed()?.disabled).toBe(true) + if (mode === 'live') expect(managed()).toBeUndefined() }) it.each([false, true])('rechecks installed bundle peers before accepting a disabled installation (exempted=%s)', async (exempted) => { diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index 740d2abf31..3ac4989980 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -33,7 +33,7 @@ interface FakePnpm { /** The run's exit code and captured output. */ exitCode: number output: string - /** Exit code of the `install --frozen-lockfile` run that repairs a post-install refusal. */ + /** Exit code of the `install` run that repairs a post-install refusal. */ repairExit: number /** What the pre-install `pnpm view` lookup answers for one spec; `{}` declares no peers. */ view: (spec: string) => { exitCode: number; stdout: string } @@ -70,7 +70,7 @@ function fixture() { const answer = pnpm.view(spec) return result(answer.exitCode, answer.stdout) } - if (argv.includes('--frozen-lockfile')) return result(pnpm.repairExit, '') + if (argv.includes('--frozen-lockfile') || argv.includes('--config.lockfile=false')) return result(pnpm.repairExit, '') return result(pnpm.exitCode, pnpm.output, () => { pnpm.mutate(runDir) }) }) onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) }) @@ -346,6 +346,19 @@ it.each([true, false])('rejects incompatible installed manifests before activati expect(outcome.output).toContain('and node_modules') }) +it('repairs a profile that had no lockfile without creating one', async () => { + const { dir, context, pnpm } = fixture() + pnpm.mutate = (target) => { + installGuarded(target, 'incompatible') + writeFileSync(join(target, 'pnpm-lock.yaml'), 'created-lock\n') + } + const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 }) + expect(outcome.exitCode).toBe(1) + expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(false) + expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--config.lockfile=false'], expect.anything()) + expect(outcome.output).toContain('and node_modules') +}) + it('reports an unrepaired node_modules when the restoring install fails', async () => { const { context, pnpm } = fixture() pnpm.mutate = (target) => { installGuarded(target, 'incompatible') } From b660f72d4ef8e6a8f68586918abbd4481e5e321b Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 18:13:59 +0800 Subject: [PATCH 02/14] feat(tool): show content preparation progress for file mutations --- .../ui-conversation/src/client/locales.ts | 2 ++ packages/client/ui-tool/src/client/apply.ts | 6 +++- .../ui-tool/src/client/contract/slots.ts | 29 +++++++++++++++++-- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 17 +++++++++-- .../tool/tool-call-arguments-partial.ts | 23 +++++++++++++++ .../tool/toolviews/file-mutation-row.tsx | 8 +++-- 7 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index d170ddce32..a988d7b012 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,6 +85,7 @@ export const zh = { 'tool.title.createGoal': '创建目标', 'tool.title.getGoal': '查看目标', 'tool.title.updateGoal': '更新目标', + 'tool.preparing.content': '正在准备内容 {kilobytes}KB', 'tool.title.createSchedule': '创建定时任务', 'tool.title.listSchedules': '查看定时任务', 'tool.title.deleteSchedule': '删除定时任务', @@ -438,6 +439,7 @@ export const en = { 'tool.title.createGoal': 'Create goal', 'tool.title.getGoal': 'View goal', 'tool.title.updateGoal': 'Update goal', + 'tool.preparing.content': 'Preparing content {kilobytes}KB', 'tool.title.createSchedule': 'Create reminder', 'tool.title.listSchedules': 'List reminders', 'tool.title.deleteSchedule': 'Delete reminder', diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index e6a866d1b2..36e5f7f691 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' +import { bindToolCallArgumentsPartial } from './tool/tool-call-arguments-partial.ts' import { CONVERSATION_NS as NS } from './locale.ts' import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx' import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx' @@ -36,7 +37,10 @@ export function apply(ctx: ClientContext): void { key: 'tool-call', locale: NS, children: { - 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, + 'tool.call.toolview': { + kind: 'keyed', scope: 'session', + inject: { hooks: { toolCallArgumentsPartial: bindToolCallArgumentsPartial } }, + }, }, inject: toolInject, }, ToolCallTree)) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 3ebda6f301..2c577a6da7 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,10 +1,10 @@ /** Tool UI slot declarations and their composed component props. */ import type { - HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SlotHookFactory, } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { - OpenFileOptions, PreparingToolCall, StartedToolCall, + AssistantChatData, OpenFileOptions, PreparingToolCall, StartedToolCall, ToolResultNode, UseDisclosure, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -21,7 +21,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * generic row. The owner supplies the call identity and frozen running * or settled node through explicit phase props; preparing inputs have no arguments. */ - 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } + 'tool.call.toolview': { + kind: 'keyed' + scope: 'session' + owner: ToolCallOwnerProps + hookContext: ToolCallHookContext + inject: ToolCallInjected + } /** * Durable images of a settled image-bearing Tool call, rendered through * the attachment presentation plugin. The Tool layer never imports an @@ -39,6 +45,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } +/** Subscribe to this preparing call's raw argument prefix; other phases return an empty string. */ +export type UseToolCallArgumentsPartial = () => string + +/** Call-local sources supplied by the Tool tree to the slot's Hook binding. */ +export interface ToolCallHookContext { + readonly callId: string + /** This call's Step source, present only while preparing. */ + readonly assistant: HostObservable | undefined> | undefined +} + +/** Framework-bound subscriptions available to atomic Tool views on demand. */ +export interface ToolCallInjected { + hooks: { + toolCallArgumentsPartial: SlotHookFactory<'tool.call.toolview', UseToolCallArgumentsPartial> + } +} + /** Owner currency of the Tool image gallery slot: references plus the loader. */ export interface ToolImagesOwnerProps { /** Durable references or submission-echo previews in result order. */ diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index bf79129892..946cbf7cf9 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -2,5 +2,5 @@ export { apply, inject } from './apply.ts' export type { StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps, - ToolHostInfoInjected, ToolTreeProps, + ToolCallHookContext, ToolCallInjected, ToolHostInfoInjected, ToolTreeProps, UseToolCallArgumentsPartial, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index d6419aa205..050de5e63a 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,7 +1,7 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ import { memo, useMemo, type ReactNode } from 'react' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' -import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts' +import type { ToolCallHookContext, ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts' import { toolRowModel } from './models/tool-call-model.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import css from './ToolCallTree.module.css' @@ -18,14 +18,19 @@ function callName(call: ToolCallPhaseProps): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children, + renderSlot, callId, toolName, call, assistant, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children, }: Pick & { callId: string toolName: string call: ToolCallPhaseProps + assistant: ToolCallHookContext['assistant'] home?: string | undefined children?: ReactNode }) { + const preparing = call.phase === 'preparing' + const hookContext = useMemo(() => ({ + callId, assistant: preparing ? assistant : undefined, + }), [assistant, callId, preparing]) const owner: ToolCallOwnerProps = useMemo(() => ({ callId, toolName, @@ -51,6 +56,7 @@ const ToolCall = memo(function ToolCall({ ? : renderSlot('tool.call.toolview', owner, { entryKey: toolName, + hookContext, fallback: , })} {children} @@ -59,9 +65,10 @@ const ToolCall = memo(function ToolCall({ }) const ToolCallBranch = memo(function ToolCallBranch({ - renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, + renderSlot, block, assistant, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, }: Pick & { block: ToolCallBlock + assistant: ToolCallHookContext['assistant'] home?: string | undefined }) { const call = useMemo(() => toolCallPhase(block), [block]) @@ -71,6 +78,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ callId={call.block.callId} toolName={callName(call)} call={call} + assistant={assistant} openFile={openFile} cwd={cwd} home={home} @@ -86,6 +94,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ key={child.callId} renderSlot={renderSlot} block={child} + assistant={assistant} cwd={cwd} home={home} openFile={openFile} @@ -111,10 +120,12 @@ export function ToolCallTree({ renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t, }: ToolTreeProps) { const home = useHostInfo(info => info.home) + const assistant = node.location.kind === 'step' ? node.location.step.data.source('assistant-step') : undefined return ( void) => () => {} + +/** + * Bind one call without subscribing until its component invokes the Hook. + * @param _standard - framework-provided scope props. + * @param context - the preparing call's Step source and identity. + * @returns a Hook that reads only this call's raw argument prefix. + */ +export const bindToolCallArgumentsPartial: ToolCallInjected['hooks']['toolCallArgumentsPartial'] = ( + _standard, { assistant, callId }, +) => { + const getSnapshot = (): string => { + const block = assistant?.getSnapshot()?.blocks.find(candidate => candidate.kind === 'tool-call' && candidate.callId === callId) + return block?.kind === 'tool-call' ? block.argsRaw : '' + } + return function useToolCallArgumentsPartial() { + return useSyncExternalStore(assistant?.subscribe ?? subscribeEmpty, getSnapshot) + } +} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 7475b9b4f9..ece585bd61 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -12,9 +12,13 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'> /** * Lets users expand an applied file diff and open the reported path. */ -export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: FileMutationRowProps) { +export function FileMutationRow({ toolName, block, phase, cwd, home, openFile, inspect, useDisclosure, useToolCallArgumentsPartial, t }: FileMutationRowProps) { + const raw = useToolCallArgumentsPartial() const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) + const summary = phase === 'preparing' + ? t('tool.preparing.content', { kilobytes: Math.round(raw.length / 1024) }) + : model.summary return ( } title={t(model.titleKey)} - summary={model.summary} + summary={summary} output={model.output} errorSummary={model.errorSummary} diff={diff} From 015a9b202cf09b3e7b00246fcefa2a18a9fdcf44 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 18:35:49 +0800 Subject: [PATCH 03/14] feat(chat): add four work detail modes and legacy mappings --- packages/client/ui-chat/src/chat-settings.ts | 24 ++++++++++++------- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 3 ++- packages/client/ui-chat/src/client/locale.ts | 12 ++++++---- .../ui-chat/src/client/presentation-policy.ts | 21 ++++++++++------ .../src/client/settings/TranscriptViewRow.tsx | 3 ++- .../ui-chat/src/client/transcript-view.ts | 10 ++++---- packages/client/ui-chat/src/index.ts | 2 +- 7 files changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index c951a534eb..7335a49db5 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -9,22 +9,27 @@ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' /** Work-details presentation modes a user can choose. */ -export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const +export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const /** Work-details presentation mode. */ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] /** - * Saved value from the two-mode generation of this setting. Read as `detailed`; + * Saved value from the two-mode generation of this setting. Read as `standard`; * never offered as a choice and never written back. */ export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal' -/** Every value the durable field accepts: current modes plus the legacy saved value. */ -const TRANSCRIPT_VIEW_SETTING_VALUES = [...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE] as const +/** Saved `expanded` values read as `detailed`, without being offered or written back. */ +export const LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE = 'expanded' -/** Default preserves the compact process disclosure introduced by Chat. */ -export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact' +/** Every value the durable field accepts: current modes plus legacy saved values. */ +const TRANSCRIPT_VIEW_SETTING_VALUES = [ + ...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, +] as const + +/** Standard process summaries for users without an explicit preference. */ +export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'standard' /** Performance and usage detail levels accepted by user settings. */ export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const @@ -43,8 +48,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { - /** Work-details preference; the legacy value is accepted only from existing saved settings. */ - transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE + /** Work-details preference; legacy values are accepted only from existing saved settings. */ + transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -55,7 +60,8 @@ export interface ChatSettings { export const ChatSettingsFields = { linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), - [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), + // Missing and unrecognized modes both use Standard. + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE).loose(), } /** Schema for shared configuration values. */ diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 2944f85c38..e09a0d9504 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -138,7 +138,8 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const location = node?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined }) - const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none') + const grouped = props.usePresentation(policy => policy.stepGrouping === 'collapsed' + || (policy.stepGrouping === 'history' && turnLocation?.status !== 'open')) const reason = turnLocation?.end?.data.reason.kind const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true || reason === 'aborted' || reason === 'error' diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 0c4810c7aa..ce0ca17b07 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -100,11 +100,12 @@ export const zh = { 'settings.links.description': '对话中网页链接的打开位置', 'settings.links.sidebar': '应用内侧边栏', 'settings.links.newTab': '默认浏览器', - 'settings.transcript.title': '工作过程展示', - 'settings.transcript.description': '控制轮次和步骤的默认展开方式', + 'settings.transcript.title': '工作步骤展示', + 'settings.transcript.description': '选择希望看到多少工具调用细节', 'settings.transcript.compact': '简洁', + 'settings.transcript.standard': '标准', 'settings.transcript.detailed': '详细', - 'settings.transcript.expanded': '完全展开', + 'settings.transcript.verbose': '完全展开', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', @@ -281,10 +282,11 @@ export const en = { 'settings.links.sidebar': 'In-App Sidebar', 'settings.links.newTab': 'Default Browser', 'settings.transcript.title': 'Work details', - 'settings.transcript.description': 'Controls how turns and steps expand by default', + 'settings.transcript.description': 'Choose how much detail to show for tool calls', 'settings.transcript.compact': 'Compact', + 'settings.transcript.standard': 'Standard', 'settings.transcript.detailed': 'Detailed', - 'settings.transcript.expanded': 'Expanded', + 'settings.transcript.verbose': 'Verbose', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts index 3b570228dc..5b3ee1a7d5 100644 --- a/packages/client/ui-chat/src/client/presentation-policy.ts +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -13,8 +13,8 @@ export interface ChatPresentationPolicy { readonly mode: TranscriptViewMode /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ readonly foldCompletedTurns: boolean - /** Whether running Turns' secondary groups expose a collapsible header; historical groups always do. */ - readonly stepGrouping: 'collapsed' | 'none' + /** Collapsible group headers for all Turns, historical Turns only, or no Turns. */ + readonly stepGrouping: 'collapsed' | 'history' | 'none' /** Show the running command, path, query, or reasoning detail in group titles. */ readonly liveProcessDetail: boolean /** Whether a settled reasoning row previews its first line beside the Think title. */ @@ -29,20 +29,27 @@ const POLICIES: Readonly> = { liveProcessDetail: false, settledReasoningPreview: false, }, - detailed: { - mode: 'detailed', + standard: { + mode: 'standard', foldCompletedTurns: true, stepGrouping: 'collapsed', liveProcessDetail: true, settledReasoningPreview: true, }, - expanded: { - mode: 'expanded', + detailed: { + mode: 'detailed', foldCompletedTurns: true, - stepGrouping: 'none', + stepGrouping: 'history', liveProcessDetail: true, settledReasoningPreview: true, }, + verbose: { + mode: 'verbose', + foldCompletedTurns: false, + stepGrouping: 'none', + liveProcessDetail: false, + settledReasoningPreview: true, + }, } /** diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index 2b29abacd9..14d2d58703 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -24,8 +24,9 @@ export type TranscriptViewRowProps = const LABELS = { compact: 'settings.transcript.compact', + standard: 'settings.transcript.standard', detailed: 'settings.transcript.detailed', - expanded: 'settings.transcript.expanded', + verbose: 'settings.transcript.verbose', } as const satisfies Record /** diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 2a71ed74c0..e7f6cc4fde 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -3,14 +3,14 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' import { - DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, type ChatSettings, type TranscriptViewMode, } from '../chat-settings.ts' /** Live work-details preference consumed by Chat and its Settings row. */ export class TranscriptViewPolicy { private readonly unsubscribe: () => void - /** Reactive current mode; defaults to Compact before Host settings arrive. */ + /** Reactive current mode; defaults to Standard before Host settings arrive. */ readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE) /** @@ -26,7 +26,7 @@ export class TranscriptViewPolicy { /** * Publish and persist one explicit user choice. - * @param mode - Compact, Detailed, or Expanded work details. + * @param mode - Compact, Standard, Detailed, or Verbose work details. */ setMode(mode: TranscriptViewMode): void { if (this.mode.getSnapshot() === mode) return @@ -38,7 +38,9 @@ export class TranscriptViewPolicy { private adopt(): void { const section = this.host.getSnapshot().value if (section === undefined) return - const mode = section.transcriptView === LEGACY_TRANSCRIPT_VIEW_MODE ? 'detailed' : section.transcriptView + const saved = section.transcriptView + const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE ? 'standard' + : saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE ? 'detailed' : saved if (this.mode.getSnapshot() !== mode) this.mode.set(mode) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 036ea0cc0d..fabb8cf7df 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -9,7 +9,7 @@ import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts' import { ChatSettingsFields } from './chat-settings.ts' export { - CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, } from './chat-settings.ts' From d6fb1351b3507c7ec66686f65a8bc03137fd48be Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 18:45:34 +0800 Subject: [PATCH 04/14] fix(chat): refine verbose headers and file preparation rows --- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 2 +- .../src/client/chat/TurnProcessNodeView.tsx | 7 +++--- .../ui-tool/src/client/contract/slots.ts | 3 ++- .../tool/components/PreparingToolRow.tsx | 7 +++--- .../tool/toolviews/file-mutation-row.tsx | 25 +++++++++++++------ 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 5bb003bdfa..f2269daf9d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -102,7 +102,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ foldable, interleavedInput, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen, ]) const controllerInactive = routedNode?.kind === 'turn-process' - && !foldable + && foldCompleted && !foldable const compactAnswer = processAnswer && foldable && processPresentation.compactAnswer diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index 2224cd2873..c163938af8 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -11,20 +11,19 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ node, turnProcess, t, }: ChatNodeViewProps<'turn-process'>) { if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state') - const open = turnProcess.open + const open = !turnProcess.foldable || turnProcess.open const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined const [now, setNow] = useState(Date.now) - const ticking = turnProcess.foldable && turn?.status === 'open' + const ticking = turn?.status === 'open' useEffect(() => { if (!ticking) return setNow(Date.now()) const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) return () => { clearInterval(timer) } }, [ticking]) - if (!turnProcess.foldable) return null - const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node) + const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node) const running = turn?.status === 'open' const reason = turn?.end?.data.reason.kind const elapsedMs = turn?.start === undefined ? undefined diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 2c577a6da7..4fdaa65bd7 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -19,7 +19,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * * Registering an occupied key replaces its view; unclaimed keys use the * generic row. The owner supplies the call identity and frozen running - * or settled node through explicit phase props; preparing inputs have no arguments. + * or settled node through explicit phase props. Preparing blocks have no dispatched + * arguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix. */ 'tool.call.toolview': { kind: 'keyed' diff --git a/packages/client/ui-tool/src/client/tool/components/PreparingToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/PreparingToolRow.tsx index bd8607d09e..fbfaa3ab26 100644 --- a/packages/client/ui-tool/src/client/tool/components/PreparingToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/PreparingToolRow.tsx @@ -8,15 +8,16 @@ import { ToolRow, type ToolRowProps } from './ToolRow.tsx' type PreparingToolRowProps = Pick & { readonly icon: ReactNode readonly title: string + readonly summary?: string readonly t: ToolRowProps['t'] } /** - * Render an argument-free, non-expandable tool prefix. + * Render a non-expandable tool prefix with an optional preparation summary. * @param props - tool prefix and locale. * @returns the preparation row. */ -export function PreparingToolRow({ toolName, useDisclosure, icon, title, t }: PreparingToolRowProps) { +export function PreparingToolRow({ toolName, useDisclosure, icon, title, summary = '', t }: PreparingToolRowProps) { return + icon={icon} title={title} summary={summary} state="preparing" /> } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index ece585bd61..5462f90f8a 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -3,31 +3,42 @@ import { IconEditOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { diffCardModel } from '../models/diff-card-model.ts' -import { toolRowModel } from '../models/tool-call-model.ts' +import { toolRowModel, toolTitleKey } from '../models/tool-call-model.ts' import { ToolRow } from '../components/ToolRow.tsx' +import { PreparingToolRow } from '../components/PreparingToolRow.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const FILE_MUTATION_ICON = /** * Lets users expand an applied file diff and open the reported path. */ -export function FileMutationRow({ toolName, block, phase, cwd, home, openFile, inspect, useDisclosure, useToolCallArgumentsPartial, t }: FileMutationRowProps) { +export function FileMutationRow(props: FileMutationRowProps) { + return props.phase === 'preparing' + ? + : +} + +function PreparingFileMutationRow({ toolName, useDisclosure, useToolCallArgumentsPartial, t }: Extract) { const raw = useToolCallArgumentsPartial() + return +} + +function StartedFileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: Exclude) { const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) - const summary = phase === 'preparing' - ? t('tool.preparing.content', { kilobytes: Math.round(raw.length / 1024) }) - : model.summary return ( } + icon={FILE_MUTATION_ICON} title={t(model.titleKey)} - summary={summary} + summary={model.summary} output={model.output} errorSummary={model.errorSummary} diff={diff} From ac77cefba75f05443172afa81f3c4e27cf16bf63 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 18:53:01 +0800 Subject: [PATCH 05/14] test(chat): cover work detail modes and preparation subscriptions --- .../src/client/chat/TurnProcessNodeView.tsx | 3 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 52 +++++--- .../client/ui-chat/tests/config.host.spec.ts | 9 +- .../tests/presentation-policy.client.spec.tsx | 15 ++- .../tests/reasoning-row.client.spec.tsx | 6 +- .../transcript-view-policy.client.spec.ts | 22 +++- .../tests/transcript-view-row.client.spec.tsx | 28 ++-- .../ui-tool/tests/diff-card.client.spec.tsx | 1 + .../tests/preparing-rows.client.spec.tsx | 12 +- ...ool-call-arguments-partial.client.spec.tsx | 122 ++++++++++++++++++ 11 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index c163938af8..b404787a1a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -16,13 +16,14 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ ? node.location.turn : undefined const [now, setNow] = useState(Date.now) - const ticking = turn?.status === 'open' + const ticking = turn?.status === 'open' && turn.start !== undefined useEffect(() => { if (!ticking) return setNow(Date.now()) const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) return () => { clearInterval(timer) } }, [ticking]) + if (turn?.start === undefined && turn?.status !== 'closed') return null const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node) const running = turn?.status === 'open' const reason = turn?.end?.data.reason.kind diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index d96660c893..f73e0dbd08 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -114,7 +114,7 @@ describe('Chat apply wiring', () => { .find(entry => entry.options.id === 'transcript-view')! const face = (row.inject as unknown as () => TranscriptViewRowInjected)() - expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') + expect(face.hooks.transcriptView.getSnapshot()).toBe('standard') face.setTranscriptView('detailed') expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed') expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed') diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 3068bff875..78f45f1693 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -681,7 +681,7 @@ describe('ChatView', () => { expect(parent?.tagName).toBe('DIV') fireEvent.change(input, { target: { value: 'retained local input' } }) const observe = vi.spyOn(ResizeObserver.prototype, 'observe') - for (const mode of ['detailed', 'expanded', 'compact'] as const) { + for (const mode of ['standard', 'detailed', 'compact'] as const) { act(() => { h.setTranscriptView(mode) }) expect(view.getByRole('textbox', { name: inside.key })).toBe(input) expect(input.value).toBe('retained local input') @@ -690,7 +690,7 @@ describe('ChatView', () => { } fireEvent.click(groupHeader) expect(groupHeader.getAttribute('aria-expanded')).toBe('false') - act(() => { h.setTranscriptView('expanded') }) + act(() => { h.setTranscriptView('detailed') }) expect(view.getByRole('textbox', { name: inside.key })).toBe(input) act(() => { h.setTranscriptView('compact') }) expect(view.queryByRole('textbox', { name: inside.key })).toBeNull() @@ -737,10 +737,10 @@ describe('ChatView', () => { expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([true, true, true]) fireEvent.click(headers[0]!) - for (const mode of ['detailed', 'expanded', 'compact', 'expanded'] as const) { + for (const mode of ['standard', 'detailed', 'compact', 'detailed'] as const) { act(() => { h.setTranscriptView(mode) }) - expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'expanded']) - expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'expanded']) + expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'detailed']) + expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'detailed']) expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots) } @@ -753,6 +753,16 @@ describe('ChatView', () => { expect(bodies[2]!.hasAttribute('hidden')).toBe(true) expect(bodies[0]!.hasAttribute('hidden')).toBe(false) expect(bodies[1]!.hasAttribute('hidden')).toBe(true) + + act(() => { h.setTranscriptView('verbose') }) + expect(roots.every(root => root.closest('[hidden]') === null)).toBe(true) + expect(headers.every(header => header.closest('[hidden]') !== null)).toBe(true) + expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true) + expect(control.disabled).toBe(true) + expect(control.textContent).toContain(h.props.t('message.turnProcess.took', { duration: formatRunDuration(3_000, h.props.t) })) + fireEvent.click(control) + expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true) + expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots) }) it.each([ @@ -918,7 +928,7 @@ describe('ChatView', () => { expect(headers).toHaveLength(40) const titles = headers.map(header => header.textContent) - for (const mode of ['detailed', 'expanded', 'compact'] as const) { + for (const mode of ['standard', 'detailed', 'compact'] as const) { translate.mockClear() act(() => { h.setTranscriptView(mode) }) expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0) @@ -936,7 +946,7 @@ describe('ChatView', () => { groupStore.publish() }) translate.mockClear() - act(() => { h.setTranscriptView('detailed') }) + act(() => { h.setTranscriptView('standard') }) expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0) }) @@ -1319,9 +1329,9 @@ describe('ChatView', () => { }) it.each([ - { mode: 'compact', anchor: 'group' }, { mode: 'detailed', anchor: 'group' }, - { mode: 'expanded', anchor: 'group' }, - { mode: 'compact', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, { mode: 'expanded', anchor: 'node' }, + { mode: 'compact', anchor: 'group' }, { mode: 'standard', anchor: 'group' }, + { mode: 'detailed', anchor: 'group' }, + { mode: 'compact', anchor: 'node' }, { mode: 'standard', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, ] as const)( 'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => { const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 }) @@ -1387,14 +1397,14 @@ describe('ChatView', () => { it.each([ { mode: 'compact', hasProcess: false, hasSteering: false }, + { mode: 'standard', hasProcess: false, hasSteering: false }, { mode: 'detailed', hasProcess: false, hasSteering: false }, - { mode: 'expanded', hasProcess: false, hasSteering: false }, { mode: 'compact', hasProcess: true, hasSteering: false }, + { mode: 'standard', hasProcess: true, hasSteering: false }, { mode: 'detailed', hasProcess: true, hasSteering: false }, - { mode: 'expanded', hasProcess: true, hasSteering: false }, { mode: 'compact', hasProcess: false, hasSteering: true }, + { mode: 'standard', hasProcess: false, hasSteering: true }, { mode: 'detailed', hasProcess: false, hasSteering: true }, - { mode: 'expanded', hasProcess: false, hasSteering: true }, ] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => { const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 }) const initial = { @@ -1716,7 +1726,7 @@ describe('ChatView', () => { expect(view.getAllByText('即发即显')).toHaveLength(1) }) - it.each(['compact', 'detailed', 'expanded'] as const)( + it.each(['compact', 'standard', 'detailed'] as const)( 'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => { const opening = { requestId: 'opening' as never, placement: 'transcript' as const, @@ -1825,7 +1835,7 @@ describe('ChatView', () => { expect(view.getByLabelText('回到底部')).toBeTruthy() }) - it.each(['compact', 'detailed', 'expanded'] as const)( + it.each(['compact', 'standard', 'detailed'] as const)( 'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => { const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({ id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never }, @@ -2345,7 +2355,7 @@ describe('ChatView', () => { expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) }) - it.each(['compact', 'detailed', 'expanded'] as const)( + it.each(['compact', 'standard', 'detailed'] as const)( 'preserves steering-separated process groups in %s mode after Turn completion', (mode) => { const nodes = [ userInTurn(1, 'question', 1), @@ -2389,7 +2399,7 @@ describe('ChatView', () => { view.getByText('second direction').closest('[data-chat-flow-kind]'), roots[2], view.getByText('final answer').closest('[data-chat-flow-kind]'), ]) - for (const next of ['compact', 'detailed', 'expanded', mode] as const) { + for (const next of ['compact', 'standard', 'detailed', mode] as const) { act(() => { h.setTranscriptView(next) }) expect(visibleOrder()).toEqual(order) expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots) @@ -2476,14 +2486,14 @@ describe('ChatView', () => { expect(processRow.getAttribute('hidden')).toBe('until-found') const toggle = turnProcessControl(view.container)! - for (const mode of ['detailed', 'expanded', 'compact'] as const) { + for (const mode of ['standard', 'detailed', 'compact'] as const) { act(() => { h.setTranscriptView(mode) }) expect(turnProcessControl(view.container)).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(processRow.getAttribute('hidden')).toBe('until-found') } fireEvent.click(toggle) - for (const mode of ['detailed', 'expanded', 'compact'] as const) { + for (const mode of ['standard', 'detailed', 'compact'] as const) { act(() => { h.setTranscriptView(mode) }) expect(turnProcessControl(view.container)).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') @@ -2674,7 +2684,7 @@ describe('ChatView', () => { const toggle = view.getByRole('button', { name: h.props.t('message.turnProcess.worked') }) let row = view.container.querySelector('[data-chat-node-key="fixture:tool:partial"]')! let group = view.container.querySelector('[data-chat-group-key]') - for (const mode of ['compact', 'detailed', 'expanded'] as const) { + for (const mode of ['compact', 'standard', 'detailed'] as const) { act(() => { h.setTranscriptView(mode) }) expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(row.getAttribute('hidden')).toBe('until-found') @@ -2689,7 +2699,7 @@ describe('ChatView', () => { const beforePageGroup = group const groupToggle = group?.querySelector('[data-process-activity]') if (grouped && manualOpen) { - act(() => { h.setTranscriptView('detailed') }) + act(() => { h.setTranscriptView('standard') }) fireEvent.click(groupToggle!) expect(groupToggle?.getAttribute('aria-expanded')).toBe('true') } diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index cd68309593..e8591c98b1 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -17,7 +17,14 @@ describe('ui-chat Host settings', () => { expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' }) await configuration.update({ transcriptView: 'normal' }) expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' }) - await expect(configuration.update({ transcriptView: 'dense' })).rejects.toThrow() + for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { + await configuration.update({ transcriptView: mode }) + expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) + } + for (const mode of ['dense', '', 42, false, {}, null, undefined]) { + await configuration.update({ transcriptView: mode }) + expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' }) + } await configuration.update({ performanceUsage: 'compact' }) expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' }) await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow() diff --git a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx index de1319c00c..dd0f2a9dd5 100644 --- a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx +++ b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx @@ -10,12 +10,13 @@ afterEach(cleanup) describe('Chat presentation policy', () => { it.each([ - ['compact', 'collapsed', false, false], - ['detailed', 'collapsed', true, true], - ['expanded', 'none', true, true], - ] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview, liveProcessDetail) => { + ['compact', true, 'collapsed', false, false], + ['standard', true, 'collapsed', true, true], + ['detailed', true, 'history', true, true], + ['verbose', false, 'none', true, false], + ] as const)('maps %s to stable presentation capabilities', (mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail) => { const policy = presentationPolicyFor(mode) - expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview, liveProcessDetail }) + expect(policy).toEqual({ mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail }) expect(presentationPolicyFor(mode)).toBe(policy) }) @@ -29,7 +30,7 @@ describe('Chat presentation policy', () => { expect(listener).toHaveBeenCalledTimes(1) expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed')) dispose() - mode.set('expanded') + mode.set('verbose') expect(listener).toHaveBeenCalledTimes(1) }) @@ -54,7 +55,7 @@ describe('Chat presentation policy', () => { act(() => { mode.set('detailed') }) expect(foldRender).toHaveBeenCalledTimes(1) expect(previewRender).toHaveBeenCalledTimes(2) - act(() => { mode.set('expanded') }) + act(() => { mode.set('standard') }) expect(foldRender).toHaveBeenCalledTimes(1) expect(previewRender).toHaveBeenCalledTimes(2) act(() => { mode.set('compact') }) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 0d6b505ff9..5a875f31cb 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -45,14 +45,14 @@ describe('ReasoningRow', () => { it('retains summaries and expanded Markdown when the work-details mode changes', () => { const reset = createSnapshotStore(0) const useDisclosure = bindDisclosure(reset) - const mode = createSnapshotStore('detailed') + const mode = createSnapshotStore('standard') const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) const view = render() const root = view.container.querySelector('[data-variant="think"]')! const summary = view.getByText('First line') const toggle = view.getByRole('button') - for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) { + for (const next of ['compact', 'standard', 'detailed', 'verbose', 'compact'] as const) { act(() => { mode.set(next) }) expect(view.getByText('First line')).toBe(summary) expect(root.hasAttribute('data-preview')).toBe(next !== 'compact') @@ -60,7 +60,7 @@ describe('ReasoningRow', () => { } fireEvent.click(toggle) const body = view.getByText('Detailed body') - for (const next of ['detailed', 'expanded', 'compact'] as const) { + for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) { act(() => { mode.set(next) }) expect(view.getByText('Detailed body')).toBe(body) expect(toggle.getAttribute('aria-expanded')).toBe('true') diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 76351cea2b..28a9212054 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts' import { TranscriptViewPolicy } from '../src/client/transcript-view.ts' describe('TranscriptViewPolicy', () => { - it('defaults to Compact and publishes explicit choices before persistence settles', () => { + it('defaults to Standard and publishes explicit choices before persistence settles', () => { const host = stubConfigForm() const observed: string[] = [] let current = (): string => 'unconstructed' @@ -19,20 +19,20 @@ describe('TranscriptViewPolicy', () => { const policy = new TranscriptViewPolicy(scope) current = () => policy.mode.getSnapshot() - expect(policy.mode.getSnapshot()).toBe('compact') + expect(policy.mode.getSnapshot()).toBe('standard') policy.setMode('detailed') expect(policy.mode.getSnapshot()).toBe('detailed') expect(observed).toEqual(['transcriptView=detailed:detailed']) expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed') }) - it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => { + it('adopts Host state, reads normal as Standard, and ignores identical writes', () => { const host = stubConfigForm() const policy = new TranscriptViewPolicy(host.scope) host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(policy.mode.getSnapshot()).toBe('detailed') - policy.setMode('detailed') + expect(policy.mode.getSnapshot()).toBe('standard') + policy.setMode('standard') expect(host.set).not.toHaveBeenCalled() host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) @@ -42,7 +42,17 @@ describe('TranscriptViewPolicy', () => { it('adopts an accepted section standing at construction', () => { const host = stubConfigForm() host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded') + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed') + expect(host.set).not.toHaveBeenCalled() + }) + + it.each(['compact', 'standard', 'detailed', 'verbose'] as const)('preserves an explicit %s setting without migration writes', (mode) => { + const host = stubConfigForm() + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true }) + const policy = new TranscriptViewPolicy(host.scope) + expect(policy.mode.getSnapshot()).toBe(mode) + expect(host.set).not.toHaveBeenCalled() + policy.dispose() }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 1c36d876ab..7ba68130cb 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -34,7 +34,7 @@ function noPendingInteraction() { // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] -function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) { +function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | typeof zh = en) { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) }) const props: TranscriptViewRowProps = { @@ -53,20 +53,22 @@ function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typ } describe('TranscriptViewRow', () => { - it('explains the preference and shows Compact by default', () => { + it('explains the preference and shows Standard by default', () => { mount() expect(screen.getByText('Work details')).toBeDefined() - expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined() - expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('Choose how much detail to show for tool calls')).toBeDefined() + expect(screen.getByRole('button', { name: /Standard/ }).getAttribute('aria-expanded')).toBe('false') }) it.each([ + ['compact', 'Compact'], ['detailed', 'Detailed'], - ['expanded', 'Expanded'], + ['verbose', 'Verbose'], ] as const)('selects %s and follows the mirrored value', (mode, label) => { const b = mount() - fireEvent.click(screen.getByRole('button', { name: /Compact/ })) + fireEvent.click(screen.getByRole('button', { name: /Standard/ })) expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull() + expect(screen.queryByRole('menuitem', { name: 'Expanded' })).toBeNull() fireEvent.click(screen.getByRole('menuitem', { name: label })) expect(b.setTranscriptView).toHaveBeenCalledWith(mode) const trigger = screen.getByRole('button', { name: label }) @@ -76,21 +78,25 @@ describe('TranscriptViewRow', () => { expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull() }) - it('shows all three work-detail values in Chinese', () => { + it('shows all four work-detail values in Chinese', () => { const b = mount('compact', zh) - expect(screen.getByText('工作过程展示')).toBeDefined() + expect(screen.getByText('工作步骤展示')).toBeDefined() + expect(screen.getByText('选择希望看到多少工具调用细节')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: '简洁' })) + fireEvent.click(screen.getByRole('menuitem', { name: '标准' })) + expect(b.setTranscriptView).toHaveBeenLastCalledWith('standard') + fireEvent.click(screen.getByRole('button', { name: '标准' })) fireEvent.click(screen.getByRole('menuitem', { name: '详细' })) expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed') fireEvent.click(screen.getByRole('button', { name: '详细' })) fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' })) - expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded') + expect(b.setTranscriptView).toHaveBeenLastCalledWith('verbose') expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined() }) it('returns focus before publishing a mode change without refocusing after the menu closes', async () => { const b = mount() - const trigger = screen.getByRole('button', { name: /Compact/ }) + const trigger = screen.getByRole('button', { name: /Standard/ }) fireEvent.click(trigger) const item = screen.getByRole('menuitem', { name: 'Detailed' }) item.focus() @@ -173,7 +179,7 @@ describe('PerformanceUsageRow', () => { fireEvent.click(screen.getByRole('button', { name: 'Detailed' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Compact' })) expect(setPerformanceUsage).toHaveBeenCalledWith('compact') - expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(1) expect(b.setTranscriptView).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 3f9282b29f..a80446e058 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -207,6 +207,7 @@ describe('FileMutationRow diff card', () => { }) const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({ + useToolCallArgumentsPartial: () => { throw new Error('dispatched calls must not subscribe to preparing arguments') }, useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app', sessionId: SID, useSessions: bindSnapshotSelector(list()), t, diff --git a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx index 1140e6498c..037a59a804 100644 --- a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -27,7 +27,7 @@ function preparation(name: string): Props { return { phase: 'preparing', callId: 'call', toolName: name, block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] }, - t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), + t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(), useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null), } as Props } @@ -53,16 +53,19 @@ describe('argument-free tool preparation', () => { expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull() }) - it('keeps the common write row mounted as complete arguments and a result arrive', () => { + it('replaces preparation with the dispatched row and retains that row through the result', () => { const props = preparation('write') const view = render() - const row = view.container.querySelector('[data-tool="write"]') + const preparingRow = view.container.querySelector('[data-tool="write"]') + vi.mocked(props.useToolCallArgumentsPartial).mockClear() const started: StartedToolCall = { phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"hello.txt","content":"hello"}', } view.rerender() - expect(view.container.querySelector('[data-tool="write"]')).toBe(row) + const row = view.container.querySelector('[data-tool="write"]') + expect(row).not.toBe(preparingRow) + expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled() expect(view.getByText('hello.txt')).toBeTruthy() expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() const result: ToolResultNode = { @@ -71,6 +74,7 @@ describe('argument-free tool preparation', () => { } view.rerender() expect(view.container.querySelector('[data-tool="write"]')).toBe(row) + expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled() expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) diff --git a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx new file mode 100644 index 0000000000..f1b82985cb --- /dev/null +++ b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +/** Call-scoped preparation subscriptions and file-mutation progress. */ +import { useMemo } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { AssistantChatData, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import type { ToolCallHookContext } from '../src/client/contract/slots.ts' +import { bindToolCallArgumentsPartial } from '../src/client/tool/tool-call-arguments-partial.ts' +import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx' + +afterEach(cleanup) + +const standard = {} as Parameters[0] +const data = (first: string, second = ''): AssistantChatData => ({ + status: 'running', turn: 1, step: 1, time: 1, + blocks: [ + { kind: 'text', text: 'Preparing changes' }, + { kind: 'tool-call', callId: 'first', name: 'write', argsRaw: first }, + { kind: 'tool-call', callId: 'second', name: 'edit', argsRaw: second }, + ], +}) + +function source(initial: AssistantChatData | undefined) { + const store = createSnapshotStore(initial) + const listeners = new Set<() => void>() + return { + set: store.set, + listeners, + getSnapshot: store.getSnapshot, + subscribe: (listener: () => void) => { + listeners.add(listener) + const dispose = store.subscribe(listener) + return () => { listeners.delete(listener); dispose() } + }, + } +} + +function Probe({ context, label, rendered }: { context: ToolCallHookContext; label: string; rendered: () => void }) { + const usePartial = useMemo(() => bindToolCallArgumentsPartial(standard, context), [context]) + const raw = usePartial() + rendered() + return {raw} +} + +describe('tool argument prefix Hook', () => { + it('does not subscribe until used and refreshes only the call whose prefix changed', () => { + const assistant = source(data('{', 'second prefix')) + const first = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' }) + expect(assistant.listeners.size).toBe(0) + const firstRendered = vi.fn() + const secondRendered = vi.fn() + const firstContext = { assistant, callId: 'first' } + const secondContext = { assistant, callId: 'second' } + const view = render(<> + + + ) + expect(first).toBeTypeOf('function') + expect(assistant.listeners.size).toBe(2) + firstRendered.mockClear() + secondRendered.mockClear() + act(() => { assistant.set(data('{"file_path":', 'second prefix')) }) + expect(view.getByLabelText('first').textContent).toBe('{"file_path":') + expect(firstRendered).toHaveBeenCalled() + expect(secondRendered).not.toHaveBeenCalled() + firstRendered.mockClear() + act(() => { assistant.set(data('{"file_path":', 'more second prefix')) }) + expect(firstRendered).not.toHaveBeenCalled() + expect(view.getByLabelText('second').textContent).toBe('more second prefix') + view.unmount() + expect(assistant.listeners.size).toBe(0) + }) + + it('handles missing sources and calls and detaches a replaced Step source', () => { + const old = source(undefined) + const next = source(data('new step')) + const rendered = vi.fn() + const view = render() + expect(view.getByLabelText('raw').textContent).toBe('') + act(() => { old.set(data('unrelated')) }) + expect(view.getByLabelText('raw').textContent).toBe('') + view.rerender() + expect(old.listeners.size).toBe(0) + expect(next.listeners.size).toBe(1) + expect(view.getByLabelText('raw').textContent).toBe('new step') + view.rerender() + expect(next.listeners.size).toBe(0) + expect(view.getByLabelText('raw').textContent).toBe('') + view.unmount() + }) + + it.each([ + ['write', en, 'Preparing content'], ['edit', zh, '正在准备内容'], + ] as const)('%s subscribes only during preparation and rounds raw length upward', (toolName, dictionary, label) => { + const assistant = source(data('')) + const usePartial = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' }) + const props = { + phase: 'preparing', toolName, callId: 'first', useToolCallArgumentsPartial: usePartial, + block: { phase: 'preparing', callId: 'first', name: toolName, turn: 1, step: 1, time: 1, subCalls: [] }, + useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), t: makeTranslate(dictionary), + } as Parameters[0] + const view = render() + for (const [length, kilobytes] of [[0, 0], [1, 1], [1024, 1], [1025, 2], [12 * 1024, 12]]) { + act(() => { assistant.set(data('中'.repeat(length!))) }) + expect(view.getByText(`${label} ${kilobytes}KB`)).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() + } + expect(assistant.listeners.size).toBe(1) + const started = { phase: 'start' as const, callId: 'first', name: toolName, turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"file.txt","content":"hello"}' } + view.rerender() + expect(assistant.listeners.size).toBe(0) + expect(view.getByText('file.txt')).toBeTruthy() + const result: ToolResultNode = { kind: 'tool-result', callId: 'first', seq: 3, time: 3, callTime: 2, call: { name: toolName, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [] } + view.rerender() + expect(assistant.listeners.size).toBe(0) + expect(view.getByText('file.txt')).toBeTruthy() + }) +}) From 4987e90b32aefeb0e947c401e5a7a8f508d11b13 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 19:04:54 +0800 Subject: [PATCH 06/14] fix: static --- .../cordis-client-runner/src/client/slot-catalog.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 85a21e28c0..18e55d4342 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3815,14 +3815,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:38', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:45', }, { key: 'tool.call.toolview', kind: 'keyed', scope: 'session', summary: 'Keyed Tool call view dispatched by wire Tool name.', - doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props; preparing inputs have no arguments.', + doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props. Preparing blocks have no dispatched\narguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix.', registerOptions: [ { name: 'key', @@ -3862,8 +3862,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write', - hookContext: '', - slotInject: '', + hookContext: 'ToolCallHookContext', + slotInject: 'ToolCallInjected', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted', occupants: [ 'client-ui-deliverables PresentRow key \'present\'', @@ -3922,7 +3922,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:25', }, { key: 'tool.view.cordis', From 1b55f5bea3a88de1c013c1247f566b85bb6450b8 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 19:18:22 +0800 Subject: [PATCH 07/14] fix(chat): retain preparing tools and ordering until step completion --- .../src/client/sessions/assistant-stream.ts | 24 ++++- .../src/client/sessions/session.ts | 6 +- .../tests/assistant-stream.client.spec.ts | 38 +++++++- .../tests/sessions-service.client.spec.ts | 17 ++-- .../client/conversation-nodes/assistant.ts | 7 +- .../tests/tool-preparation.client.spec.ts | 87 ++++++++++++++----- 6 files changed, 145 insertions(+), 34 deletions(-) diff --git a/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/packages/api/session-controller/src/client/sessions/assistant-stream.ts index fcecd645d5..973fd10853 100644 --- a/packages/api/session-controller/src/client/sessions/assistant-stream.ts +++ b/packages/api/session-controller/src/client/sessions/assistant-stream.ts @@ -24,7 +24,12 @@ interface ActiveAttempt { /** One Web publication decision from the assistant stream fold. */ export type ClientAssistantStreamResult = - | { readonly type: 'publish'; readonly entry: SessionLiveEventEntry } + | { + readonly type: 'publish' + readonly entry: SessionLiveEventEntry + /** Retire the successful attempt's transient rows after publishing its Step end. */ + readonly retireAttemptId?: LlmAttemptId + } | { readonly type: 'settlement' readonly attemptId: LlmAttemptId @@ -38,6 +43,7 @@ export type ClientAssistantStreamResult = /** Keeps transient Assistant presentation behind one settlement-aware interface. */ export class ClientAssistantStream { private activeAttempt: ActiveAttempt | undefined + private retainedAttempt: Pick | undefined private readonly pending = new Map() private publishedSeqs = new Set() private durableCursor = -1 @@ -56,6 +62,7 @@ export class ClientAssistantStream { this.pending.clear() this.transientInGap = 0 this.activeAttempt = undefined + this.retainedAttempt = undefined const opening = baseline?.activeAttempt if (opening !== undefined) { this.activeAttempt = { @@ -114,13 +121,16 @@ export class ClientAssistantStream { /** * Fold one dense transient frame and release its named durable settlement. + * Successful messages retain their transient rows until the owning Step ends; + * interrupted messages, failed attempts, and abandonment retire them immediately. * @param frame - next Assistant stream frame received by the follow connection. * @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible. */ acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult { switch (frame.type) { case 'start': - if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' } + if (this.activeAttempt !== undefined || this.retainedAttempt !== undefined + || this.pending.size > 0) return { type: 'rebaseline' } this.pending.clear() this.activeAttempt = { attemptId: frame.attemptId, @@ -176,6 +186,10 @@ export class ClientAssistantStream { return { type: 'rebaseline' } } this.pending.delete(frame.outcome.seq) + if (entry.event.type === 'assistant/message' && entry.event.data.interrupted !== true) { + this.retainedAttempt = { attemptId: attempt.attemptId, turn: attempt.turn, step: attempt.step } + return this.publish(entry) + } this.publishedSeqs.add(entry.event.seq) return { type: 'settlement', attemptId: attempt.attemptId, entry } } @@ -196,6 +210,12 @@ export class ClientAssistantStream { private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult { this.publishedSeqs.add(entry.event.seq) + const retained = this.retainedAttempt + if (retained !== undefined && entry.event.type === 'step/end' + && entry.event.data.turn === retained.turn && entry.event.data.step === retained.step) { + this.retainedAttempt = undefined + return { type: 'publish', entry, retireAttemptId: retained.attemptId } + } return { type: 'publish', entry } } } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 7ad5c637be..284abc5656 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -723,8 +723,10 @@ export class Session implements SessionFace { this.notifier.markDirty() return } - if (result?.type === 'publish' && this.appendLive(result.entry)) { - this.notifier.markDirty() + if (result?.type === 'publish') { + const changed = this.appendLive(result.entry) + if (result.retireAttemptId !== undefined) this.eventSource.settleAssistant(result.retireAttemptId) + if (changed || result.retireAttemptId !== undefined) this.notifier.markDirty() } else if (result?.type === 'transient') { this.eventSource.append(result.entry) this.notifier.markDirty() diff --git a/packages/api/session-controller/tests/assistant-stream.client.spec.ts b/packages/api/session-controller/tests/assistant-stream.client.spec.ts index 2e1ef4b053..df7fc01993 100644 --- a/packages/api/session-controller/tests/assistant-stream.client.spec.ts +++ b/packages/api/session-controller/tests/assistant-stream.client.spec.ts @@ -136,14 +136,48 @@ describe('ClientAssistantStream', () => { } }) - it('stages one owned settlement and releases it from the matching end frame', () => { + it('publishes a successful message at stream end and retires its chunks at its Step end', () => { const stream = opened() const durable = messageEvent(2) expect(stream.acceptDurable(durable)).toBeUndefined() expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' })) expect(stream.acceptFrame(end(1, { kind: 'committed', eventType: 'assistant/message', seq: 2, - }))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable }) + }))).toEqual({ type: 'publish', entry: durable }) + for (const [turn, step] of [[2, 1], [1, 2]]) { + const unrelated = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: turn!, step: step! } }) + expect(stream.acceptDurable(unrelated)).toEqual({ type: 'publish', entry: unrelated }) + } + const closed = entry({ type: 'step/end', seq: SessionSeq(4), time: 4, data: { turn: 1, step: 1 } }) + expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed, retireAttemptId: ATTEMPT }) + expect(stream.acceptDurable(ordinary(5))).toEqual({ type: 'publish', entry: ordinary(5) }) + expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 4))).toBeUndefined() + }) + + it('clears retained chunks on replacement and refuses a new attempt before Step completion', () => { + const stream = opened() + const durable = messageEvent(2) + stream.acceptDurable(durable) + stream.acceptFrame(end(0, { kind: 'committed', eventType: 'assistant/message', seq: 2 })) + expect(stream.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' }) + expect(stream.replace([durable])).toEqual([durable]) + const closed = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: 1, step: 1 } }) + expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed }) + expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 3))).toBeUndefined() + }) + + it('retires failed attempts and interrupted messages at stream end', () => { + const message = messageEvent(2) + if (message.event.type !== 'assistant/message') throw new Error('expected assistant message') + const interrupted = entry({ ...message.event, data: { ...message.event.data, interrupted: true as const } }) + for (const durable of [attemptEvent(2), interrupted]) { + const stream = opened() + stream.acceptDurable(durable) + expect(stream.acceptFrame(end(0, { + kind: 'committed', eventType: durable.event.type as 'assistant/message' | 'assistant/attempt', seq: 2, + }))).toEqual({ type: 'settlement', attemptId: ATTEMPT, entry: durable }) + expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 2))).toBeUndefined() + } }) it('rebaselines duplicate durable settlements or starts', () => { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index d4061def11..ef3012c393 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -164,7 +164,7 @@ describe('scope tree', () => { expect(b.mock.remote.session.projections).not.toHaveBeenCalled() }) - it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => { + it('publishes the final Assistant message before retiring transient chunks at Step end', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' }) @@ -232,13 +232,20 @@ describe('scope tree', () => { }) await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { - expect(binding.eventSource.getSnapshot().entries).toHaveLength(1) + expect(binding.eventSource.getSnapshot().entries).toHaveLength(2) }) expect(publications).toEqual([ ['assistant/live-chunk'], - ['assistant/message'], + ['assistant/live-chunk', 'assistant/message'], ]) + b.mock.streams.push(FOLLOW, { + type: 'event', event: { type: 'step/end', seq: 1, time: 3, data: { turn: 1, step: 1 } }, + }) + await b.mock.streams.drained(FOLLOW) + expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) + .toEqual(['assistant/message', 'step/end']) + expect(binding.eventSource.getSnapshot().change).toEqual({ kind: 'settle-assistant', attemptId }) dispose() }) @@ -373,10 +380,10 @@ describe('scope tree', () => { await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) - .toEqual(['assistant/message', 'assistant/message']) + .toEqual(['assistant/message', 'assistant/live-chunk', 'assistant/message']) }) expect(binding.eventSource.getSnapshot().change).toEqual({ - kind: 'settle-assistant', attemptId: String(attemptId), entry: currentMessage, + kind: 'append', entries: [currentMessage], }) }) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 73d1265dab..53b8057c58 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -259,7 +259,8 @@ function projectAssistant(context: ConversationNodeContext): Ass const status = settled?.interrupted === true ? 'interrupted' : settled === undefined ? 'running' : 'settled' - const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + const anchorSeq = (settled?.interrupted === true ? settled.seq : state.firstVisibleSeq ?? settled?.seq) + ?? context.matches[0]?.event.seq ?? 0 const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0 return { anchorSeq, @@ -341,7 +342,9 @@ export const assistantDefinition: ConversationNodeDefinition = { const settled = data.finalNode const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(data.blocks) if (settled === undefined && !visible && current == null) return null - const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + // A successful message retains its live anchor alongside pending Tool calls. + const anchorSeq = (settled?.interrupted === true ? settled.seq : state.firstVisibleSeq ?? settled?.seq) + ?? context.matches[0]?.event.seq ?? 0 return chatNode(context, 'assistant-step', anchorSeq, data, { visibility: settled?.interrupted === true || visible ? 'visible' : 'hidden', }) diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index c458a0869f..286eca7eed 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -25,22 +25,22 @@ const opening = [ entry({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), ] -function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write'): SessionEventLikeEntry { +function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write', index = id === first ? 0 : 1): SessionEventLikeEntry { return { type: 'transient', event: { type: 'assistant/live-chunk', seq, time: seq, data: { turn: 1, step: 1, attemptId, chunk: { - type: 'tool-call-delta', index: id === first ? 0 : 1, id, argumentsDelta, + type: 'tool-call-delta', index, id, argumentsDelta, ...(name === null ? {} : { name }), } }, }, } } -function call(seq: number, callId = first): SessionLiveEventEntry { +function call(seq: number, callId = first, name = 'write', argumentsRaw = args): SessionLiveEventEntry { return entry({ type: 'tool/call', seq: SessionSeq(seq), time: seq, - data: { turn: 1, step: 1, callId, name: 'write', arguments: args } }) + data: { turn: 1, step: 1, callId, name, arguments: argumentsRaw } }) } function result(seq: number, callId = first): SessionLiveEventEntry { @@ -49,18 +49,20 @@ function result(seq: number, callId = first): SessionLiveEventEntry { } }) } -function settlement(): SessionAssistantSettlementEntry { +function settlement(ids = [first, second], name = 'write', argumentsRaw = args, text = ''): SessionAssistantSettlementEntry { const stream = new AssistantStreamAccumulator() - for (const [index, id] of [first, second].entries()) { - const chunk: StreamChunk = { type: 'tool-call-delta', index, id, name: 'write', argumentsDelta: args } - stream.push({ time: 3 + index, chunk }) + if (text !== '') stream.push({ time: 3, chunk: { type: 'text-delta', index: 0, text } }) + for (const [index, id] of ids.entries()) { + const chunk: StreamChunk = { type: 'tool-call-delta', index: index + Number(text !== ''), id, name, argumentsDelta: argumentsRaw } + stream.push({ time: 3, chunk }) } return { type: 'event', event: { type: 'assistant/message', seq: SessionSeq(5), time: 5, surfaceOp: 'append', data: { turn: 1, step: 1, stream: [...stream.snapshot()], - message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [first, second].map(id => ({ - type: 'tool-call', id, name: 'write', arguments: args, - })) }), + message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [ + ...text === '' ? [] : [{ type: 'text' as const, text }], + ...ids.map(id => ({ type: 'tool-call' as const, id, name, arguments: argumentsRaw })), + ] }), }, } } } @@ -130,19 +132,62 @@ describe('Tool preparation and durable replay', () => { expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) }) - it('withdraws transient preparation on settlement and converges with a durable-only replacement', () => { + it('keeps five command preparations below the Assistant message through dispatch and Step end', () => { + const h = harness() + const ids = Array.from({ length: 5 }, (_, index) => ToolCallId(`command-${index}`)) + const command = '{"command":"echo hello"}' + const text = 'Running five commands' + h.assembler.append({ type: 'transient', event: { + type: 'assistant/live-chunk', seq: 2.01, time: 3, + data: { turn: 1, step: 1, attemptId, chunk: { type: 'text-delta', index: 0, text } }, + } }) + for (const [index, id] of ids.entries()) h.assembler.append(delta(2.1 + index / 10, id, command, 'bash', index + 1)) + const keys = h.tools().map(node => node.key) + const groups = h.assembler.grouped('chat')! + const group = groups.entries.find(entry => entry.kind === 'group')! + const order = groups.entries + expect(order[0]).toMatchObject({ kind: 'node', groupPart: 'response' }) + expect(order[1]).toEqual(group) + const message = settlement(ids, 'bash', command, text) + const history = [...opening, message] + h.assembler.append(message) + expect(h.phases()).toEqual(ids.map(id => [id, 'preparing'])) + expect(groups.entries).toEqual(order) + for (const [index, id] of ids.entries()) { + const started = call(6 + index * 2, id, 'bash', command) + h.assembler.append(started) + expect(h.phases()).toEqual(ids.map((callId, at) => [callId, at < index ? 'result' : at === index ? 'start' : 'preparing'])) + expect(groups.entries).toEqual(order) + const completed = result(7 + index * 2, id) + h.assembler.append(completed) + history.push(started, completed) + expect(h.tools().map(node => node.key)).toEqual(keys) + expect(groups.entries).toEqual(order) + } + const closed = entry({ type: 'step/end', seq: SessionSeq(16), time: 16, data: { turn: 1, step: 1 } }) + history.push(closed) + h.assembler.append(closed) + h.assembler.settleAssistant(attemptId) + const replay = harness(history) + expect(h.phases()).toEqual(ids.map(id => [id, 'result'])) + expect(h.material()).toEqual(replay.material()) + expect(groups.entries).toEqual(order) + }) + + it('hides undispatched preparations when a settled Step ends before every call starts', () => { const h = harness() h.assembler.append(delta(2.1)) - const key = h.tools()[0]!.key + h.assembler.append(delta(2.2, second)) const message = settlement() - h.assembler.settleAssistant(attemptId, message) - expect(h.phases()).toEqual([]) - const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)] - for (const event of history.slice(3)) h.assembler.append(event) - expect(h.tools()[0]?.key).toBe(key) - const replay = harness(history) - expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) - expect(h.material()).toEqual(replay.material()) + h.assembler.append(message) + h.assembler.append(call(6)) + h.assembler.append(result(7)) + expect(h.phases()).toEqual([[first, 'result'], [second, 'preparing']]) + const closed = entry({ type: 'step/end', seq: SessionSeq(8), time: 8, data: { turn: 1, step: 1 } }) + h.assembler.append(closed) + expect(h.phases()).toEqual([[first, 'result']]) + h.assembler.settleAssistant(attemptId) + expect(h.material()).toEqual(harness([...opening, message, call(6), result(7), closed]).material()) }) it('does not create preparing rows from stored Assistant streams in history or older pages', () => { From 5a32aae571b99c4c4bf10c5a0e49fcca28ed865b Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 19:29:37 +0800 Subject: [PATCH 08/14] docs(chat): align preparing hooks and work detail modes --- ...026-09-22-tool-call-three-phases.i18n.yaml | 4 +- .../2026-09-22-tool-call-three-phases.md | 10 ++-- .../2026-09-22-tool-call-three-phases.zh.md | 10 ++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 6 +-- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 46 +++++++++--------- .../client/conversation-nodes/README.zh.md | 48 +++++++++---------- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 7 ++- packages/client/ui-tool/README.zh.md | 7 ++- 15 files changed, 84 insertions(+), 78 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml index bfbfe3cdf3..5b3f65eaa0 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.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/architecture/2026-09-22-tool-call-three-phases.md -2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20 -2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54 +2026-09-22-tool-call-three-phases.md: f43eaf1a685358f251a446889b6ac25f5fdf0144 +2026-09-22-tool-call-three-phases.zh.md: accbd2e966595ae7f124138147d191f97cb763ee diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md index e7b42a4ed9..f43eaf1a68 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md @@ -18,7 +18,7 @@ The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversatio | Stage | Creation or update evidence | Available data and presentation | |---|---|---| -| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. | +| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. | | `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. | | `result` | `tool/result` | The result and any complete arguments paired within the loaded window. | @@ -30,7 +30,7 @@ The [Conversation Definition](../../../../packages/client/ui-conversation/src/cl A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches. -When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results. +Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results. ### Converge on final state without replaying preparation @@ -40,7 +40,7 @@ Groups include Tool nodes from preparation onward and classify and count them by ### Shared rows reuse their existing presentation -[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no arguments or result, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding. +[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding. Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced. @@ -62,6 +62,6 @@ This decision replaces the [business-node assembly](2026-08-09-client-conversati - A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments. - Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse. -- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage. -- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation. +- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage. +- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress. - Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results. diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md index 5a68575fd8..accbd2e966 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md @@ -18,7 +18,7 @@ Status: implemented | 阶段 | 创建或更新依据 | 可用数据与展示 | |---|---|---| -| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 | +| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 | | `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 | | `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 | @@ -30,7 +30,7 @@ Status: implemented Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。 -流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。 +成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。 ### 最终状态收敛,不重现准备过程 @@ -40,7 +40,7 @@ Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。As ### 通用行复用现有外壳 -[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有参数或结果,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。 +[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。 read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。 @@ -62,6 +62,6 @@ read、read_image、write/edit、search、web、todo、question、details 和通 - 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。 - 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。 -- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。 -- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。 +- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。 +- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。 - 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index c283462c19..f7f78810e2 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752 -README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272 +README.md: 728d98b90aa96829a878b071c03af3ec961c839e +README.zh.md: 9a2c54b6865302e69daa5f67ec9baa17255f8100 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fa2a251e94..728d98b90a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -43,7 +43,7 @@ Projection reads retain loading and failure state independently of their values. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index releases the durable entry. A successful message retains its transient rows until the owning `step/end`, so pending tool identities remain available for dispatch. Failed or interrupted settlements retire their rows immediately; earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index d1a73ed584..9a2c54b686 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -43,7 +43,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布持久条目。成功消息的瞬态 row 保留到所属 `step/end`,让待派发工具的身份持续可用;失败或中断的 settlement 立即移除对应 row,同一步骤中更早的 retry 仍然保留。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 01fae81ea8..62894f8e8f 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: cca209016220560b7f39831e11ebb677da515ea9 -README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683 +README.md: f6924e91b24d6da9bae7722734dd8c7455145abf +README.zh.md: 0834d1f556a0757dd40fd5d0cdd7fb28c3f8516b diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index cca2090162..f6924e91b2 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and fold eligible completed-turn process rows without hiding final answers. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. +Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself. @@ -78,7 +78,7 @@ During uninterrupted following, local transcript and steering echoes remain moun When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render. -Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. +Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 0b97e490f5..0834d1f556 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作过程展示模式控制思考预览,并收起符合条件的已完成轮次过程行,不隐藏最终答案。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。 文件提及提供方同时接收当前查看的会话 ID 与收尾轮次的属主信息,因此继承历史中的链接可以指向 fork 自身。 @@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## Chat 隐藏的行 -Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -78,7 +78,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。 -工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 +工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 ----- diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index d93cefddcd..bdc5022dce 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/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-chat/src/client/conversation-nodes/README.md -README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2 -README.zh.md: ae43726417becea973b22e2f2a16871cec116584 +README.md: 12c63b54977d43120baae1f044675760b69303c9 +README.zh.md: 341c2826a1a2d070d314808f9a4ba6aff2f2378a diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 3b6b82685c..12c63b5497 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -44,7 +44,7 @@ Every reply separates secondary groups; only the final answer is protected from | Which rows belong together? | Group boundaries | The reply separates G1 and G2; changing from `read` to `bash` does not. | | What did a group do? | Category counts | G1 has `read=1, commands=1`; G2 has `code=1`. Reasoning adds no tool count. | | What does its title say? | Group state and summary | Running groups use their latest running activity; closed groups use their highest-count categories. Counts never decide folding. | -| How much detail is visible? | Display mode and manual group opening | Completed Turns retain group headers and manual bodies in every mode; Expanded directly shows group bodies only in running Turns. | +| How much detail is visible? | Display mode and manual group opening | Compact, Standard, and Detailed retain historical group headers; Detailed shows running bodies directly, while Verbose shows both running and historical bodies directly. | | Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1, the intermediate reply, and G2 together. | ### What the reader sees @@ -53,21 +53,21 @@ Assume the Turn above completed normally, with no inner disclosure manually open | Viewing state | Visible content | |---|---| -| Whole Turn collapsed, any mode | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. | -| Whole Turn open, Compact or Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. | -| Whole Turn and G1 open, Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. | -| Whole Turn open, Expanded | G1/G2 headers, intermediate reply, and final response. Completed Turns' group bodies still require manual opening. | +| Whole Turn collapsed, Compact/Standard/Detailed | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. | +| Whole Turn open, Compact/Standard/Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. | +| Whole Turn and G1 open, Standard or Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. | +| Verbose | Whole-Turn duration/status header without a collapse action, all process rows, intermediate reply, and final response. No group headers; individual tool/reasoning bodies remain manual. | Visibility applies from outside inward: whole Turn → secondary group → individual reasoning/tool disclosure. Opening an inner layer cannot bypass a closed outer layer. Category changes never open or close a layer. Mode changes preserve membership and manual opening choices. -If steering, a User message, or a trigger notice follows process output, the Turn offers only individual group disclosures, not whole-Turn collapse. In Compact/Detailed, `G1 → steering 1 → G2 → steering 2 → G3` retains all three headers and both inputs in that order, with intermediate replies visible; opening one group reveals only its body. Expanded directly shows group bodies only while the Turn runs; completed Turns retain headers and manual disclosure. The Turn control still shows duration or status, without a collapse action. +If steering, a User message, or a trigger notice follows process output, the Turn offers only individual group disclosures, not whole-Turn collapse. In Compact/Standard, `G1 → steering 1 → G2 → steering 2 → G3` retains all three headers and both inputs in that order, with intermediate replies visible; opening one group reveals only its body. Detailed directly shows group bodies only while the Turn runs; completed Turns retain headers and manual disclosure. Verbose shows group bodies directly in both cases. The Turn control still shows duration or status, without a collapse action. ----- ## Whole-Turn folding -Whole-Turn folding controls the loaded process range, independently of secondary groups. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. +Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them. @@ -108,21 +108,21 @@ Automatic collapse keeps the process open if hiding it would hide keyboard focus ## Display modes -Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back. +Settings → General → Work details offers `compact`, `standard` (default), `detailed`, and `verbose`; its description is “Choose how much detail to show for tool calls”. A saved `normal` reads as `standard`, and a saved `expanded` reads as `detailed`, without automatic write-back. Existing `detailed` remains `detailed`. Missing or invalid values, including the period before Host settings arrive, use `standard`; invalid values in other settings still fail validation. -| Behavior | Compact | Detailed | Expanded | -|---|---|---|---| -| Process-group header | Category summary | Summary and live task detail | Hidden in running Turns; retained in historical Turns | -| Process-group body | Initially collapsed | Initially collapsed | Directly visible without a group-level height cap in running Turns; manual disclosure in historical Turns | -| Settled reasoning preview | Hidden | First line | First line | -| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | -| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | +| Behavior | Compact | Standard | Detailed | Verbose | +|---|---|---|---|---| +| Process-group header | Category summary | Summary and live task detail | Hidden in running Turns; retained in historical Turns | Hidden | +| Process-group body | Initially collapsed | Initially collapsed | Directly visible without a group-level height cap in running Turns; manual disclosure in historical Turns | Directly visible without a group-level height cap in running and historical Turns | +| Settled reasoning preview | Hidden | First line | First line | First line | +| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | Manual expansion | +| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | Always open; duration/status header cannot collapse it | -A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Detailed appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title. +A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title. ### Group-title rules -All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Detailed mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation. +All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Standard mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation. The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category. @@ -145,23 +145,23 @@ The labels below describe recorded activity, not successful outcomes. For exampl | Group state | Title composition | |---|---| -| Not closed, with a running tool | Use the live category, not the highest-count category. Detailed appends nonempty detail with ` · `; Compact omits detail. | -| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Detailed may append running reasoning detail. | +| Not closed, with a running tool | Use the live category, not the highest-count category. Standard appends nonempty detail with ` · `; Compact omits detail. | +| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Standard may append running reasoning detail. | | Closed, zero categories | Use the completed-analysis label. | | Closed, one category | Use its closed label without a count. | | Closed, two categories | Join ranked labels with “and”. Chinese removes the second leading `已` only when both labels start with it. | | Closed, three categories | Join all three ranked labels with commas. | | Closed, more than three categories | Show the first three labels followed by “etc.” (`等` in Chinese). | -English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Expanded hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. +English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers. -Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns. +Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap and uses 16px row spacing in running Turns; Verbose applies this layout to historical Turns as well. An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position. Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography. -Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn. +Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Detailed and Verbose do not open every individual disclosure. Only Verbose keeps the whole Turn open. ----- @@ -170,7 +170,7 @@ Switching modes retains manually opened groups and inner disclosures. It changes [process-groups.ts](process-groups.ts) groups visible Chat content; [process-activity.ts](process-activity.ts) summarizes the activity inside each group. Both follow the rules below. -A group collects adjacent process content within one Turn. Step-number changes alone do not split it. Compact, Detailed, and Expanded use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. +A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. | Input | Membership and segmentation | |---|---| diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index ae43726417..341c2826a1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -44,7 +44,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 哪些行放在一起? | 分组边界 | 阶段回复分开 G1 与 G2;从 `read` 换成 `bash` 不会拆组。 | | 一组做了什么? | 类别计数 | G1 为 `read=1, commands=1`,G2 为 `code=1`;思考不增加工具次数。 | | 组标题写什么? | 组状态与摘要 | 未结束组显示最近运行活动;已结束组显示次数最多的类别。次数不决定折叠。 | -| 组内显示多少细节? | 展示模式与手动开合 | 已完成轮次各模式都保留组头,正文手动展开;Expanded 仅对运行中轮次直接显示组正文。 | +| 组内显示多少细节? | 展示模式与手动开合 | 简洁、标准、详细模式保留历史组头;详细模式直接显示运行中正文,完全展开模式直接显示运行中与历史正文。 | | 过程整体是否可见? | 整轮开合 | 整轮收起时,G1、阶段回复和 G2 一起隐藏。 | ### 用户实际看到什么 @@ -53,21 +53,21 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 查看状态 | 可见内容 | |---|---| -| 整轮收起,任意模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 | -| 整轮展开,Compact 或 Detailed | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 | -| 整轮和 G1 展开,Detailed | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 | -| 整轮展开,Expanded | G1/G2 组头、阶段回复及最终答案;已完成轮次的组正文仍需手动打开。 | +| 整轮收起,简洁/标准/详细模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 | +| 整轮展开,简洁/标准/详细模式 | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 | +| 整轮和 G1 展开,标准或详细模式 | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 | +| 完全展开模式 | 不可收起的整轮时长或状态抬头、全部过程行、阶段回复与最终答案。没有组头,单个工具/思考正文仍需手动展开。 | 显隐从外向内生效:整轮 → 二级组 → 单个思考或工具。内层展开不能越过外层收起。类别变化不会开合任何一层;切换模式保留分组和用户的手动展开选择。 -若过程开始后又出现 steering、User 或触发通知,该轮不提供整轮收起,只保留各组自己的开合。`G1 → steering 1 → G2 → steering 2 → G3` 在 Compact/Detailed 中始终保留三个组头及两条输入的顺序,阶段回复也保持可见;打开一个组只显示该组正文。Expanded 仅在该轮仍运行时直接显示组正文,完成后保留组头及手动开合。整轮控件仍显示时长或状态,但不提供折叠操作。 +若过程开始后又出现 steering、User 或触发通知,该轮不提供整轮收起,只保留各组自己的开合。`G1 → steering 1 → G2 → steering 2 → G3` 在简洁/标准模式中始终保留三个组头及两条输入的顺序,阶段回复也保持可见;打开一个组只显示该组正文。详细模式仅在该轮仍运行时直接显示组正文,完成后保留组头及手动开合;完全展开模式在两种情况下都直接显示组正文。整轮控件仍显示时长或状态,但不提供折叠操作。 ----- ## 整轮折叠 -整轮折叠控制已加载的过程范围,独立于二级分组。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 +简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。 @@ -108,21 +108,21 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ## 展示模式 -设置 → 通用设置 → 工作过程展示将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写。 +设置 → 通用设置 → 工作步骤展示提供 `compact`、`standard`(默认)、`detailed` 和 `verbose`,说明为「选择希望看到多少工具调用细节」。旧值 `normal` 读取为 `standard`,`expanded` 读取为 `detailed`,不自动回写;已有 `detailed` 保持不变。未配置、无效值以及 Host 设置到达前均使用 `standard`;其他设置的无效值仍会校验失败。 -| 行为 | 简洁 | 详细 | 完全展开 | -|---|---|---|---| -| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 运行中轮次隐藏;历史轮次保留 | -| 过程组正文 | 初始收起 | 初始收起 | 运行中轮次直接显示、不设组级限高;历史轮次按手动开合显示 | -| 已结算推理预览 | 隐藏 | 首行 | 首行 | -| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | -| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | +| 行为 | 简洁 | 标准 | 详细 | 完全展开 | +|---|---|---|---|---| +| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 运行中轮次隐藏;历史轮次保留 | 隐藏 | +| 过程组正文 | 初始收起 | 初始收起 | 运行中轮次直接显示、不设组级限高;历史轮次按手动开合显示 | 运行中与历史轮次都直接显示,不设组级限高 | +| 已结算推理预览 | 隐藏 | 首行 | 首行 | 首行 | +| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | 手动展开 | +| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | 始终展开;时长或状态抬头不能收起它 | -已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;详细模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。 +已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。 ### 组标题规则 -三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在 Detailed 中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 +三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在标准模式中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 @@ -145,23 +145,23 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 组状态 | 标题组合规则 | |---|---| -| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。Detailed 用 ` · ` 追加非空详情,Compact 不追加。 | -| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;Detailed 可以追加运行中推理详情。 | +| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。标准模式用 ` · ` 追加非空详情,简洁模式不追加。 | +| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;标准模式可以追加运行中推理详情。 | | 已结束,零个类别 | 使用「已完成分析」。 | | 已结束,一个类别 | 使用该类别的已结束文案,不带次数。 | | 已结束,两个类别 | 按排名用「并」连接;仅当两项都以 `已` 开头时省略第二项的 `已`,例如 `已读取文件并搜索代码`。 | | 已结束,三个类别 | 按排名用中文逗号 `,` 连接三项文案。 | | 已结束,超过三个类别 | 只显示前三项文案,末尾加「等」。 | -英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。Expanded 隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组。 +英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。 -组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。 +组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高,使用 16px 行间距;完全展开模式对历史轮次也采用该布局。 已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。 -单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 +单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 -切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。 +切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。详细和完全展开都不表示自动打开每个内部控件;只有完全展开模式始终展开整轮。 ----- @@ -170,7 +170,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" [process-groups.ts](process-groups.ts) 划分可见 Chat 内容,[process-activity.ts](process-activity.ts) 汇总每组活动,两者共同遵循下列业务规则。 -一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。Compact、Detailed 和 Expanded 使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。 +一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。 | 输入 | 归属与切分 | |---|---| diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index af3b5ca741..6f021da319 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb -README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e +README.md: b4a529fbcb1ed33976d71baf043e625fcca4c89c +README.zh.md: 406dc884f3529af79d675e6fbe1586e7485c3780 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index a00ed8e8c7..b4a529fbcb 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -29,7 +29,7 @@ Tool calls appear in the conversation as cards: a root call tree with its nested Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states. -Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · `. Preparation provides no argument body, file link, result, or interaction requiring parameters. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution. +Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · `. Preparation exposes no complete arguments, file link, result, or parameter-dependent interaction. Write/edit show `Preparing content NKB` in the summary; N is `Math.ceil(raw.length / 1024)`, an integer estimate of the raw argument string length, not the file's byte size. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution. ### Registering a business tool view @@ -47,7 +47,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis ### Built-in views -Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`. +Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. The shared argument parser returns no call during preparation and does not parse partial JSON. Write/edit use separate preparing and dispatched components, so only the preparing component invokes `useToolCallArgumentsPartial`; start and result share the dispatched component. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`. This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. @@ -67,6 +67,9 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it. + +The slot-injected `useToolCallArgumentsPartial` Hook lazily subscribes to the owning Step's `assistant-step` source and selects this callId's raw argument prefix. Missing sources or calls return an empty string. Other calls in the same Step may trigger a snapshot check, but an unchanged selected string does not refresh the consumer. Tools that do not invoke the Hook add no subscription; dispatched calls have no argument-prefix source. + ### Cards diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index c4a6f6183b..406dc884f3 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。 -派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。 +派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供完整参数、文件链接、结果或依赖参数的交互。write/edit 的摘要显示「正在准备内容 NKB」;N 为 `Math.ceil(raw.length / 1024)`,是原始参数字符串长度的整数近似值,不是文件字节数。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。 ### 注册业务工具视图 @@ -47,7 +47,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 ### 内置视图 -每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 +每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段的共享参数解析入口直接返回无调用,不解析部分 JSON。write/edit 将准备态和派发后阶段拆成两个组件,只有准备态组件调用 `useToolCallArgumentsPartial`,start 与 result 共用派发后组件。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 @@ -67,6 +67,9 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。 + +slot 注入的 `useToolCallArgumentsPartial` 钩子按需订阅所属 Step 的 `assistant-step` 来源,并选取当前 callId 的原始参数前缀。来源或调用不存在时返回空字符串。同一步骤中的其他调用可能触发快照检查,但选中的字符串未变时不会刷新使用方。不调用钩子的工具不新增订阅,已派发的调用不再提供参数前缀来源。 + ### 卡片 From 6943f9fee6fb65d826f47038cfab2816ee91c251 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 19:33:31 +0800 Subject: [PATCH 09/14] fix(chat): satisfy hook documentation and lint contracts --- .../client/ui-chat/src/client/chat/TurnProcessNodeView.tsx | 6 +++--- packages/client/ui-chat/src/index.ts | 3 ++- .../ui-tool/src/client/tool/tool-call-arguments-partial.ts | 6 ++++-- .../tests/tool-call-arguments-partial.client.spec.tsx | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index b404787a1a..71dc44693b 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -25,9 +25,9 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ }, [ticking]) if (turn?.start === undefined && turn?.status !== 'closed') return null const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node) - const running = turn?.status === 'open' - const reason = turn?.end?.data.reason.kind - const elapsedMs = turn?.start === undefined ? undefined + const running = turn.status === 'open' + const reason = turn.end?.data.reason.kind + const elapsedMs = turn.start === undefined ? undefined : Math.max(1000, (turn.end?.time ?? now) - turn.start.time) const duration = elapsedMs === undefined ? undefined : running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t) diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index fabb8cf7df..b919f5786a 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -9,7 +9,8 @@ import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts' import { ChatSettingsFields } from './chat-settings.ts' export { - CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, + LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, } from './chat-settings.ts' diff --git a/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts b/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts index 071e3d4fd2..bc55412b4d 100644 --- a/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts +++ b/packages/client/ui-tool/src/client/tool/tool-call-arguments-partial.ts @@ -11,13 +11,15 @@ const subscribeEmpty = (): (() => void) => () => {} * @returns a Hook that reads only this call's raw argument prefix. */ export const bindToolCallArgumentsPartial: ToolCallInjected['hooks']['toolCallArgumentsPartial'] = ( - _standard, { assistant, callId }, + _standard, context, ) => { + const { assistant, callId } = context + const subscribe = assistant === undefined ? subscribeEmpty : (listener: () => void) => assistant.subscribe(listener) const getSnapshot = (): string => { const block = assistant?.getSnapshot()?.blocks.find(candidate => candidate.kind === 'tool-call' && candidate.callId === callId) return block?.kind === 'tool-call' ? block.argsRaw : '' } return function useToolCallArgumentsPartial() { - return useSyncExternalStore(assistant?.subscribe ?? subscribeEmpty, getSnapshot) + return useSyncExternalStore(subscribe, getSnapshot) } } diff --git a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx index f1b82985cb..f3e30f81b0 100644 --- a/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-arguments-partial.client.spec.tsx @@ -28,9 +28,9 @@ function source(initial: AssistantChatData | undefined) { const store = createSnapshotStore(initial) const listeners = new Set<() => void>() return { - set: store.set, + set: (value: AssistantChatData | undefined) => { store.set(value) }, listeners, - getSnapshot: store.getSnapshot, + getSnapshot: () => store.getSnapshot(), subscribe: (listener: () => void) => { listeners.add(listener) const dispose = store.subscribe(listener) From 96a4607ba2fe163b0ec6333da3499c7411efa4de Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 19:35:53 +0800 Subject: [PATCH 10/14] test(chat): refresh work detail snapshots and preparation progress --- apps/web/tests/changed-files-turn.e2e.ts | 41 ++++++++++++++++++- .../expected/cordis-history/ui.expected.md | 8 ++-- .../desktop-locale/automatic.expected.md | 4 +- .../desktop-locale/selected.expected.md | 4 +- .../settings-chrome/dialog-en.expected.md | 4 +- .../dialog-no-browser.expected.md | 4 +- .../settings-chrome/dialog.expected.md | 4 +- .../expected/steer-all/mid-steer.expected.md | 2 +- .../steer-all/settled-expanded.expected.md | 2 +- apps/web/tests/settings-chrome.e2e.ts | 16 ++++---- apps/web/tests/turn-tail-actions.e2e.ts | 33 +++++++++------ .../preparing-edit.expected.md | 1 + .../preparing-write.expected.md | 1 + .../web/changed-files-turn/ui.expected.md | 6 +-- .../feedback-command/ack-expanded.expected.md | 2 +- .../feedback-release/ack-expanded.expected.md | 2 +- .../fresh-round-trip/ui-expanded.expected.md | 4 +- .../ui-expanded.expected.md | 18 ++++---- .../reloaded-expanded.expected.md | 2 +- .../retry-expanded.expected.md | 2 +- .../plan-review/approved-expanded.expected.md | 4 +- snapshots/web/present-svg/ui.expected.md | 4 +- snapshots/web/present/ui.expected.md | 4 +- snapshots/web/ptc-round/preparing.expected.md | 2 +- snapshots/web/ptc-round/ui.expected.md | 4 +- .../answered-expanded.expected.md | 4 +- .../question-composer/cancelled.expected.md | 2 +- .../seeded-history/command-row.expected.md | 4 +- .../seeded-history/feedback-row.expected.md | 4 +- .../seeded-history/ui-expanded.expected.md | 4 +- snapshots/web/skill-tool-row/ui.expected.md | 4 +- snapshots/web/steering/mid-steer.expected.md | 4 +- .../ui-expanded.expected.md | 4 +- snapshots/web/workflow-run/ui.expected.md | 4 +- 34 files changed, 129 insertions(+), 83 deletions(-) create mode 100644 snapshots/web/changed-files-turn/preparing-edit.expected.md create mode 100644 snapshots/web/changed-files-turn/preparing-write.expected.md diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 321d21fe3b..efa9d21e2a 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -14,7 +14,7 @@ import { fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' +import { openSettings, connectFreshWorkspaceZh, expandOwningTurnProcess, ZH_BROWSER_LOCALE } from './support.ts' const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url)) const FIXTURE = join(DIR, 'session.v3.jsonl') @@ -62,6 +62,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { let tripwire: ReturnType let cwd: string let replayRoot: string | undefined + let releasePreparations: (() => void) | undefined beforeAll(async () => { let replayOverride: string | undefined @@ -92,6 +93,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { }) afterAll(async () => { + releasePreparations?.() try { await browser?.close() } finally { @@ -106,10 +108,45 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { it('records the edited, created, and shell-appended files with their line counts', async () => { if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) const settled = scaffold.whenTurnSettled() + const preparations = ['edit', 'write'].map(name => ({ + name, ready: Promise.withResolvers<{ callId: string; kilobytes: number }>(), + release: Promise.withResolvers(), held: false, + })) + const names = new Map() + const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + for await (const chunk of next()) { + yield chunk + if (chunk.type !== 'tool-call-delta') continue + if (chunk.name !== undefined) names.set(chunk.id, chunk.name) + const preparation = preparations.find(value => value.name === names.get(chunk.id)) + if (preparation === undefined || preparation.held || chunk.argumentsDelta.length === 0) continue + preparation.held = true + preparation.ready.resolve({ callId: chunk.id, kilobytes: Math.ceil(chunk.argumentsDelta.length / 1024) }) + await preparation.release.promise + } + }, { prepend: true }) + releasePreparations = () => { + for (const preparation of preparations) preparation.release.resolve(undefined) + dispose() + } const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') - const sessionId = await settled + const observations = preparations.map(async (preparation) => { + const { callId, kilobytes } = await Promise.race([ + preparation.ready.promise, + settled.then(() => { throw new Error(`No ${preparation.name} argument prefix was streamed`) }), + ]) + const row = page.locator(`[data-chat-call-id="${callId}"] [data-state="preparing"]`) + await row.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, row) + await row.getByText(`正在准备内容 ${kilobytes}KB`, { exact: true }).waitFor() + expect(await row.getByRole('button').count()).toBe(0) + expect(await row.locator('pre').count()).toBe(0) + await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}.expected.md`), await row.ariaSnapshot(), MODE) + preparation.release.resolve(undefined) + }) + const [sessionId] = await Promise.all([settled, ...observations]).finally(() => { releasePreparations?.() }) const session = scaffold.ctx.agents.get(sessionId)?.session if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace') cwd = session.header.cwd diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 45381454fa..5337f00b50 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -14,9 +14,9 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Called tools" [expanded] -- button "Think" +- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." - button "Inspect plugins 0 dynamic plugins" -- button "Think" +- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." - button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": - tab "Client" @@ -28,11 +28,11 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think" +- button "Think The Host returned snap-1/pkg-1, so I will request its first activation." - text: Run Cordis Plugin snap-1 · pkg-1 Ready - button "Inspect" - text: snap-1/pkg-1 is awaiting user approval (run-1). -- button "Think" +- button "Think The activation request has been submitted, so I will return the requested readiness marker." - paragraph: CORDIS_UI_READY - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index dcb0ed4d1b..58ef7cfca5 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -18,8 +18,8 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 工作过程展示 控制轮次和步骤的默认展开方式 - - button "简洁" + - text: px 工作步骤展示 选择希望看到多少工具调用细节 + - button "标准" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index eab4f4e977..1a1d3524fc 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -18,8 +18,8 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Work details Controls how turns and steps expand by default - - button "Compact" + - text: px Work details Choose how much detail to show for tool calls + - button "Standard" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 3f945d9cce..6d08031062 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -18,8 +18,8 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Work details Controls how turns and steps expand by default - - button "Compact" + - text: px Work details Choose how much detail to show for tool calls + - button "Standard" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 3f945d9cce..6d08031062 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -18,8 +18,8 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Work details Controls how turns and steps expand by default - - button "Compact" + - text: px Work details Choose how much detail to show for tool calls + - button "Standard" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c0a1b2ed35..8a6184330c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -18,8 +18,8 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 工作过程展示 控制轮次和步骤的默认展开方式 - - button "简洁" + - text: px 工作步骤展示 选择希望看到多少工具调用细节 + - button "标准" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 6a14e1ea0f..37a6c38e98 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -10,7 +10,7 @@ - button "Copy" - status: Deep diving... - button "Deep diving for {{duration}}" [disabled] [expanded] -- button "Analyzing the request" +- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - text: "Interjection: include the word ORANGE in your final reply." diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 3e00422aa2..eb11ecdd4c 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -11,7 +11,7 @@ - status: Worked - button "Took {{duration}}" [disabled] - button "Asked questions" [expanded] -- button "Think" +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index ea014fd3b2..14278995c4 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -560,15 +560,15 @@ describe('web e2e: settings modal and General preferences', () => { }, 90_000) it.each([ - ['detailed', '详细'], ['expanded', '完全展开'], + ['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'], ] as const)('persists the %s work-details mode across reload', async (mode, label) => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view')) await openSettings(page, 'zh') const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 }) - const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..') - await details.getByRole('button', { name: '简洁', exact: true }).click() + await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 }) + const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..') + await details.getByRole('button', { name: '标准', exact: true }).click() await page.getByRole('menuitem', { name: label, exact: true }).click() await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 }) @@ -581,14 +581,14 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await openSettings(page, 'zh') const reloaded = page.getByRole('dialog', { name: '设置' }) - const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..') + const restoredDetails = reloaded.getByText('工作步骤展示', { exact: true }).locator('../..') await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) await restoredDetails.getByRole('button', { name: label, exact: true }).click() - await page.getByRole('menuitem', { name: '简洁', exact: true }).click() - await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 }) + await page.getByRole('menuitem', { name: '标准', exact: true }).click() + await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 }) - .toContain('transcriptView: compact') + .toContain('transcriptView: standard') await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7a7bfe9293..e00c958e26 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -244,8 +244,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { }, 60_000) it.skipIf(MODE === 'record').each([ - ['detailed', 'Detailed'], ['expanded', 'Expanded'], - ] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => { + ['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'], + ] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => { await launch() onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) const { settled } = await sendPrompt() @@ -259,31 +259,38 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await openSettings(page, 'en') const dialog = page.getByRole('dialog', { name: 'Settings' }) await dialog.getByText('Work details', { exact: true }).locator('../..') - .getByRole('button', { name: 'Compact', exact: true }).click() + .getByRole('button', { name: 'Standard', exact: true }).click() await page.getByRole('menuitem', { name: label, exact: true }).click() await page.keyboard.press('Escape') expect(await process.count()).toBe(1) - expect(await process.getAttribute('aria-expanded')).toBe('false') - expect(await tool.isVisible()).toBe(false) await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 }) .toContain(`transcriptView: ${mode}`) - await process.click() const group = page.locator('[data-sample="bash"]').first().locator('xpath=ancestor::*[@data-chat-group-key]') const groupControl = group.locator('[data-process-activity]') - expect(await groupControl.isVisible()).toBe(true) - expect(await groupControl.getAttribute('aria-expanded')).toBe('false') - expect(await tool.isVisible()).toBe(false) - await groupControl.click() - expect(await tool.isVisible()).toBe(true) - await process.click() + if (mode === 'verbose') { + expect(await process.isDisabled()).toBe(true) + expect(await process.getAttribute('aria-expanded')).toBe('true') + expect(await groupControl.isVisible()).toBe(false) + expect(await tool.isVisible()).toBe(true) + } else { + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + await process.click() + expect(await groupControl.isVisible()).toBe(true) + expect(await groupControl.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + await groupControl.click() + expect(await tool.isVisible()).toBe(true) + await process.click() + } await openSettings(page, 'en') const restored = page.getByRole('dialog', { name: 'Settings' }) await restored.getByText('Work details', { exact: true }).locator('../..') .getByRole('button', { name: label, exact: true }).click() - await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: 'Standard', exact: true }).click() await page.keyboard.press('Escape') await process.waitFor({ timeout: 10_000 }) expect(await process.getAttribute('aria-expanded')).toBe('false') diff --git a/snapshots/web/changed-files-turn/preparing-edit.expected.md b/snapshots/web/changed-files-turn/preparing-edit.expected.md new file mode 100644 index 0000000000..c563131f17 --- /dev/null +++ b/snapshots/web/changed-files-turn/preparing-edit.expected.md @@ -0,0 +1 @@ +- text: 正在准备调用 编辑 正在准备内容 1KB diff --git a/snapshots/web/changed-files-turn/preparing-write.expected.md b/snapshots/web/changed-files-turn/preparing-write.expected.md new file mode 100644 index 0000000000..b639c72c3f --- /dev/null +++ b/snapshots/web/changed-files-turn/preparing-write.expected.md @@ -0,0 +1 @@ +- text: 正在准备调用 写入 正在准备内容 1KB diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 7500599d2e..61b2cdc9a2 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -3,12 +3,12 @@ - status: 已完成工作 - button "用时 {{duration}}" [expanded] - button "执行了命令,已写入文件,已读取文件等" [expanded] -- button "思考" +- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it." - button "读取 intro.md": - text: 读取 - button "intro.md" - button "运行命令 List files and show notes.txt" -- button "思考" +- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append." - button "编辑 intro.md +1 -1": - text: 编辑 - button "intro.md" @@ -22,7 +22,7 @@ - button "app.local" - text: +1 -0 - button "运行命令 Append done to notes.txt" -- button "思考" +- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."' - button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt" - paragraph: 四件事全部完成: - list: diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index f9d619e382..10e74c04ce 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -11,7 +11,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user wants me to reply with a single word. Let me comply." - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index f9d619e382..10e74c04ce 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -11,7 +11,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user wants me to reply with a single word. Let me comply." - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0c4d334df3..e277911d98 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -11,9 +11,9 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Ran commands" [expanded] -- button "Think" +- button "Think The user wants me to run a simple bash command and reply with \"DONE\"." - button "Bash Echo the test string" -- button "Think" +- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 067f2ece45..e2676d0594 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -17,18 +17,18 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Ran commands and updated the plan" [expanded] -- button "Think" +- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""' - button "Bash List package directories deterministically" - button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" -- button "Think" +- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."' - button "Bash List packages directory contents" -- button "Think" +- button "Think There are 4 packages:" - button "Failed Bash Randomly pick one package directory" -- button "Think" +- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."' - button "Bash Randomly select one package with Python" -- button "Think" +- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."' - button "Bash List all files in session-reference package" -- button "Think" +- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."' - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -54,12 +54,12 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Ran commands" [expanded] -- button "Think" +- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."' - button "Bash Randomly select another package with Python" -- button "Think" +- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."' - button "Bash Re-roll excluding the package from turn 1" - button "Bash List all files in token-meter package" -- button "Think" +- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."' - paragraph: - strong: Turn 2 / 2 - text: — 随机选中的包: diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 761eb41613..d58fce245c 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -11,7 +11,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user wants me to reply with a single word. Let me comply." - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index ad8e9f8f2b..672e1c5b05 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -13,7 +13,7 @@ - group: - status: Retried model request (1/5) · {{duration}} - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 6f978228c7..23a36d6ec3 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -10,14 +10,14 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."' - paragraph: - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . - button "Called tools" [expanded] - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"' -- button "Think" +- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."' - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 08f503ab05..aa49819a0e 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -3,14 +3,14 @@ - status: 已完成工作 - button "用时 {{duration}}" [expanded] - button "已完成分析" [expanded] -- button "思考" +- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses." - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 - button "已写入文件并调用工具" [expanded] - button "写入 von-neumann.svg +83 -0": - text: 写入 - button "von-neumann.svg" - text: +83 -0 -- button "思考" +- button "思考 The file is created. Let me present it as a deliverable." - button "交付文件 已交付 von-neumann.svg" - paragraph: - text: 已创建 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 34a2a77047..63418d2f53 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -11,13 +11,13 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] -- button "Think" +- button "Think The user wants one run_code program that:" - text: Failed - 'button "Code Error: code run failed (exception): AFTER_PRESENT"' - button "Present files Delivery failed missing.txt" - button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files" - button "Present files Delivered report.txt, 说明.txt" -- button "Think" +- button "Think The program ran as intended:" - paragraph: - text: "The single program ran exactly as ordered:" - code: present diff --git a/snapshots/web/ptc-round/preparing.expected.md b/snapshots/web/ptc-round/preparing.expected.md index 53b352db4c..c3b9eac0bd 100644 --- a/snapshots/web/ptc-round/preparing.expected.md +++ b/snapshots/web/ptc-round/preparing.expected.md @@ -1,3 +1,3 @@ - button "Preparing to run code" [expanded] -- button "Think" +- 'button "Think The user wants me to write a single `run_code` program that:"' - text: Preparing tool call Code diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 986dcf439c..873cf43d8d 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -11,7 +11,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Ran code, ran commands, read files" [expanded] -- button "Think" +- 'button "Think The user wants me to write a single `run_code` program that:"' - button "Code Run bash echo and catch missing file read" - button "Bash Echo CODE_ROUND_OK" [expanded] - text: Done workspace echo CODE_ROUND_OK @@ -20,7 +20,7 @@ - button "Inspect" - text: Failed - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"' -- button "Think" +- button "Think The program ran successfully. Let me now reply DONE as instructed." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 346b77ff8d..01d7ae2f94 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -11,12 +11,12 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Asked questions" [expanded] -- button "Think" +- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." - button "Ask question 1/1 answered" [expanded] - term: Which color do you prefer? - definition: Blue Include accessibility notes - button "Inspect" -- button "Think" +- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 7d5d89a078..855fb34837 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -10,7 +10,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Asked questions" [expanded] -- button "Think" +- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." - button "Ask question cancelled" [expanded] - paragraph: This question set was cancelled before answers were submitted. - list: diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 86f3b1a9cc..bbea1a9d41 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -13,14 +13,14 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Read files" [expanded] -- button "Think" +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 769c31023f..fa6a31d7ae 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -13,14 +13,14 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Read files" [expanded] -- button "Think" +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 6cbe989134..8623e4329b 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -13,14 +13,14 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Read files" [expanded] -- button "Think" +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 6e08ccbd47..ea40915240 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -10,11 +10,11 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Called tools" [expanded] -- button "Think" +- button "Think Load the requested skill." - button "Skill editing-cordis-compositions" [expanded] - region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file states the declaration format; when it leaves a question open, read the `@deepseek-ai/dsh-agent-preset` README and `lib/types` declarations under the `packageDir` that `cordis_inspect_query` `Config.listConfigs` returns when queried with a `preset-` row's `entry` id, or `packages/preset/agent-preset/src` in a source checkout. ## Where declarations live The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, the bundle resolves from the dsh installation, not the profile; querying `Config.listConfigs` with the `entry` id of any `preset-` row it declares returns that `packageDir`. In a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with the file-read tool when you need a template; `minimal.patch.yml` is the shortest. In Desktop the bundle sits inside `app.asar`, which shell commands cannot open. Load `cordis-composition-reference` for the patch dialect and the list of plugin packages a preset can mount. A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. ## Create a preset Write a bundle directory in the workspace with exactly two files, then install it. `review-preset/package.json`: ```json { \"name\": \"@local/dsh-review-preset\", \"version\": \"1.0.0\", \"private\": true, \"type\": \"module\", \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } } } ``` `review-preset/cordis.patch.yml`: ```yaml - insert: - id: preset-review name: '@deepseek-ai/dsh-agent-preset' config: id: review name: Review description: Reviews changes with the shell only. order: 10 plugins: - id: persona name: '@deepseek-ai/dsh-persona' config: prefix: You review software changes. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' ``` Install with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands. ## Change a shipped preset Override the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries: ```yaml - id: preset-standard name: '@deepseek-ai/dsh-agent-preset' config: id: standard order: 1 plugins: # the shipped list with your changes ``` ## Migrate a legacy preset Before declaration rows, a user preset was a directory `$DSH_HOME/.agent-presets//` holding `preset.yml` (display `name`, `description`, `order`) and `agent.cordis.yml` (the plugin entry list). Nothing reads that directory any more. To migrate one, create a bundle as above whose declaration takes `id` from the directory name, `name`, `description`, and `order` from `preset.yml`, and `plugins` from `agent.cordis.yml` verbatim; check each plugin name against `cordis-composition-reference` because packages renamed since the preset was written fail at activation. Install it, verify the row, then delete the legacy directory. ## Verify `plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. ## Choose plugin placement Host plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents. A preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory. ## Extend the Host For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`, including a mounted plugin's Config schema through the Host `Config` provider; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. " - button "Inspect" -- button "Think" +- button "Think The skill is loaded." - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 0a731c2f3e..505c03941c 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -9,8 +9,8 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] -- button "Waiting for your action" +- button "Deep diving for {{duration}}" [disabled] +- button "Waiting for your action · Ready to continue?" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - region "Ready to continue?": diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 625180666b..4fa5dffd27 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -18,7 +18,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -29,7 +29,7 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] -- button "Think" +- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 12ffccb420..f0272f08d8 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -3,10 +3,10 @@ - status: Worked - button "Took {{duration}}" [expanded] - button "Called tools" [expanded] -- button "Think" +- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" - button "Run workflow snapshot-flow" - button "snapshot-flow 1 member Completed" -- button "Think" +- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop." - paragraph: WORKFLOW_DONE - button "Copy" - button "Good response" From 51d70c5f5cb68560c43d259033cf7aef55c41af6 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 23 Sep 2026 19:51:29 +0800 Subject: [PATCH 11/14] feat(plugins): report incompatible versions as typed refusals Compatibility checks now yield the refused packages as data instead of a profile-specific sentence. runProfilePnpm returns them as PackageResult.incompatible, and the plugin manager refuses with the incompatible-version code carrying the same records. Each surface renders them: the CLI prints the exact allow-version command for its profile, the Web plugin manager words them through its locale dictionary, and app-boot keeps one neutral English diagnostic for logs and startup stderr. --- apps/cli/src/plugin.ts | 3 ++ apps/cli/tests/plugin.spec.ts | 13 +++++++++ docs/event-producer-consumer.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 6 ++-- docs/event-producer-consumer.zh.md | 6 ++-- packages/api/remotes/src/client/index.ts | 3 +- .../app-boot/src/compatibility-preflight.ts | 2 +- .../boot/app-boot/src/plugin-compatibility.ts | 12 +++----- packages/boot/app-boot/src/profile.ts | 2 +- .../tests/plugin-compatibility.spec.ts | 10 +------ packages/boot/app-boot/tests/profile.spec.ts | 1 - packages/boot/plugin-manager/README.i18n.yaml | 4 +-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/failure.ts | 25 +++++++++++++++-- packages/boot/plugin-manager/src/index.ts | 15 ++++++---- .../boot/plugin-manager/src/operations.ts | 28 +++++++++++++++---- packages/boot/plugin-manager/src/types.ts | 15 +++++++++- .../boot/plugin-manager/tests/manager.spec.ts | 19 +++++++++++-- .../plugin-manager/tests/operations.spec.ts | 4 +++ .../src/client/PluginManagerPage.tsx | 4 +++ .../ui-plugin-manager/src/client/locales.ts | 4 +++ .../src/client/manager-store.ts | 18 ++++++++++-- .../src/client/presentation.ts | 25 +++++++++++++---- .../tests/components.client.spec.tsx | 17 +++++++++++ .../tests/manager-store.client.spec.ts | 18 ++++++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 8 ++++-- 27 files changed, 206 insertions(+), 64 deletions(-) diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index fcdfa58532..8f60bf8bfc 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -74,6 +74,9 @@ export async function runPlugin(profile: string, args: readonly string[]): Promi onOutput: (text, stream) => { process[stream].write(text) }, }) if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n') + for (const { name, version, runtimeVersion } of result.incompatible ?? []) { + process.stderr.write(`dsh: to accept the risk, run: dsh plugin --profile ${profile} allow-version ${name}@${version} --dsh-version ${runtimeVersion} --accept-risk\n`) + } if (result.exitCode !== 0) process.stderr.write(`dsh: plugin command failed; diagnostics: ${result.logPath}\n`) if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) { process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts index 4a3ccd10d4..1a69b62693 100644 --- a/apps/cli/tests/plugin.spec.ts +++ b/apps/cli/tests/plugin.spec.ts @@ -81,6 +81,19 @@ it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitC else expect(stderr).toHaveBeenCalledWith('dsh: plugin command failed; diagnostics: /profile/log\n') }) +it('names the exact grant command for each package a compatibility check refused', async () => { + const { stderr } = fixture() + vi.mocked(runPluginCommand).mockResolvedValue({ + exitCode: 1, output: '', truncated: false, logPath: '/profile/log', + incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }], + }) + expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1) + expect(stderr.mock.calls.map(call => call[0])).toEqual([ + 'dsh: to accept the risk, run: dsh plugin --profile test allow-version @example/plugin@1.2.3 --dsh-version 0.1.0 --accept-risk\n', + 'dsh: plugin command failed; diagnostics: /profile/log\n', + ]) +}) + it('forwards all other commands unchanged and retains package diagnostics', async () => { const { stderr } = fixture() vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 127, output: '', truncated: false, logPath: '/profile/log' }) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a86f94ab5f..1f93fe0ef6 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: fb0816fe5f66b3981eac1f0fcb2b4d418a12ecfe -event-producer-consumer.zh.md: b2f7a1b2075bd8328a45ed7eedef2c5a3eecd1ce +event-producer-consumer.md: b67f1c9b2ab5764e8be7be0bb584de9da4eea103 +event-producer-consumer.zh.md: df371af272167b3b5d12ad9cc32abb27eaeea058 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fb0816fe5f..b67f1c9b2a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -52,9 +52,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b2f7a1b207..df371af272 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -54,9 +54,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ec97b6cc77..a00689e949 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -27,7 +27,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { - BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult, + BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, + PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, ReadOnlyReason, Registry, } from '@deepseek-ai/dsh-plugin-manager/types' diff --git a/packages/boot/app-boot/src/compatibility-preflight.ts b/packages/boot/app-boot/src/compatibility-preflight.ts index bd8fc2059c..5b4589b117 100644 --- a/packages/boot/app-boot/src/compatibility-preflight.ts +++ b/packages/boot/app-boot/src/compatibility-preflight.ts @@ -103,7 +103,7 @@ function preflight( const manifest = manifestOf(ctx, row.name, base) if (manifest === undefined) return undefined const issue = evaluatePluginCompatibility(manifest, exemptions) - return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue, profile.name) + return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue) } catch (error) { // Peer metadata that cannot be read or validated is refused rather than silently admitted. /* v8 ignore next -- every reader and parser used here rejects with an Error. */ diff --git a/packages/boot/app-boot/src/plugin-compatibility.ts b/packages/boot/app-boot/src/plugin-compatibility.ts index 52508e34cb..047fe1f96e 100644 --- a/packages/boot/app-boot/src/plugin-compatibility.ts +++ b/packages/boot/app-boot/src/plugin-compatibility.ts @@ -89,19 +89,15 @@ export function evaluatePluginCompatibility( /** * Describe incompatible peers, their risk, and the exact-version remedy. + * Surfaces with their own grant mechanism or locale render the structured result themselves. * @param issue - incompatible plugin/runtime result, including exempted mismatches. - * @param profile - profile whose composition met the plugin; a CLI-managed profile gets the `dsh plugin` grant command. - * @returns a warning suitable for CLI and plugin-management diagnostics. + * @returns an English diagnostic for logs and stderr. */ -export function pluginCompatibilityWarning(issue: PluginCompatibility, profile?: string): string { +export function pluginCompatibilityWarning(issue: PluginCompatibility): string { const key = `${issue.name}@${issue.version}` - // The Electron application owns the desktop profile, and `dsh plugin` refuses to manage it. - const grant = profile === undefined || profile.toLowerCase() === 'desktop' - ? `grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} through the plugin manager` - : `run \`dsh plugin --profile ${profile} allow-version ${key} --dsh-version ${issue.runtimeVersion} --accept-risk\`` return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. ` + 'Running it may cause crashes or data loss. ' + 'Update the plugin or install a plugin version compatible with this dsh runtime. ' - + `To accept this risk explicitly, ${grant}, then retry the installation or restart dsh. ` + + `To accept this risk explicitly, grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} with \`dsh plugin allow-version\` or the plugin manager, then retry the installation or restart dsh. ` + `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.` } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index b6257241d8..7406443b26 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -659,7 +659,7 @@ export function loadProfileDirectory( } // A bundle is not a plugin row, so row admission never reads its own peers. const issue = evaluatePluginCompatibility(bundleManifest, exemptions) - if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue, basename(dir))) + if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue)) const patchPaths = bundlePatchPaths(packageDir, bundle) const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath)) layers.push({ packageName, packageDir, patchPaths, patches }) diff --git a/packages/boot/app-boot/tests/plugin-compatibility.spec.ts b/packages/boot/app-boot/tests/plugin-compatibility.spec.ts index 225bb873a6..951bbf6a31 100644 --- a/packages/boot/app-boot/tests/plugin-compatibility.spec.ts +++ b/packages/boot/app-boot/tests/plugin-compatibility.spec.ts @@ -171,16 +171,8 @@ describe('compatibility warning', () => { + 'Running it may cause crashes or data loss. ' + 'Update the plugin or install a plugin version compatible with this dsh runtime. ' + 'To accept this risk explicitly, grant the exact-version exemption for @example/plugin@2.0.0 on dsh 0.1.7-alpha.1 ' - + 'through the plugin manager, then retry the installation or restart dsh. ' + + 'with `dsh plugin allow-version` or the plugin manager, then retry the installation or restart dsh. ' + `Exact-version exemption: ${exempted ? 'active' : 'not active'}.`, ) }) - - it('names the dsh plugin grant command for a CLI-managed profile only', () => { - const issue = { ...identity, runtimeVersion: runtime, peers: { '@deepseek-ai/dsh': '^0.2.0' }, exempted: false } - expect(pluginCompatibilityWarning(issue, 'web')).toContain( - 'run `dsh plugin --profile web allow-version @example/plugin@2.0.0 --dsh-version 0.1.7-alpha.1 --accept-risk`, then retry', - ) - expect(pluginCompatibilityWarning(issue, 'Desktop')).toContain('through the plugin manager') - }) }) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 3674c1a2dd..57b05d9aa5 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -447,7 +447,6 @@ describe('loadProfile', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( `skipping profile bundle "guarded": Error: Plugin guarded@0.0.0 is incompatible with dsh ${getDshRuntimeVersion()}`, )) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('dsh plugin --profile demo allow-version guarded@0.0.0')) writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify({ 'guarded@0.0.0': [getDshRuntimeVersion()] })) expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['guarded', 'kept']) }) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 9cb2f10b4d..a076c5dabe 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/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/boot/plugin-manager/README.md -README.md: 2bb8fa493f92a077c63bbefd281b89c1ea2d1be6 -README.zh.md: 005af225a880e4edabfe44e2c8c2df980779ae6e +README.md: c047c71476308f0123bdebed6d9ab21e8ececc2f +README.zh.md: 1f99f67498790d997a7e345ca301b85c319c7b09 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 2bb8fa493f..c047c71476 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -66,7 +66,7 @@ An exemption is an exact `package-name@version` mapped to a list of exact DSH ru A grant takes effect on the next composition. A live profile recomposes, so the granted plugin mounts in the running session and the result reports `applied`; a startup-only profile keeps its current entries until restart and reports `restart-required`. -The CLI exposes `dsh plugin --profile version-exemptions`, `allow-version --dsh-version --accept-risk`, and `revoke-version --dsh-version `. A grant prints a risk warning before saving. The Web page reports compatibility failures; use the tool or CLI to grant an exemption and retry the original operation. +The CLI exposes `dsh plugin --profile version-exemptions`, `allow-version --dsh-version --accept-risk`, and `revoke-version --dsh-version `. A grant prints a risk warning before saving. A compatibility refusal carries the `incompatible-version` code with each refused package's `name`, `version`, `runtimeVersion`, and unsatisfied `peers`; each surface renders that record itself. The Web page words it through its locale dictionary, and a CLI refusal prints the exact `allow-version` command. Use the tool or CLI to grant an exemption and retry the original operation. ### Configuration diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 005af225a8..1f99f67498 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -66,7 +66,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro 授权在下一次组合时生效。在线 profile 会重新组合,被授权的插件会在当前会话中挂载,结果报告 `applied`;仅启动型 profile 在重启前保留当前条目并报告 `restart-required`。 -CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version --dsh-version --accept-risk` 和 `revoke-version --dsh-version `。授权会在保存前打印风险警告。Web 页面报告兼容性失败;通过工具或 CLI 添加豁免后,重试原操作。 +CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version --dsh-version --accept-risk` 和 `revoke-version --dsh-version `。授权会在保存前打印风险警告。兼容性拒绝带有 `incompatible-version` 错误码,以及每个被拒绝软件包的 `name`、`version`、`runtimeVersion` 和未满足的 `peers`;各界面自行呈现这份记录。Web 页面通过 locale 词典生成文案,CLI 拒绝时打印精确的 `allow-version` 命令。通过工具或 CLI 添加豁免后,重试原操作。 ### 配置 diff --git a/packages/boot/plugin-manager/src/failure.ts b/packages/boot/plugin-manager/src/failure.ts index 2c1046d632..b8f01b075e 100644 --- a/packages/boot/plugin-manager/src/failure.ts +++ b/packages/boot/plugin-manager/src/failure.ts @@ -1,10 +1,29 @@ /** Localizable rejections shared by profile management operations. */ -import type { ManagementError } from './types.ts' +import type { PluginCompatibility } from '@deepseek-ai/dsh-app-boot' +import type { IncompatiblePlugin, ManagementError } from './types.ts' /** Expected management rejection; presentation belongs to the caller's locale. */ export class ManagementFailure extends Error { /** Code rendered by the caller's locale dictionary. */ readonly code: ManagementError['code'] - /** @param code Localizable management rejection. */ - constructor(code: ManagementError['code']) { super(code); this.code = code } + /** The packages an `incompatible-version` rejection names. */ + readonly incompatible: IncompatiblePlugin[] | undefined + /** + * @param code Localizable management rejection. + * @param incompatible Packages the running DSH version rejects, for `incompatible-version`. + */ + constructor(code: ManagementError['code'], incompatible?: IncompatiblePlugin[]) { + super(code) + this.code = code + this.incompatible = incompatible + } +} + +/** + * Drop the exemption status from an unexempted compatibility result. + * @param issue Result whose exemption is not active. + * @returns The package, runtime, and rejected peer ranges. + */ +export function incompatiblePlugin(issue: PluginCompatibility): IncompatiblePlugin { + return { name: issue.name, version: issue.version, runtimeVersion: issue.runtimeVersion, peers: issue.peers } } diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 6f5a5cc1c1..ea8a94f548 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -14,7 +14,7 @@ import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, - evaluatePluginCompatibility, pluginCompatibilityWarning, readProfileCompatibility, readProfileVersionExemptions, + evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' @@ -24,7 +24,7 @@ import { classifyInstallFailure } from './install-failure.ts' import { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts' import { writePluginEnabled } from './patch.ts' -import { ManagementFailure } from './failure.ts' +import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, readPendingBuilds } from './build-approval.ts' import { checkGithubConnection } from './github-connection.ts' import type { @@ -91,7 +91,8 @@ function messageOf(error: unknown): string { return error instanceof Error ? err /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */ function managementError(error: unknown): ManagementError { - return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) } + if (!(error instanceof ManagementFailure)) return { code: 'operation-error', diagnostic: messageOf(error) } + return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } } } /** The caller stopped an installation; its files are restored before this is thrown. */ @@ -298,7 +299,7 @@ export class PluginManager extends TypertRemoteService { continue } const compatibility = evaluatePluginCompatibility(info, exemptions) - if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility)) + if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href) bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), @@ -505,6 +506,8 @@ export class PluginManager extends TypertRemoteService { run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run if (stopped()) throw new InstallCancelledError() + // A compatibility refusal is the package's own answer, so no other registry is asked. + if (run.incompatible !== undefined) throw new ManagementFailure('incompatible-version', run.incompatible) // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. if (run.exitCode === 0 && run.timedOut !== true) break /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ @@ -541,7 +544,7 @@ export class PluginManager extends TypertRemoteService { const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure('not-bundle') const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir)) - if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility)) + if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file) } catch (error) { // pnpm has exited by now, so the files it rewrote go back as they were. @@ -717,7 +720,7 @@ export class PluginManager extends TypertRemoteService { if (metadata === undefined) throw new ManagementFailure('not-bundle') if (enabled) { const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir)) - if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility)) + if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) this.bundleRows(name) } } diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 1a2cd412c2..8a5772c49e 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -14,7 +14,8 @@ import { import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { parseInstallSpec } from './install-spec.ts' import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts' -import type { PackageResult, Registry } from './types.ts' +import { incompatiblePlugin } from './failure.ts' +import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts' export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot' /** Profile and invocation locations supplied by the launcher. */ @@ -265,13 +266,15 @@ export async function runProfilePnpm( else await writeFileAtomic(file.path, file.text, { mode: 0o600 }) } } + /** Packages a compatibility check refused; callers render them for their own surface. */ + const incompatible: IncompatiblePlugin[] = [] const rejected = async (warnings: readonly string[], restoration: string): Promise => { const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n` await log.write(diagnostic) options.onOutput?.(diagnostic, 'stderr') append(Buffer.from(diagnostic)) await log.close() - return { exitCode: 1, output: output.toString('utf8'), truncated, logPath } + return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible } } // An install command names the packages it adds, so their manifests are read and checked before // pnpm runs: an incompatible version is never installed, and the one already in use keeps working. @@ -286,7 +289,10 @@ export async function runProfilePnpm( const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags) if (manifest === undefined) continue const issue = evaluatePluginCompatibility(manifest, exemptions) - if (issue !== undefined && !issue.exempted) preflight.push(pluginCompatibilityWarning(issue, context.profile)) + if (issue !== undefined && !issue.exempted) { + preflight.push(pluginCompatibilityWarning(issue)) + incompatible.push(incompatiblePlugin(issue)) + } } catch (error) { void error; continue } } if (preflight.length > 0) return rejected(preflight, 'nothing was installed') @@ -410,17 +416,24 @@ export async function runProfilePnpm( // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it. const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed const found: string[] = [] + const issues: IncompatiblePlugin[] = [] try { const manifest = readProfileManifest('dsh', packageDir) for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) { const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir)) - if (issue !== undefined && !issue.exempted) found.push(pluginCompatibilityWarning(issue, context.profile)) + if (issue !== undefined && !issue.exempted) { + found.push(pluginCompatibilityWarning(issue)) + issues.push(incompatiblePlugin(issue)) + } } } catch (error) { found.push(`Cannot validate installed package ${name}: ${String(error)}`) } if (found.length === 0) continue - if (!untouched) warnings.push(...found) + if (!untouched) { + warnings.push(...found) + incompatible.push(...issues) + } else { const notice = `\ndsh: warning: ${found.join('\n')}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n` await log.write(notice) @@ -456,7 +469,10 @@ export async function runProfilePnpm( clearTimeout(idleTimer) await log.close() } - return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {} } + return { + exitCode, output: output.toString('utf8'), truncated, logPath, + ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {}, + } } /** Initialize and run the dsh plugin command with the same write lock as the service. diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index e544d53d7d..b87f231e60 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -8,10 +8,21 @@ import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types /** Reasons a profile control cannot modify its target. */ export type ReadOnlyReason = 'management-required' | 'unaddressable' +/** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */ +export interface IncompatiblePlugin { + name: string + version: string + runtimeVersion: string + /** Only the DSH peer ranges the running version does not satisfy. */ + peers: Record +} + /** Localizable management failure and optional external diagnostic. */ export interface ManagementError { - code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'operation-error' + code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' diagnostic?: string + /** Present with `incompatible-version`: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[] } /** One running-profile entry and its persistent control availability. */ @@ -92,6 +103,8 @@ export interface PackageResult { kind?: PluginInstallFailureKind /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ timedOut?: boolean + /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[] } /** Persisted change and independently observed application outcome. */ diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 24e317844c..caaf31f25b 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1334,7 +1334,10 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p const managed = () => [...ctx.loader.entries()].find(entry => entry.id === 'include:managed') // The bundle's own peers are incompatible, so its whole layer is skipped and contributes no row. expect(managed()).toBeUndefined() - expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error?.diagnostic).toContain('crashes or data loss') + expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error).toEqual({ + code: 'incompatible-version', + incompatible: [{ name: 'extra', version: '1.0.0', runtimeVersion: runtime, peers: { '@deepseek-ai/dsh': '999.0.0' } }], + }) expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' }) expect(await manager.setVersionExemption('extra@1.0.0', runtime, true)).toMatchObject({ changed: false, application: 'failed' }) expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] }) @@ -1356,6 +1359,18 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p if (mode === 'live') expect(managed()).toBeUndefined() }) +it('reports a package run refused for compatibility as a typed refusal', async () => { + const { manager } = await fixture() + const incompatible = [{ name: 'dsh-x', version: '2.0.0', runtimeVersion: getDshRuntimeVersion(), peers: { '@deepseek-ai/dsh': '999.0.0' } }] + const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ + exitCode: 1, output: 'dsh: installation rejected', truncated: false, logPath: 'pnpm.log', kind: 'unknown', incompatible, + }) + onTestFinished(() => { install.mockRestore() }) + const result = await manager.installBundle('dsh-x') + expect(result).toMatchObject({ application: 'failed', changed: false, error: { code: 'incompatible-version', incompatible } }) + expect(install).toHaveBeenCalledTimes(1) +}) + it.each([false, true])('rechecks installed bundle peers before accepting a disabled installation (exempted=%s)', async (exempted) => { const { manager, dir, bundle } = await fixture() if (exempted) await manager.setVersionExemption('incompatible@1.0.0', getDshRuntimeVersion(), true, true) @@ -1374,7 +1389,7 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab const result = await manager.installBundle('incompatible', { enabled: false }) expect(result).toMatchObject({ application: exempted ? 'applied' : 'failed', changed: exempted }) if (!exempted) { - expect(result.error?.diagnostic).toContain('incompatible@1.0.0') + expect(result.error).toMatchObject({ code: 'incompatible-version', incompatible: [{ name: 'incompatible', version: '1.0.0' }] }) expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) } expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index 3ac4989980..763da12ea5 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -187,6 +187,9 @@ it.each([ execution: 'service', outputBytes: 8192, onOutput: (text) => { messages.push(text) }, }) expect(outcome.exitCode).toBe(1) + expect(outcome.incompatible).toEqual([ + { name: 'plugin', version: '1.0.0', runtimeVersion: getDshRuntimeVersion(), peers: { '@deepseek-ai/dsh': '999.0.0' } }, + ]) expect(outcome.output).toContain('installation rejected') expect(outcome.output).toContain('plugin@1.0.0') expect(outcome.output).toContain('nothing was installed') @@ -335,6 +338,7 @@ it.each([true, false])('rejects incompatible installed manifests before activati execution: 'service', outputBytes: 8192, activateNewBundles, onOutput: (text) => { messages.push(text) }, }) expect(outcome.exitCode).toBe(1) + expect(outcome.incompatible).toMatchObject([{ name: 'incompatible', version: '1.0.0', peers: { '@deepseek-ai/dsh-app-boot': '>=999.0.0' } }]) expect(outcome.output).toContain('incompatible@1.0.0') expect(outcome.output).toContain('installation rejected') expect(readFileSync(outcome.logPath, 'utf8')).toContain('incompatible@1.0.0') diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 3140b0c296..0b80283a95 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -700,6 +700,10 @@ function registryOption(registry: Registry, t: Translate, resolved: string | nul */ function failureText(failure: InstallState['failure'], t: Translate, install?: Pick): string { if (failure === null) return t('installFailureGeneric') + // A compatibility refusal is the package's own answer, whatever pnpm's exit classified the run as. + if (failure.code === 'incompatible-version') { + return managementText({ code: failure.code, ...failure.incompatible === undefined ? {} : { incompatible: failure.incompatible } }, t) + } // Blocked scripts the Host could not name leave the person to allow them in the profile's pnpm settings by hand. if (failure.kind === 'build-blocked' && !failure.pendingBuilds?.length) return t('installFailureBuildBlockedManual') const host = install?.subject?.host diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index bd27fb5041..7fdf9313f4 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -183,6 +183,8 @@ export const zh = { reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载', reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们', reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新', + reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {peers}),运行它可能导致崩溃或数据丢失。请安装与当前 DSH 兼容的插件版本;如果确认承担风险,可以让 Agent 为这一精确版本授予豁免。', + reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失', reasonOperationError: 'Host 报告了一个错误', } satisfies Record @@ -372,5 +374,7 @@ export const en = { reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.', reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.', reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.', + reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {peers}); running it may cause crashes or data loss. Install a plugin version compatible with this DSH, or, to accept the risk, ask the agent to grant an exemption for this exact version.', + reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.', reasonOperationError: 'The Host reported an error.', } satisfies Record diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 4fe666b5b6..07491140f1 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -11,6 +11,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { BundleInfo, ChangeResult, + IncompatiblePlugin, ManagementError, PluginEntryId, PluginInfo, @@ -49,6 +50,8 @@ export type ManagerNotice = readonly code?: ManagementError['code'] /** The Host's diagnostic or the transport's words, shown verbatim; empty when the code says it all. */ readonly reason: string + /** The packages an `incompatible-version` refusal names. */ + readonly incompatible?: readonly IncompatiblePlugin[] readonly packageName?: string readonly seq: number } @@ -185,6 +188,8 @@ export interface InstallState { readonly failure: { readonly reason: string readonly code?: ManagementError['code'] + /** The packages an `incompatible-version` refusal names. */ + readonly incompatible?: readonly IncompatiblePlugin[] readonly kind?: PluginInstallFailureKind /** What the last failed run could not reach, as the Host attributed it: the registry, or the spec's own host. */ readonly failedAt?: 'registry' | 'spec-host' @@ -301,7 +306,9 @@ type Answer = /** A refused answer or a change the Host could not apply, carrying what it said and, for a refusal, its code. */ class RemoteAnswerError extends Error { - constructor(readonly reason: string, readonly code?: ManagementError['code']) { + constructor( + readonly reason: string, readonly code?: ManagementError['code'], readonly incompatible?: readonly IncompatiblePlugin[], + ) { super(reason) this.name = 'RemoteAnswerError' } @@ -315,6 +322,7 @@ function failureOf( return { reason: error?.diagnostic ?? '', ...error === undefined ? {} : { code: error.code }, + ...error?.incompatible === undefined ? {} : { incompatible: error.incompatible }, ...kind === undefined ? {} : { kind }, ...failedAt === undefined ? {} : { failedAt }, ...pendingBuilds === undefined || pendingBuilds.length === 0 ? {} : { pendingBuilds }, @@ -324,7 +332,11 @@ function failureOf( /** The notice a thrown failure becomes: a refusal keeps its code, anything else its words. */ function failedNotice(error: unknown, subject: { action: FailedAction; packageName?: string }, seq: number): ManagerNotice { const code = error instanceof RemoteAnswerError ? error.code : undefined - return { kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code }, ...subject, seq } + const incompatible = error instanceof RemoteAnswerError ? error.incompatible : undefined + return { + kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code }, + ...incompatible === undefined ? {} : { incompatible }, ...subject, seq, + } } /** The runs with every one still open settled at `exitCode`. */ @@ -992,7 +1004,7 @@ export class PluginManagerController { const result = answer.value switch (result.application) { case 'failed': - throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code) + throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code, result.error?.incompatible) case 'cancelled': this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } }) return diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index b5f004b0ac..12973f0bba 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,6 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ -import type { ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client' +import type { IncompatiblePlugin, ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts' @@ -53,6 +53,7 @@ const CODE_KEYS = { 'stop-profile': 'reasonStopProfile', 'bundle-in-use': 'reasonBundleInUse', 'stale-approval': 'reasonStaleApproval', + 'incompatible-version': 'reasonIncompatibleVersionUnnamed', 'operation-error': 'reasonOperationError', } satisfies Record @@ -66,13 +67,23 @@ const FAILED_KEYS = { } satisfies Record /** - * What a management error reads as: the code's sentence, or, for an - * operation error, the Host's diagnostic as it is. - * @param error - the Host's code and its diagnostic, when it has one. + * What a management error reads as: the code's sentence, one sentence per + * package an incompatibility names, or, for an operation error, the Host's diagnostic as it is. + * @param error - the Host's code, its diagnostic, and the packages an incompatibility names. * @param t - the manager's translate seat. * @returns the sentence. */ -export function managementText(error: { readonly code: ManagementError['code']; readonly diagnostic?: string }, t: Translate): string { +export function managementText(error: { + readonly code: ManagementError['code'] + readonly diagnostic?: string + readonly incompatible?: readonly IncompatiblePlugin[] +}, t: Translate): string { + if (error.code === 'incompatible-version' && error.incompatible !== undefined && error.incompatible.length > 0) { + return error.incompatible.map(plugin => t('reasonIncompatibleVersion', { + plugin: `${plugin.name}@${plugin.version}`, runtime: plugin.runtimeVersion, + peers: Object.entries(plugin.peers).map(([name, range]) => `${name} ${range}`).join(', '), + })).join(' ') + } if (error.code !== 'operation-error') return t(CODE_KEYS[error.code]) return error.diagnostic === undefined || error.diagnostic === '' ? t('reasonOperationError') : error.diagnostic } @@ -134,7 +145,9 @@ export function noticeText(notice: ManagerNotice, t: Translate): string { unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown', } as const)[notice.outcome]) case 'failed': { - const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t) + const reason = notice.code === undefined ? notice.reason : managementText({ + code: notice.code, diagnostic: notice.reason, ...notice.incompatible === undefined ? {} : { incompatible: notice.incompatible }, + }, t) return t(FAILED_KEYS[notice.action], { reason: reason === '' ? t('reasonOperationError') : reason }) } } diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 906578bc85..75a467ca4b 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -42,6 +42,10 @@ function row(overrides: Partial = {}): PackageRow { return { entryId: 'include:sidebar' as PluginEntryId, rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active', ...overrides } } +const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0', '@deepseek-ai/dsh-core': '^0.2.0' } } +/** The English sentence an incompatibility of {@link INCOMPATIBLE}, optionally renamed, reads as. */ +const incompatibleText = (name = INCOMPATIBLE.name): string => en.reasonIncompatibleVersion + .replace('{plugin}', `${name}@2.0.0`).replace('{runtime}', '0.1.0').replace('{peers}', '@deepseek-ai/dsh ^0.2.0, @deepseek-ai/dsh-core ^0.2.0') const MIRROR = 'https://registry.npmmirror.com/' const OFFICIAL = 'https://registry.npmjs.org/' @@ -755,6 +759,11 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy() set({ packages: [pkg({ error: { code: 'operation-error' } })] }) expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy() + // An incompatibility reads from its structured packages in the dictionary's words, one sentence per package. + set({ packages: [pkg({ error: { code: 'incompatible-version', incompatible: [INCOMPATIBLE, { ...INCOMPATIBLE, name: 'other' }] } })] }) + expect(within(detail).getByText(`${en.reasonLabel}: ${incompatibleText()} ${incompatibleText('other')}`)).toBeTruthy() + set({ packages: [pkg({ error: { code: 'incompatible-version' } })] }) + expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed}`)).toBeTruthy() fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) expect(document.querySelector('[data-plugin-detail]')).toBeNull() // A bundle that leaves the list drops back to the cards. @@ -1046,6 +1055,12 @@ describe('PluginManagerPage', () => { expect(screen.getByText(en.reasonNotBundle)).toBeTruthy() set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'ERR_PNPM_ADDING_TO_ROOT', code: 'operation-error' } } }) expect(screen.getByText('ERR_PNPM_ADDING_TO_ROOT')).toBeTruthy() + // A compatibility refusal outranks the kind pnpm's exit was classified as. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', + failure: { reason: '', code: 'incompatible-version', incompatible: [INCOMPATIBLE], kind: 'unknown' } } }) + expect(screen.getByText(incompatibleText())).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '', code: 'incompatible-version' } } }) + expect(screen.getByText(en.reasonIncompatibleVersionUnnamed)).toBeTruthy() set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'the transport said so' } } }) expect(screen.getByText('the transport said so')).toBeTruthy() set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '' } } }) @@ -1289,6 +1304,8 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', 'the tree rejected it')) set({ notice: { kind: 'failed', action: 'uninstall', code: 'bundle-in-use', reason: '', packageName: 'pkg-1', seq: 5 } }) expect(screen.getByRole('alert').textContent).toContain(en.failedUninstall.replace('{reason}', en.reasonBundleInUse)) + set({ notice: { kind: 'failed', action: 'enable', code: 'incompatible-version', incompatible: [INCOMPATIBLE], reason: '', packageName: 'pkg-1', seq: 5 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', incompatibleText())) set({ notice: { kind: 'failed', action: 'rowDisable', code: 'operation-error', reason: 'EACCES', packageName: 'pkg-1', seq: 6 } }) expect(screen.getByRole('alert').textContent).toContain(en.failedRowDisable.replace('{reason}', 'EACCES')) set({ notice: { kind: 'failed', action: 'disable', reason: '', packageName: 'pkg-1', seq: 7 } }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 829d8b4fd4..6c95ea1133 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -10,6 +10,7 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' +const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0' } } const ROW_ENTRY = 'include:sidebar' as PluginEntryId const BUNDLE: BundleInfo = { @@ -278,7 +279,8 @@ describe('PluginManagerController', () => { .mockRejectedValueOnce('odd') .mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' }))) .mockResolvedValueOnce(ok(failed())) - .mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })), + .mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })) + .mockResolvedValueOnce(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] }))), }) await controller.load() face.setEnabled(BUNDLE.name, true) @@ -297,6 +299,13 @@ describe('PluginManagerController', () => { // A change the Host stopped is said in passing. face.setEnabled(BUNDLE.name, true) await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) }) + // An incompatibility keeps the packages it names for the page to word. + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { + expect(state().notice).toEqual({ + kind: 'failed', action: 'enable', code: 'incompatible-version', incompatible: [INCOMPATIBLE], reason: '', packageName: BUNDLE.name, seq: 8, + }) + }) face.dismissNotice() expect(state().notice).toBeNull() }) @@ -973,12 +982,17 @@ describe('PluginManagerController', () => { { jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null }, ]) expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle' }) + // An incompatibility keeps the packages it names. + await installing() + answer(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] }))) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.failure).toEqual({ reason: '', code: 'incompatible-version', incompatible: [INCOMPATIBLE] }) // A failure the Host does not explain has neither code nor words. await installing() answer(ok(failed())) await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) expect(state().install.failure).toEqual({ reason: '' }) - expect(plugins.installBundle).toHaveBeenCalledTimes(5) + expect(plugins.installBundle).toHaveBeenCalledTimes(6) // Editing the spec after a failure starts over too. face.editInstallSpec('y') expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index fdc2bb3304..6f8e8962d2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5109,6 +5109,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageVariantId', declaration: 'export type ImageVariantId = Branded<\'ImageVariantId\'>;', }, + { + name: 'IncompatiblePlugin', + declaration: 'export interface IncompatiblePlugin {\n name: string;\n version: string;\n runtimeVersion: string;\n peers: Record;\n}', + }, { name: 'IndexInjection', declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'script-preload\';\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};', @@ -5423,7 +5427,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ManagementError', - declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'operation-error\';\n diagnostic?: string;\n}', + declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'incompatible-version\' | \'operation-error\';\n diagnostic?: string;\n incompatible?: IncompatiblePlugin[];\n}', }, { name: 'ManualCompactAgentContext', @@ -5611,7 +5615,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PackageResult', - declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n}', + declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n incompatible?: IncompatiblePlugin[];\n}', }, { name: 'PeerAdmission', From f64c57dbb2d254f744eb406e78bdf87c604267fb Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 23 Sep 2026 20:04:31 +0800 Subject: [PATCH 12/14] fix(plugin-manager): drop the agent exemption hint from the incompatibility reason --- packages/client/ui-plugin-manager/src/client/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 7fdf9313f4..139c41321d 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -183,7 +183,7 @@ export const zh = { reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载', reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们', reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新', - reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {peers}),运行它可能导致崩溃或数据丢失。请安装与当前 DSH 兼容的插件版本;如果确认承担风险,可以让 Agent 为这一精确版本授予豁免。', + reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {peers}),运行它可能导致崩溃或数据丢失。请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失', reasonOperationError: 'Host 报告了一个错误', } satisfies Record @@ -374,7 +374,7 @@ export const en = { reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.', reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.', reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.', - reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {peers}); running it may cause crashes or data loss. Install a plugin version compatible with this DSH, or, to accept the risk, ask the agent to grant an exemption for this exact version.', + reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {peers}); running it may cause crashes or data loss. Install a plugin version compatible with this DSH.', reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.', reasonOperationError: 'The Host reported an error.', } satisfies Record From b09dd9482799642f687a6bd30b375cd1e832fa14 Mon Sep 17 00:00:00 2001 From: ihsiang Date: Wed, 23 Sep 2026 20:10:50 +0800 Subject: [PATCH 13/14] feat(web): restore the process-row polish on the grouped work details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): restore the process-row polish on the grouped work details The sweep highlight, shared row colors, expanded-row spacing, and the 12–24px font-size range were developed against the pre-revert work-details implementation. Re-apply them to the grouped implementation that replaced it. - TextShimmer keeps its active-driven span and now paints a masked sweep overlay in the theme's `--dsw-alias-label-shimmer` color, so the base text inherits the caller's color and hover changes. - DisclosureRow owns the row color: tertiary at rest, secondary on hover, with glyphs, titles, and summaries inheriting it; tool, skill, command, reasoning, and workflow rows drop their own hover darkening. - ChatView spaces process rows 8px apart and keeps 16px beside Assistant responses; the grouped header sits 8px above its first member. - The Settings font-size stepper accepts 12–24px, and the grouped header's leading box and glyph scale with the content font-size delta. * fix(web): restore blue running status and refine process disclosures * fix(web): finalize running snapshots and whale animation * fix(web): preserve disclosure layout and repair CI fixtures --- THIRD_PARTY_NOTICES.md | 1 + apps/web/tests/assembled-boot.ts | 15 +++ apps/web/tests/built-boot.expected.e2e.ts | 6 +- .../clickable-links-gallery/ui.expected.md | 4 +- .../expected/cordis-history/ui.expected.md | 12 +- .../expected/fork-mid-turn/ui.expected.md | 8 +- .../conversation-expanded.expected.md | 4 +- .../conversation.expected.md | 4 +- .../markdown-cjk-strong/ui.expected.md | 4 +- .../expected/markdown-images/ui.expected.md | 4 +- .../markdown-inline-code-links/ui.expected.md | 4 +- .../expected/math-rendering/ui.expected.md | 4 +- .../reference-composer/order.expected.md | 4 +- .../skill-user-invoke/ui-expanded.expected.md | 4 +- .../expected/skill-user-invoke/ui.expected.md | 4 +- .../stats-paged-history/ui.expected.md | 112 +++++++++--------- .../expected/steer-all/mid-steer.expected.md | 4 +- .../steer-all/settled-expanded.expected.md | 4 +- .../expected/steer-all/settled.expected.md | 4 +- apps/web/tests/goal-multi-turn-actions.e2e.ts | 29 +++++ apps/web/tests/idle-submission-handoff.e2e.ts | 12 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/live-interactions.e2e.ts | 2 +- apps/web/tests/reasoning-preview.e2e.ts | 47 +++++++- apps/web/tests/seeded-history.e2e.ts | 1 - apps/web/tests/shimmer.ts | 66 +++++++++++ .../mid-stream.expected.md | 4 +- apps/web/tests/support.ts | 8 +- apps/web/tests/turn-tail-actions.e2e.ts | 92 +++++++++++++- apps/web/tests/workflow-run.e2e.ts | 8 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- packages/client/ui-chat/package.json | 7 +- .../src/client/chat/ChatGroupSeat.module.css | 25 ++-- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 8 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 + .../src/client/chat/ChatView.module.css | 71 ++++++++++- .../ui-chat/src/client/chat/ChatView.tsx | 2 + .../client/chat/GenericCommandCard.module.css | 12 -- .../src/client/chat/GenericCommandCard.tsx | 10 +- .../src/client/chat/MessageItem.module.css | 64 +++------- .../ui-chat/src/client/chat/MessageItem.tsx | 7 +- .../src/client/chat/ReasoningRow.module.css | 33 ------ .../ui-chat/src/client/chat/ReasoningRow.tsx | 10 +- .../ui-chat/src/client/chat/RunningStatus.tsx | 82 +++++++++++++ .../chat/TurnProcessNodeView.module.css | 14 +-- .../src/client/chat/TurnProcessNodeView.tsx | 40 +++---- .../chat/TurnTriggerNodeView.module.css | 18 +-- .../src/client/chat/lottie-player.d.ts | 6 + .../src/client/chat/whale-tail.lottie.json | 1 + .../src/client/contract/turn-process.ts | 4 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 8 +- .../client/conversation-nodes/README.zh.md | 8 +- packages/client/ui-chat/src/client/locale.ts | 14 +-- .../ui-chat/tests/chat-view.client.spec.tsx | 87 ++++++++------ .../tests/reasoning-row.client.spec.tsx | 6 +- .../tests/turn-tail-spacing.client.spec.ts | 4 +- packages/client/ui-chat/tsconfig.client.json | 4 +- .../src/client/PresentRow.module.css | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 5 +- packages/client/ui-primitives/README.zh.md | 5 +- .../src/DisclosureRow.module.css | 26 +++- .../ui-primitives/src/DisclosureRow.tsx | 12 +- .../ui-primitives/src/Shimmer.module.css | 88 ++++++++++++++ packages/client/ui-primitives/src/Shimmer.tsx | 66 +++++++++++ .../ui-primitives/src/TextShimmer.module.css | 27 ----- .../client/ui-primitives/src/TextShimmer.tsx | 23 ---- .../client/ui-primitives/src/icons/index.tsx | 6 +- packages/client/ui-primitives/src/index.ts | 4 +- .../tests/shimmer.client.spec.tsx | 41 +++++++ .../tests/text-shimmer-styles.client.spec.ts | 16 --- .../tests/text-shimmer.client.spec.tsx | 29 ----- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../ui-skill/src/client/SkillRow.module.css | 39 ++---- .../client/ui-skill/src/client/SkillRow.tsx | 31 +++-- .../ui-skill/tests/skill-row.client.spec.tsx | 6 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 8 +- packages/client/ui-theme/README.zh.md | 8 +- .../ui-theme/src/styles/design-platform.css | 10 ++ .../src/styles/gradient-shadow-text.css | 4 +- .../client/ui-theme/src/theme-settings.ts | 2 +- .../ui-theme/tests/boot-theme.client.spec.ts | 4 +- .../client/ui-theme/tests/config.host.spec.ts | 10 +- .../tests/font-size-row.client.spec.tsx | 6 +- .../ui-theme/tests/theme.client.spec.ts | 18 +-- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 4 +- packages/client/ui-tool/README.zh.md | 4 +- .../client/tool/components/ToolRow.module.css | 49 ++------ .../src/client/tool/components/ToolRow.tsx | 30 ++--- .../tool/toolviews/bash-sample.module.css | 25 ++-- .../src/client/tool/toolviews/bash-sample.tsx | 24 ++-- .../ui-tool/tests/diff-card.client.spec.tsx | 5 +- .../tests/tool-row-styles.client.spec.ts | 10 +- .../ui-tool/tests/tool-row.client.spec.tsx | 7 +- .../src/client/WorkflowRunPanel.module.css | 16 +-- pnpm-lock.yaml | 18 ++- scripts/rescope-vendor.ts | 1 + scripts/test-dom-environment.ts | 37 +++++- .../web/auto-review-denial/ui.expected.md | 8 +- snapshots/web/bash-abort-row/ui.expected.md | 2 +- .../web/changed-files-turn/ui.expected.md | 4 +- .../web/deepseek-messages-chat/ui.expected.md | 4 +- .../web/default-workspace/ui.expected.md | 4 +- .../feedback-command/ack-expanded.expected.md | 4 +- .../web/feedback-command/ack.expected.md | 4 +- .../feedback-release/ack-expanded.expected.md | 4 +- .../web/feedback-release/ack.expected.md | 4 +- .../web/file-upload-round/ui.expected.md | 4 +- .../fresh-round-trip/ui-expanded.expected.md | 4 +- snapshots/web/fresh-round-trip/ui.expected.md | 4 +- .../ui-expanded.expected.md | 8 +- .../goal-multi-turn-actions/ui.expected.md | 9 +- .../reloaded-expanded.expected.md | 4 +- .../web/lifecycle-chrome/reloaded.expected.md | 4 +- .../web/live-interactions/loading.expected.md | 4 +- .../retry-expanded.expected.md | 4 +- .../web/live-interactions/retry.expected.md | 4 +- .../running-draft.expected.md | 4 +- snapshots/web/message-actions/ui.expected.md | 13 +- snapshots/web/minimal-preset/ui.expected.md | 4 +- .../plan-review/approved-expanded.expected.md | 4 +- .../web/plan-review/approved.expected.md | 4 +- snapshots/web/present-svg/ui.expected.md | 4 +- snapshots/web/present/ui.expected.md | 4 +- snapshots/web/ptc-round/ui.expected.md | 4 +- .../answered-expanded.expected.md | 4 +- .../question-composer/answered.expected.md | 4 +- .../question-composer/cancelled.expected.md | 4 +- .../web/queue-actions/collapsed.expected.md | 4 +- .../web/queue-actions/editing.expected.md | 4 +- .../web/queue-actions/layout.expected.md | 4 +- .../web/queue-actions/sending.expected.md | 4 +- snapshots/web/queue-actions/ui.expected.md | 4 +- .../web/queued-image/delivered.expected.md | 8 +- snapshots/web/queued-image/queued.expected.md | 4 +- .../seeded-history/command-row.expected.md | 8 +- .../seeded-history/feedback-row.expected.md | 8 +- .../seeded-history/ui-expanded.expected.md | 8 +- snapshots/web/seeded-history/ui.expected.md | 8 +- snapshots/web/skill-tool-row/ui.expected.md | 4 +- snapshots/web/steering/mid-steer.expected.md | 4 +- .../web/steering/settled-expanded.expected.md | 4 +- snapshots/web/steering/settled.expected.md | 4 +- .../subagent-conversation/nested.expected.md | 4 +- .../sidebar-chat.expected.md | 4 +- .../ui-expanded.expected.md | 8 +- .../web/subagent-conversation/ui.expected.md | 8 +- .../offline-composer.expected.md | 4 +- .../turn-tail-actions/completed.expected.md | 4 +- .../web/turn-tail-actions/focused.expected.md | 4 +- .../web/turn-tail-actions/running.expected.md | 4 +- .../web/turn-tail-actions/settled.expected.md | 5 +- .../usage-expanded.expected.md | 4 +- snapshots/web/web-search-round/ui.expected.md | 4 +- snapshots/web/workflow-run/ui.expected.md | 4 +- tsconfig.host.json | 1 + 163 files changed, 1280 insertions(+), 783 deletions(-) create mode 100644 apps/web/tests/shimmer.ts create mode 100644 packages/client/ui-chat/src/client/chat/RunningStatus.tsx create mode 100644 packages/client/ui-chat/src/client/chat/lottie-player.d.ts create mode 100644 packages/client/ui-chat/src/client/chat/whale-tail.lottie.json create mode 100644 packages/client/ui-primitives/src/Shimmer.module.css create mode 100644 packages/client/ui-primitives/src/Shimmer.tsx delete mode 100644 packages/client/ui-primitives/src/TextShimmer.module.css delete mode 100644 packages/client/ui-primitives/src/TextShimmer.tsx create mode 100644 packages/client/ui-primitives/tests/shimmer.client.spec.tsx delete mode 100644 packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts delete mode 100644 packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 862299b9a2..3edabbb639 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -96,6 +96,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`lexical`](https://github.com/facebook/lexical) | MIT | +| [`lottie-web`](https://github.com/airbnb/lottie-web) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index b7ab90a8e2..34f67f4a2c 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -218,6 +218,7 @@ export function installAssembledBootEnv(): void { }) } let fontsDescriptor: PropertyDescriptor | undefined + let canvasContextDescriptor: PropertyDescriptor | undefined beforeEach(() => { fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() }) @@ -233,6 +234,18 @@ export function installAssembledBootEnv(): void { vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => { callback(0) }, 0) as unknown as number) vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) + vi.stubGlobal('matchMedia', (media: string) => Object.assign(new EventTarget(), { + media, matches: false, onchange: null, addListener: () => {}, removeListener: () => {}, + })) + // Lottie's SVG player creates one transparent Canvas placeholder while importing. + canvasContextDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext') + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: (contextId: string) => { + if (contextId !== '2d') throw new Error(`assembled boot: unsupported Canvas context ${contextId}`) + return { fillStyle: '', fillRect: () => {} } + }, + }) }) afterEach(async () => { @@ -263,6 +276,8 @@ export function installAssembledBootEnv(): void { delete ownNavigator.languages delete ownNavigator.language vi.unstubAllGlobals() + if (canvasContextDescriptor === undefined) Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext') + else Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', canvasContextDescriptor) if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') else Object.defineProperty(document, 'fonts', fontsDescriptor) if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 7ab01184fc..f645214133 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -129,7 +129,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write' && row.textContent?.includes('new-demo.txt')) if (writeRow === undefined) throw new Error('fixture write row missing') - expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1) expect(writeRow.querySelector('[data-diff]')).toBeNull() for (const row of mutationRows) { const toggle = row.querySelector('[data-expandable]') @@ -140,7 +141,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const writeDiff = writeRow.querySelector('[data-diff]') if (writeDiff === null) throw new Error('expanded fixture write diff missing') within(writeDiff).getByText('hello fixture', { exact: true }) - expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1) expect(writeDiff.textContent).not.toContain('+1 -0') // The web render intent reaches the assembled boot graph: the fixture's diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 25c6502dad..a3494849d0 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Wrote files, called tools, searched code, etc." [expanded] - button "Write site/report.html +1 -0": - text: Write diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 45381454fa..3de6c560dd 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -11,8 +11,8 @@ - button "Jump to turn 3" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Inspect plugins 0 dynamic plugins" @@ -42,8 +42,8 @@ - button "Plugin status updated 9/1 {{clock}}": - text: Plugin status updated - time: 9/1 {{clock}} -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: The Cordis Plugin is running. - button "Copy" - button "Good response" @@ -51,8 +51,8 @@ - button "Branch into a new conversation" - text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools" [expanded] - text: Stop Cordis Plugin snap-1 - button "Inspect" diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 98f72fecb8..4cecf35241 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -1,11 +1,11 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 5f580db1a6..4abdc53e16 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -19,8 +19,8 @@ - button "GitHub event received {{clock}}": - text: GitHub event received - time: {{clock}} -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 5f580db1a6..4abdc53e16 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -19,8 +19,8 @@ - button "GitHub event received {{clock}}": - text: GitHub event received - time: {{clock}} -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index e113cb6b3d..2eda5d3afb 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 31dee5c21a..d6bea9a4f8 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - heading "Markdown images" [level=2] - paragraph: - 'button "View full image: Remote test image"': diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index bf25c8d4ae..c9d932b478 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -6,8 +6,8 @@ - tab "Trajectory" - text: Show the local preview URL. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 3fb277318a..3d4b08ccd1 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Render this mathematical proof. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 589d2954d1..779c3e1f20 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 930e9eb385..09a6526be6 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -10,8 +10,8 @@ - button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 930e9eb385..09a6526be6 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -10,8 +10,8 @@ - button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 5af4333df1..e6a96742a4 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -36,8 +36,8 @@ - button "Jump to turn 28" - text: m1 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r1 - button "Copy" - button "Good response" @@ -45,8 +45,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m2 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r2 - button "Copy" - button "Good response" @@ -54,8 +54,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m3 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r3 - button "Copy" - button "Good response" @@ -63,8 +63,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m4 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r4 - button "Copy" - button "Good response" @@ -72,8 +72,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m5 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r5 - button "Copy" - button "Good response" @@ -81,8 +81,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m6 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r6 - button "Copy" - button "Good response" @@ -90,8 +90,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m7 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r7 - button "Copy" - button "Good response" @@ -99,8 +99,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m8 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r8 - button "Copy" - button "Good response" @@ -108,8 +108,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m9 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r9 - button "Copy" - button "Good response" @@ -117,8 +117,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m10 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r10 - button "Copy" - button "Good response" @@ -126,8 +126,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m11 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r11 - button "Copy" - button "Good response" @@ -135,8 +135,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m12 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r12 - button "Copy" - button "Good response" @@ -144,8 +144,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m13 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r13 - button "Copy" - button "Good response" @@ -153,8 +153,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m14 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r14 - button "Copy" - button "Good response" @@ -162,8 +162,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m15 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r15 - button "Copy" - button "Good response" @@ -171,8 +171,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m16 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r16 - button "Copy" - button "Good response" @@ -180,8 +180,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m17 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r17 - button "Copy" - button "Good response" @@ -189,8 +189,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m18 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r18 - button "Copy" - button "Good response" @@ -198,8 +198,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m19 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r19 - button "Copy" - button "Good response" @@ -207,8 +207,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m20 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r20 - button "Copy" - button "Good response" @@ -216,8 +216,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m21 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r21 - button "Copy" - button "Good response" @@ -225,8 +225,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m22 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r22 - button "Copy" - button "Good response" @@ -234,8 +234,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m23 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r23 - button "Copy" - button "Good response" @@ -243,8 +243,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m24 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r24 - button "Copy" - button "Good response" @@ -252,8 +252,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m25 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r25 - button "Copy" - button "Good response" @@ -261,8 +261,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m26 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r26 - button "Copy" - button "Good response" @@ -270,8 +270,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m27 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r27 - button "Copy" - button "Good response" @@ -279,8 +279,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: r28 - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 6a14e1ea0f..55222e9624 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -8,13 +8,13 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Analyzing the request" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - text: "Interjection: include the word ORANGE in your final reply." - button "Copy" +- status: Deep diving +- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 3e00422aa2..5282565384 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - button "Asked questions" [expanded] - button "Think" - button "Ask question 1/1 answered" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index f419491430..c92baa21e1 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 1361a476d3..be9e5fc341 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -155,6 +155,32 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) + const trigger = page.locator('[data-turn-trigger]').first() + const process = page.locator('[data-turn-process]').first() + const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')) + await process.click() + try { + for (const fontSize of [14, 17, 24]) { + await scaffold!.ctx.settings.update('ui-theme', { fontSize }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(`${String(fontSize)}px`) + const group = page.locator('[data-process-activity]:visible').first() + const groupFontSize = await group.evaluate(element => getComputedStyle(element).fontSize) + expect(groupFontSize).toBe(`${String(fontSize)}px`) + expect(await process.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize) + expect(await trigger.getByText('Continuing goal', { exact: true }) + .evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize) + expect(await process.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16) + expect(await group.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + } + } finally { + await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(originalFontSize) + await process.click() + } expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( '[data-chat-flow-kind="system-prompt"]', @@ -163,7 +189,10 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled')))) .toEqual([null, null]) + await page.mouse.move(0, 0) await branchButtons.last().focus() + await page.locator('[data-composer-input]').first().focus() + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/idle-submission-handoff.e2e.ts b/apps/web/tests/idle-submission-handoff.e2e.ts index 9d27909d4d..2fb9930fcb 100644 --- a/apps/web/tests/idle-submission-handoff.e2e.ts +++ b/apps/web/tests/idle-submission-handoff.e2e.ts @@ -144,8 +144,10 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first if (order === 'transcript-first') { await expect.poll(() => gate.turnStartDelivery()).toBeDefined() - gate.releaseThrough(gate.turnStartDelivery()!) - await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() + const start = gate.turnStartDelivery()! + if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start') + gate.releaseThrough(start) + await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' }) await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) trace.push({ phase: 'turn-start', ...await placement(page) }) await expect.poll(() => gate.admissionDelivery()).toBeDefined() @@ -166,8 +168,10 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first if (order === 'inbox-first') { await expect.poll(() => gate.turnStartDelivery()).toBeDefined() - gate.releaseThrough(gate.turnStartDelivery()!) - await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() + const start = gate.turnStartDelivery()! + if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start') + gate.releaseThrough(start) + await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' }) await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) trace.push({ phase: 'turn-start', ...await placement(page) }) } diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index ecedf4ed73..f06f73ffea 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -344,7 +344,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await reasoningComplete.promise expect(await thinking.getAttribute('data-preview')).toBeNull() expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false) + expect(await thinking.locator('[data-streaming]:not([inert] *)').isVisible()).toBe(false) } observedReasoning.resolve(undefined) return await settled diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 6592322148..ef9667049a 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), + () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(), { timeout: 10_000 }, ).toBe(true) await page.locator('[data-streaming="true"]') diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 9571b51d1c..7820173e55 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -8,6 +8,7 @@ import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts' +import { expectSharedShimmer } from './shimmer.ts' const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}` const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] @@ -49,7 +50,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f const browser = await chromium.launch() try { const page = await newEnglishPage(browser) - const console = watchConsole(page) + const pageConsole = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) await page.setViewportSize({ width: 480, height: 1000 }) @@ -68,9 +69,32 @@ it('shows completed paragraph first lines across blank lines with a right-edge f first.proceed.resolve(undefined) await second.arrived.promise - const preview = reasoning.locator('[data-streaming]') + const preview = reasoning.locator('[data-streaming]:not([inert] *)') await expect.poll(() => preview.textContent()).toBe('First paragraph') expect(await preview.isVisible()).toBe(true) + for (const fontSize of [14, 24]) { + await scaffold.ctx.settings.update('ui-theme', { fontSize }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(`${String(fontSize)}px`) + const geometry = await preview.evaluate((element) => { + const text = element.firstElementChild + const header = element.closest('[data-disclosure-row]')! + const range = document.createRange() + range.selectNodeContents(text!) + return { + fadeStart: element.getBoundingClientRect().right - 48, + textRight: range.getBoundingClientRect().right, + previewRight: element.getBoundingClientRect().right, + headerRight: header.getBoundingClientRect().right, + headerHeight: header.getBoundingClientRect().height, + } + }) + expect(geometry.textRight).toBeLessThanOrEqual(geometry.fadeStart) + expect(geometry.previewRight).toBe(geometry.headerRight) + expect(geometry.headerHeight).toBe(24 + fontSize - 14) + } + await scaffold.ctx.settings.update('ui-theme', { fontSize: 14 }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))).toBe('14px') await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') }) second.proceed.resolve(undefined) @@ -79,15 +103,30 @@ it('shows completed paragraph first lines across blank lines with a right-edge f expect(await preview.getAttribute('data-retained-preview')).toBe('true') expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient') expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + await expectSharedShimmer(reasoning) + const group = page.locator('[data-process-activity]').filter({ has: page.locator('[data-shimmer="true"]') }) + await expectSharedShimmer(group) + await group.hover() + await expectSharedShimmer(group) expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false') await compareOrRefreshGolden(UI_EXPECTED, await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode()) + await group.click() + expect(await group.getAttribute('aria-expanded')).toBe('false') + await expectSharedShimmer(group) + await page.emulateMedia({ reducedMotion: 'reduce' }) + try { + expect(await group.locator('[inert]').evaluate(element => getComputedStyle(element).display)).toBe('none') + expect(await group.innerText()).toBe('Analyzing the request') + } finally { + await page.emulateMedia({ reducedMotion: null }) + } third.proceed.resolve(undefined) await settled await page.getByText('Done', { exact: true }).waitFor() - expect(console.pageErrors).toEqual([]) - expect(console.warnings).toEqual([]) + expect(pageConsole.pageErrors).toEqual([]) + expect(pageConsole.warnings).toEqual([]) } finally { adapter.release() await browser.close() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 8419aae826..5c24765398 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -372,7 +372,6 @@ describe('web e2e: seeded history renders through cold resume', () => { const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => element.getBoundingClientRect().top) - // Collapsed control row keeps its own 8px margin plus the 8px flow gap. expect(answerTop).toBe(processBottom + 16) expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are diff --git a/apps/web/tests/shimmer.ts b/apps/web/tests/shimmer.ts new file mode 100644 index 0000000000..660b811125 --- /dev/null +++ b/apps/web/tests/shimmer.ts @@ -0,0 +1,66 @@ +/** Browser geometry for one moving mask over text beside retained row icons. */ +import type { Locator } from 'playwright' +import { expect } from 'vitest' + +/** + * Check glyph alignment and scroll overflow across a running row's shared sweep. + * @param row - visible row containing exactly one active shimmer. + * @returns after restoring the row's animation playback. + */ +export async function expectSharedShimmer(row: Locator): Promise { + const shimmer = row.locator('[data-shimmer="true"]') + expect(await shimmer.count()).toBe(1) + const frames = await shimmer.evaluate((root) => { + const base = root.firstElementChild! + const decoration = root.querySelector(':scope > [inert]')! + const mask = decoration.firstElementChild! + const highlight = mask.firstElementChild! + const animations = root.getAnimations({ subtree: true }) + .filter(animation => animation instanceof CSSAnimation && animation.animationName.includes('dsh-row-shimmer-')) + const original = animations.map(animation => ({ animation, time: animation.currentTime, state: animation.playState })) + try { + return [400, 800, 1200, 1301, 1799, 1801].map((time) => { + for (const animation of animations) { + animation.pause() + animation.currentTime = time + } + const originals = [...base.querySelectorAll('svg, span, button')] + const copies = [...highlight.querySelectorAll('svg, span, button')] + return { + animations: animations.length, + mask: getComputedStyle(mask).maskImage, + hidden: decoration.getAttribute('aria-hidden'), + maskLeft: mask.getBoundingClientRect().left, + width: root.getBoundingClientRect().width, + clientWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + icons: base.querySelectorAll('svg').length, + copyCount: copies.length, + baseCount: originals.length, + glyphOffsets: originals.map((element, index) => { + const a = element.getBoundingClientRect() + const b = copies[index]!.getBoundingClientRect() + return [a.left - b.left, a.top - b.top, a.width - b.width, a.height - b.height] + }).flat(), + } + }) + } finally { + for (const { animation, time, state } of original) { + animation.currentTime = time + if (state === 'running') animation.play() + } + } + }) + for (const frame of frames) { + expect(frame.animations).toBe(2) + expect(frame.mask).toContain('linear-gradient') + expect(frame.hidden).toBe('true') + expect(frame.width).toBeGreaterThan(0) + expect(frame.scrollWidth).toBe(frame.clientWidth) + expect(frame.icons).toBe(0) + expect(frame.copyCount).toBe(frame.baseCount) + expect(frame.glyphOffsets.every(offset => Math.abs(offset) < 0.1)).toBe(true) + } + expect(frames[0]!.maskLeft).toBeLessThan(frames[1]!.maskLeft) + expect(frames[1]!.maskLeft).toBeLessThan(frames[2]!.maskLeft) +} diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index a2443a2e47..743e96c4f8 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -8,12 +8,12 @@ - tab "Trajectory" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - text: ts - button "Wrap lines" [pressed] - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" +- status: Deep diving +- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 884f160418..0918814111 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -85,7 +85,7 @@ export async function expandTurnProcesses(page: Page): Promise { } /** - * Expand the Turn process and secondary group containing a hidden descendant. + * Expand the settled Turn process and secondary group containing a hidden descendant. * @param page - page containing the Chat view. * @param target - descendant whose outer process disclosures should open. */ @@ -94,8 +94,10 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn) if (turn !== undefined) { const control = page.locator(`[data-turn-process="${turn}"]`) - await control.waitFor({ state: 'visible', timeout: 10_000 }) - if (await control.getAttribute('aria-expanded') === 'false') await control.click() + if (await control.count() > 0) { + await control.waitFor({ state: 'visible', timeout: 10_000 }) + if (await control.getAttribute('aria-expanded') === 'false') await control.click() + } } const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]') const header = group.locator('[data-process-activity]').first() diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7a7bfe9293..d4bbccbc65 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { expectSharedShimmer } from './shimmer.ts' import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -144,16 +145,84 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) const runningProcess = page.locator('[data-turn-process]') - expect(await runningProcess.count()).toBe(1) - expect(await runningProcess.isDisabled()).toBe(true) - expect(await runningProcess.getAttribute('aria-expanded')).toBe('true') + expect(await runningProcess.count()).toBe(0) await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), + () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(), { timeout: 10_000 }, ).toBe(true) await page.locator('[data-streaming="true"]') .getByText('partial', { exact: true }) .waitFor({ timeout: 10_000 }) + const runningStatus = page.locator('[data-chat-running]') + expect(await runningStatus.innerText()).toMatch(/Deep diving for \d+s/) + await expectSharedShimmer(runningStatus) + expect(await runningStatus.evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') + const contentBottom = await page.locator('[data-streaming="true"]').evaluate(element => + element.closest('[data-chat-flow-kind="assistant-step"]')!.getBoundingClientRect().bottom) + expect(await runningStatus.evaluate(element => element.getBoundingClientRect().top)).toBe(contentBottom + 32) + const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')) + try { + for (const fontSize of [14, 17, 24]) { + await scaffold!.ctx.settings.update('ui-theme', { fontSize }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(`${String(fontSize)}px`) + const layout = await runningStatus.evaluate((status) => { + const icon = status.querySelector('svg')! + const label = status.querySelector('[data-shimmer] > span > span')! + const group = document.querySelector('[data-process-activity]')! + const groupIcon = group.querySelector('[data-step-process-icon]')! + return { + iconSize: icon.getBoundingClientRect().width, + groupIconSize: groupIcon.getBoundingClientRect().width, + gap: label.getBoundingClientRect().left - icon.getBoundingClientRect().right, + iconColor: getComputedStyle(icon).color, + textColor: getComputedStyle(label).color, + } + }) + expect(layout.iconSize).toBe(layout.groupIconSize - 2) + expect(layout.gap).toBe(6) + expect(layout.iconColor).toBe(layout.textColor) + } + } finally { + await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) }) + await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) + .toBe(originalFontSize) + } + const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const colors = await runningStatus.evaluate((element) => { + const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true })! + const rgb = (color: string) => { + context.fillStyle = color + context.fillRect(0, 0, 1, 1) + return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3).map(channel => channel / 255) + } + const luminance = (channels: number[]) => channels + .map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4) + .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0) + const base = rgb(getComputedStyle(element).color) + const sweep = rgb(getComputedStyle(element.querySelector('[data-shimmer] > [inert] > span')!).color) + const max = Math.max(...base) + const min = Math.min(...base) + return { + baseLight: luminance(base), + sweepLight: luminance(sweep), + saturation: (max - min) / (1 - Math.abs(max + min - 1)), + blueSweep: sweep[2]! > sweep[1]! && sweep[1]! > sweep[0]!, + } + }) + expect(colors.saturation).toBeLessThan(0.7) + expect(colors.baseLight).toBeGreaterThan(0.25) + expect(colors.sweepLight).toBeGreaterThan(colors.baseLight * 1.3) + expect(colors.blueSweep).toBe(true) + await expectSharedShimmer(runningStatus) + } finally { + await page.evaluate((value) => { + if (value === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', value) + }, originalDarkTheme) + } // Only the user bubble owns a footer (clock + copy; user bubbles carry no // branch action): the narration is not the answer yet. const copyButtons = page.getByRole('button', { name: 'Copy' }) @@ -170,6 +239,19 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await settled expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted']) await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 }) + const stopped = page.getByRole('button', { name: 'Stopped', exact: true }) + expect(await stopped.isEnabled()).toBe(true) + expect(await stopped.evaluate(element => getComputedStyle(element).fontSize)).toBe('14px') + expect(await stopped.getAttribute('aria-expanded')).toBe('false') + expect(await runningStatus.count()).toBe(0) + const processGroup = page.locator('[data-process-activity]').first() + expect(await processGroup.isVisible()).toBe(false) + await stopped.click() + expect(await stopped.getAttribute('aria-expanded')).toBe('true') + expect(await processGroup.isVisible()).toBe(true) + await stopped.click() + expect(await stopped.getAttribute('aria-expanded')).toBe('false') + expect(await processGroup.isVisible()).toBe(false) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) await copyButtons.last().focus() @@ -235,7 +317,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { element.closest('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom) const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => element.closest('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top) - expect(answerTop).toBe((processBottom ?? 0) + 8) + expect(answerTop).toBe((processBottom ?? 0) + 16) await process.focus() const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 42c5b1d8fc..8c497e0db7 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -134,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const disclosures = element.querySelectorAll('[data-disclosure-row]') const runHeader = disclosures[0] const phaseHeader = disclosures[1] - const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null + const phaseTitle = phaseHeader?.querySelector('[data-shimmer] > span > span') ?? null const phaseStatus = element.querySelector('[data-phase-status-text]') const originalPhaseTitle = phaseTitle?.textContent ?? '' if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout' @@ -153,6 +153,10 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, phaseTitleRight, phaseStatusLeft, + phaseStatusRight: phaseStatus?.getBoundingClientRect().right ?? 0, + phaseHeaderRight: phaseHeader?.getBoundingClientRect().right ?? 0, + contentGap: phaseTitle?.parentElement === null || phaseTitle?.parentElement === undefined + ? '' : getComputedStyle(phaseTitle.parentElement).columnGap, } }) expect(darkNarrow.clientWidth).toBe(356) @@ -165,6 +169,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = expect(darkNarrow.runHeight).toBe(32) expect(darkNarrow.phaseHeight).toBe(32) expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft) + expect(darkNarrow.phaseStatusRight).toBe(darkNarrow.phaseHeaderRight) + expect(darkNarrow.contentGap).toBe('6px') await page.locator('[data-workflow-run]').evaluate((element) => { (element as HTMLElement).style.removeProperty('width') document.body.removeAttribute('data-ds-dark-theme') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 01fae81ea8..693a7fed3d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: cca209016220560b7f39831e11ebb677da515ea9 -README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683 +README.md: 463fd1d0ea3ad97623eabf1c4d57371fd7310927 +README.zh.md: 8e3d4455e74f55c872ec5011f0b8ac59935d3b3b diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index cca2090162..463fd1d0ea 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -40,7 +40,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat. +Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. @@ -99,6 +99,8 @@ Each group owns local `useDisclosure` state that survives mode changes while its The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. +Process rows share one text and icon color in every Work details mode: tertiary at rest and secondary on hover, while error and warning text keeps its semantic color. Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [Shimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Non-response rows sit 8px apart; anything beside an Assistant response is 16px away, and a closed Turn control keeps its answer 16px below the summary. Leading icons, disclosure chevrons, and their boxes scale with the content font-size delta. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 0b97e490f5..8e3d4455e7 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## Chat 隐藏的行 -Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,使用细边框和与周围有区分度的中性底色,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -99,6 +99,8 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 +过程行的文字与图标在所有工作过程展示模式下共用一种颜色:默认 tertiary、悬停 secondary,错误与警告文字保留语义色。运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [Shimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。非回复条目之间相隔 8px;与 Assistant 回复相邻的条目相隔 16px,收起的整轮控件与其回答之间保持 16px。前置图标、折叠箭头及其容器随正文字号增量缩放。 + ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 61174be32d..ca98b7dc89 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -82,17 +82,18 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-stats": "workspace:*", "@deepseek-ai/dsh-session-turn-outline": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", + "lottie-web": "^5.13.0", "react": "^18.2.0", - "react-dom": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:*" + "react-dom": "^18.2.0" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css index 56687cd84e..ad2e0379d2 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -10,7 +10,7 @@ padding: 0; border: 0; background: none; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: var(--dsh-content-font-size, 14px); text-align: left; @@ -19,18 +19,30 @@ } .title:hover { - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-secondary); } +/* The leading box and its glyph ride the body font-size delta so the icon + keeps its optical share of the row at every Settings size. */ .leading { position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; - color: var(--dsw-alias-label-tertiary); + width: calc(16px + var(--dsh-content-font-delta, 0px)); + height: calc(16px + var(--dsh-content-font-delta, 0px)); + color: inherit; +} + +.leading svg { + width: calc(14px + var(--dsh-content-font-delta, 0px)); + height: calc(14px + var(--dsh-content-font-delta, 0px)); +} + +.leading svg[width="16"] { + width: calc(16px + var(--dsh-content-font-delta, 0px)); + height: calc(16px + var(--dsh-content-font-delta, 0px)); } .activityIcon, @@ -68,7 +80,7 @@ } .title[aria-expanded="true"] { - padding-bottom: 16px; + padding-bottom: 8px; } .body { @@ -121,7 +133,6 @@ margin-top: var(--dsh-chat-flow-gap, 8px); } .expandedBody { - --dsh-chat-flow-gap: 16px; max-height: none; overflow: visible; scrollbar-gutter: auto; diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 2944f85c38..2ca89d0ef2 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -4,7 +4,7 @@ import { IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, - IconThinkOutlineRegular, TextShimmer, + IconThinkOutlineRegular, Shimmer, ShimmerText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ProcessActivity } from '../contract/process-groups.ts' @@ -120,7 +120,9 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG {open ? : } - {title} + + {title} + ) }) @@ -141,7 +143,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none') const reason = turnLocation?.end?.data.reason.kind const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true - || reason === 'aborted' || reason === 'error' + || reason === 'error' const spec = presentation?.spec const selectStored = useCallback((state: Readonly) => turn === undefined ? undefined : storedTurnProcessEntry(state, turn), [turn]) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 5bb003bdfa..5051844c96 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -153,6 +153,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ data-chat-node-key={routedNode.key} data-chat-group-part={groupPart} data-chat-flow-kind={routedNode.kind} + data-chat-flow-response={groupPart === 'response' || undefined} data-chat-turn={turn} data-turn-process-member={processMember || undefined} data-turn-process-hidden={processHidden || undefined} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index a6b3955f8c..d652100e90 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -67,7 +67,17 @@ hidden and empty Seats do not contribute spacing. */ .column > :not([hidden]):not(.flowItem:empty) ~ :not([hidden]):not(.flowItem:empty) { - margin-top: var(--dsh-chat-flow-gap, 16px); + margin-top: var(--dsh-chat-flow-gap, 8px); +} + +/* Assistant responses have 16px clearance from adjacent flow items. */ +.flowItem[data-chat-flow-response] { + --dsh-chat-flow-gap: 16px; +} + +.column > [data-chat-flow-response]:not([hidden]):not(:empty) + + :not([hidden]):not(.flowItem:empty):not(.running) { + --dsh-chat-flow-gap: 16px; } /* Settled-flow identity boundary. It is neutral until it becomes the natural @@ -76,10 +86,11 @@ min-width: 0; } -/* A closed process reads as one summary immediately followed by its answer. - Expanded process rows return to the ordinary 16px rhythm. */ -.flowItem[data-turn-process-answer] { - --dsh-chat-flow-gap: 8px; +/* The Turn header has 16px clearance from its input and following content. */ +.flowItem[data-chat-flow-kind="turn-process"], +.flowItem[data-turn-process-answer], +.column > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:empty):not(.running) { + --dsh-chat-flow-gap: 16px; } /* Empty Seats retain ordered layout positions for Turn lookup without @@ -101,6 +112,56 @@ line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } +.running { + display: flex; + align-items: center; + justify-content: center; + column-gap: 16px; + --dsh-chat-flow-gap: 32px; + --dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer); + color: var(--dsw-alias-label-deep-diving); + font-size: calc(var(--dsh-content-font-size, 14px) - 2px); + line-height: calc(22px + var(--dsh-content-font-delta, 0px)); +} + +.running::before, +.running::after { + content: ''; + flex: 0 0 32px; + border-top: 0.5px solid var(--dsw-alias-border-l3); +} + +.scroll:has(.running) { + padding-bottom: 24px; +} + +.runningContent { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 6px; +} + +.runningText { + min-width: 0; +} + +.runningNumber { + font-family: var(--ds-font-family-code); + font-variant-numeric: tabular-nums; +} + +.runningIcon { + display: inline-flex; + flex: none; + width: calc(14px + var(--dsh-content-font-delta, 0px)); + height: calc(14px + var(--dsh-content-font-delta, 0px)); +} + +.runningIcon path[stroke] { + stroke: currentColor; +} + .openError { color: var(--dsw-alias-state-error-primary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0514068aed..49d69a729e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -18,6 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx' import { chatRenderKey } from './render-entry.ts' import { assertNever } from '@deepseek-ai/dsh-util-values' import { TurnNavigator } from './TurnNavigator.tsx' +import { RunningStatus } from './RunningStatus.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -269,6 +270,7 @@ export function ChatView({ t={t} /> + {running && } {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index a4bb6ef0cc..e05c9ff13c 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -7,13 +7,8 @@ flex-shrink: 0; } -.chevron { - color: var(--dsw-alias-label-secondary); -} - .title { font-weight: 400; - transition: color 100ms ease; } .separator { @@ -29,17 +24,10 @@ min-width: 0; overflow: hidden; flex: 1 1 auto; - color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; - transition: color 100ms ease; -} - -.row:hover .title, -.row:hover .summary:not([data-error]) { - color: var(--dsw-alias-label-primary); } .summary[data-error], diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 0b0749881b..769e4977f4 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo, useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconApiOutlineRegular, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' import css from './GenericCommandCard.module.css' @@ -42,12 +42,12 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru const toggle = useCallback(() => { setExpanded(value => !value) }, []) const collapsedContent = useMemo(() => ( <> - + - {summary} + {summary} - ), [running, state, summary]) + ), [state, summary]) const content = useMemo(() => open ?
{body}
: undefined, [body, open, state]) @@ -56,10 +56,8 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru {state === 'running' && {t('row.running')}} {state === 'error' && {t('row.failed')}} - - {t('message.retry.status', { label, retry: node.retry, maximum, seconds })} + + {status}
diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index f59c04e404..402a9effc1 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -9,7 +9,6 @@ } .row { - position: relative; overflow: hidden; } @@ -22,35 +21,10 @@ background: var(--dsw-alias-bg-base); } -.root[data-state='running'] .row::after { - content: ''; - position: absolute; - inset-block: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-reasoning-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-reasoning-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { flex-shrink: 0; } -.chevron { - color: var(--dsw-alias-label-secondary); -} - .title { font-weight: 400; } @@ -71,7 +45,6 @@ min-width: 0; overflow: hidden; flex: 1 1 auto; - color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap; @@ -102,9 +75,3 @@ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); min-width: 0; } - -@media (prefers-reduced-motion: reduce) { - .root[data-state='running'] .row::after { - animation: none; - } -} diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 74a801d6dd..dd27a47b5c 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,6 +1,6 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ import { memo, useMemo } from 'react' -import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconThinkOutlineRegular, MarkdownText, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' @@ -59,9 +59,11 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese && (running || policy.settledReasoningPreview)) const collapsedContent = useMemo(() => ( <> - + - {summary} + + {summary} + ), [running, summary]) @@ -84,9 +86,9 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese rowClassName={css.row} leadingClassName={css.leading} titleClassName={css.title} - chevronClassName={css.chevron} icon={THINK_ICON} title={t('message.think')} + running={running} open={expanded} expandable expandOnRowClick diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx new file mode 100644 index 0000000000..a76c7210b3 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx @@ -0,0 +1,82 @@ +/** Running Turn clock isolated from the transcript's render cycle. */ +import { memo, useEffect, useRef, useState } from 'react' +import { Shimmer, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' +import lottie from 'lottie-web/build/player/lottie_light.js' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts' +import whaleTail from './whale-tail.lottie.json' with { type: 'json' } +import a11yCss from './accessibility.module.css' +import css from './ChatView.module.css' + +/** + * Show live elapsed time after the current Turn's content without announcing ticks. + * @param props - Chat timeline selector and localized copy. + * @returns the blue running indicator; mount only while the Session is running. + */ +export const RunningStatus = memo(function RunningStatus({ useChat, t }: Pick) { + const startTime = useChat(({ timeline }) => { + const latest = timeline.turnOrder.at(-1) + const turn = latest === undefined ? undefined : timeline.turns.get(latest) + return turn?.status === 'open' ? turn.start?.time : undefined + }) + const [now, setNow] = useState(Date.now) + useEffect(() => { + if (startTime === undefined) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [startTime]) + const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', { + duration: formatLiveRunDuration(Math.max(1000, now - startTime), t), + }) + return ( +
+ {t('chat.deepDiving')} + + + + + {label.split(/(\d+)/).map((part, index) => ( + {part} + ))} + + + +
+ ) +}) + +/** Loop once per two text sweeps (3s), with a 0.3s delay; hold the initial pose for reduced motion. */ +function RunningWhaleTail() { + const container = useRef(null) + useEffect(() => { + if (container.current === null) return + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + const animation = lottie.loadAnimation({ + container: container.current, + renderer: 'svg', + loop: true, + autoplay: false, + animationData: structuredClone(whaleTail), + rendererSettings: { focusable: false }, + }) + let playTimer: number | undefined + const syncPlayback = () => { + window.clearTimeout(playTimer) + animation.goToAndStop(0, true) + if (!reducedMotion.matches) { + playTimer = window.setTimeout(() => { animation.play() }, 300) + } + } + animation.addEventListener('DOMLoaded', syncPlayback) + reducedMotion.addEventListener('change', syncPlayback) + syncPlayback() + return () => { + window.clearTimeout(playTimer) + reducedMotion.removeEventListener('change', syncPlayback) + animation.removeEventListener('DOMLoaded', syncPlayback) + animation.destroy() + } + }, []) + return )} - {title} - {(keepContentWhenOpen || !open) && collapsedContent} + + {title} + {(keepContentWhenOpen || !open) && collapsedContent} +
{open && children} diff --git a/packages/client/ui-primitives/src/Shimmer.module.css b/packages/client/ui-primitives/src/Shimmer.module.css new file mode 100644 index 0000000000..5131ecd653 --- /dev/null +++ b/packages/client/ui-primitives/src/Shimmer.module.css @@ -0,0 +1,88 @@ +.root { + position: relative; + display: inline-grid; + width: max-content; + max-width: 100%; + min-width: 0; + vertical-align: top; + color: inherit; +} + +.content { + display: flex; + align-items: center; + min-width: 0; +} + +.text { + color: inherit; +} + +.text[data-shimmer-text]::after { + content: attr(data-shimmer-text); +} + +.decoration { + position: absolute; + inset: 0; + /* The moving mask must not enlarge any ancestor's scrollable area. */ + overflow: clip; + pointer-events: none; + user-select: none; +} + +.sweep { + position: absolute; + inset: 0; + overflow: hidden; + color: var(--dsw-alias-label-shimmer); + mask-image: linear-gradient(105deg, transparent 0%, black 40% 60%, transparent 100%); + transform: translateX(-100%); + animation-name: dsh-row-shimmer-sweep; +} + +/* Semantic base colors stay on the real content; the overlay supplies one tint. */ +.sweep * { + color: inherit !important; +} + +.sweep [data-shimmer-decoration] { + background: currentColor; +} + +/* Opposite transforms keep every glyph aligned while their shared mask moves. */ +.highlight { + width: 100%; + height: 100%; + transform: translateX(100%); + animation-name: dsh-row-shimmer-highlight; +} + +.sweep, +.highlight { + animation-duration: 1.5s; + animation-delay: 0.3s; + animation-timing-function: steps(48, end); + animation-iteration-count: infinite; +} + +@keyframes dsh-row-shimmer-sweep { + 0% { transform: translateX(-100%); } + 66.6667%, 100% { transform: translateX(100%); } +} + +@keyframes dsh-row-shimmer-highlight { + 0% { transform: translateX(100%); } + 66.6667%, 100% { transform: translateX(-100%); } +} + +@media (prefers-reduced-motion: reduce) { + .decoration { + display: none; + } + + .sweep, + .highlight { + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/Shimmer.tsx b/packages/client/ui-primitives/src/Shimmer.tsx new file mode 100644 index 0000000000..3c6e02b40d --- /dev/null +++ b/packages/client/ui-primitives/src/Shimmer.tsx @@ -0,0 +1,66 @@ +/** One activity highlight across a row's text and separators. */ +import { createContext, memo, useContext, type ReactNode } from 'react' +import clsx from 'clsx' +import css from './Shimmer.module.css' + +const DecorativeCopy = createContext(false) + +/** Presentational content shared by the visible row and its inert highlight. */ +export interface ShimmerProps { + /** Text and layout elements; keep icons outside and use ShimmerText for text. No effects or element ids. */ + children: ReactNode + /** Whether the row's owning operation is running. */ + active: boolean + /** Additional class applied to the retained wrapper. */ + className?: string | undefined + /** Layout class applied equally to the base and decorative content. */ + contentClassName?: string | undefined +} + +/** + * Sweep one highlight across the visible width of a row's text; place icons outside. + * Children also render in an inert, aria-hidden decoration while active; keep + * state and subscriptions above this component and supply presentational nodes. + * The decoration is clipped to the row without extending its scrollable area. + * @param props - row content, activity, and layout classes. + * @returns the stable base row and optional masked decoration. + */ +export const Shimmer = memo(function Shimmer({ children, active, className, contentClassName }: ShimmerProps) { + return ( + + {children} + {/* React 18 emits inert as a string attribute; React 19 requires boolean true. */} + {active && ( + + )} + + ) +}) + +/** Text rendered once for selection and accessibility, with generated decoration. */ +export interface ShimmerTextProps { + /** Visible, already-localized text. */ + children: string + /** Typography and truncation class applied to both copies. */ + className?: string | undefined +} + +/** + * Render row text without duplicating its selectable or accessible content. + * @param props - text and owner styling. + * @returns text in the base row or generated text in its decoration. + */ +export const ShimmerText = memo(function ShimmerText({ children, className }: ShimmerTextProps) { + const decorative = useContext(DecorativeCopy) + return ( + + {decorative ? null : children} + + ) +}) diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css deleted file mode 100644 index bdc2de9a44..0000000000 --- a/packages/client/ui-primitives/src/TextShimmer.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.root[data-text-shimmer] { - background-image: linear-gradient( - 90deg, - currentColor calc(50% - var(--dsh-text-shimmer-spread)), - color-mix(in oklab, currentColor 50%, transparent), - currentColor calc(50% + var(--dsh-text-shimmer-spread)) - ); - background-position: 100% center; - background-repeat: no-repeat; - background-size: 250% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: dsh-text-shimmer 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite; -} - -@keyframes dsh-text-shimmer { - 66.6667%, 100% { background-position: 0% center; } -} - -@media (prefers-reduced-motion: reduce) { - .root[data-text-shimmer] { - background-image: none; - -webkit-text-fill-color: currentColor; - animation: none; - } -} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx deleted file mode 100644 index 8249626435..0000000000 --- a/packages/client/ui-primitives/src/TextShimmer.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** Text-only activity animation with a stable span across lifecycle changes. */ -import { memo, useMemo, type CSSProperties } from 'react' -import clsx from 'clsx' -import css from './TextShimmer.module.css' - -/** Text and activity supplied by the owning row. */ -export interface TextShimmerProps { - children: string - active: boolean - className?: string | undefined -} - -/** - * Render text with an optional moving highlight; inactive text keeps the same node. - * @param props - localized text, running state, and owner styling. - * @returns the retained text span. - */ -export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) { - const style = useMemo(() => ({ - '--dsh-text-shimmer-spread': `${children.length * 8}px`, - } as CSSProperties), [children.length]) - return {children} -}) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 1629d28a35..cf7a39c381 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -449,9 +449,9 @@ export const IconEditOutlineMedium = (props: IconProps) => ( const IconThinkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index ab82a9f6a5..d8f4e1928f 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -7,8 +7,8 @@ export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' export type { DisclosureRowProps } from './DisclosureRow.tsx' -export { TextShimmer } from './TextShimmer.tsx' -export type { TextShimmerProps } from './TextShimmer.tsx' +export { Shimmer, ShimmerText } from './Shimmer.tsx' +export type { ShimmerProps, ShimmerTextProps } from './Shimmer.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' diff --git a/packages/client/ui-primitives/tests/shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/shimmer.client.spec.tsx new file mode 100644 index 0000000000..d2afcf0e71 --- /dev/null +++ b/packages/client/ui-primitives/tests/shimmer.client.spec.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Shimmer, ShimmerText } from '../src/Shimmer.tsx' + +afterEach(cleanup) + +describe('Shimmer', () => { + it('retains real text and controls while one inert decoration covers icons and text', () => { + const open = vi.fn() + const row = (active: boolean, title: string) => ( + + + {title} + + + ) + const view = render(row(true, 'Read')) + const title = view.getByText('Read') + const button = view.getByRole('button', { name: 'file.ts' }) + const decoration = view.container.querySelector('[inert]')! + expect(decoration.getAttribute('aria-hidden')).toBe('true') + expect(decoration.querySelector('svg')).not.toBeNull() + expect([...decoration.querySelectorAll('[data-shimmer-text]')].map(node => node.getAttribute('data-shimmer-text'))) + .toEqual(['Read', 'file.ts']) + expect(view.container.textContent).toBe('Readfile.ts') + fireEvent.click(button) + expect(open).toHaveBeenCalledOnce() + + view.rerender(row(true, 'Reading')) + expect(view.getByText('Reading')).toBe(title) + expect(view.getByRole('button', { name: 'file.ts' })).toBe(button) + expect(view.container.querySelector('[inert]')).toBe(decoration) + + view.rerender(row(false, 'Read')) + expect(view.getByText('Read')).toBe(title) + expect(view.getByRole('button', { name: 'file.ts' })).toBe(button) + expect(view.container.querySelector('[inert]')).toBeNull() + expect(view.container.querySelectorAll('svg')).toHaveLength(1) + }) +}) diff --git a/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts deleted file mode 100644 index b416a1705f..0000000000 --- a/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** CSS activity and reduced-motion rules that jsdom does not execute. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/TextShimmer.module.css', import.meta.url), 'utf8') - -describe('TextShimmer styles', () => { - it('animates active text and disables motion and transparent fill for reduced motion', () => { - expect(css).toMatch(/\.root\[data-text-shimmer\]\s*\{[^}]*animation: dsh-text-shimmer/s) - expect(css).toMatch(/@keyframes dsh-text-shimmer\s*\{[^}]*background-position: 0% center/s) - const reduced = /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.root\[data-text-shimmer\]\s*\{([^}]+)\}/.exec(css)?.[1] - expect(reduced).toContain('background-image: none;') - expect(reduced).toContain('-webkit-text-fill-color: currentColor;') - expect(reduced).toContain('animation: none;') - }) -}) diff --git a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx deleted file mode 100644 index 5f6efdf805..0000000000 --- a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { TextShimmer } from '../src/TextShimmer.tsx' - -afterEach(cleanup) - -describe('TextShimmer', () => { - it('retains its text span across activity, text, and owner-class changes', () => { - const view = render(Run) - const span = view.getByText('Run') - expect(span.tagName).toBe('SPAN') - expect(span.dataset.textShimmer).toBe('true') - expect(span.classList.contains('title')).toBe(true) - expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') - - view.rerender(End) - expect(view.getByText('End')).toBe(span) - expect(span.hasAttribute('data-text-shimmer')).toBe(false) - expect(span.classList.contains('title')).toBe(false) - expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') - - view.rerender(Running) - expect(view.getByText('Running')).toBe(span) - expect(span.dataset.textShimmer).toBe('true') - expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px') - }) - -}) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c0203ad861..784ab58cab 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 0234ce8fb6c2737ea242ac5590e471c3e4c91834 -README.zh.md: 5b039ec1c5bea818e6396f4896c05b8175beb08a +README.md: 9e9bc11f76bce8159e9f63a011c686c62db39776 +README.zh.md: 93d0f1ce318c404fcff512168d6092b21b91743e diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 0234ce8fb6..9e9bc11f76 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -35,7 +35,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves The `preparing` stage shows only the skill icon and title, with no requested skill name or expansion. The `start` and `result` stages use the recorded arguments and output described below; preparation does not consult the installed catalog. -A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. +A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. The header follows the shared [process-row text and icon colors](../ui-primitives/README.md#component-catalog). Every lifecycle state retains the ordinary skill glyph; running calls share one left-to-right shimmer across the title, separator, and skill name, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 5b039ec1c5..93d0f1ce31 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" `preparing` 阶段只显示技能图标与标题,不显示请求加载的技能名称,也不能展开。`start` 和 `result` 阶段使用下述记录参数与输出;准备阶段不查询已安装目录。 -收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。标题行遵循共享的[过程行文字与图标颜色](../ui-primitives/README.zh.md#component-catalog)。所有生命周期状态都保留普通 skill 图标;运行中的调用以同一道高光从左到右扫过标题、分隔符与 skill 名称,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。 diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css index 9fad40a2d0..39a73a614c 100644 --- a/packages/client/ui-skill/src/client/SkillRow.module.css +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -12,33 +12,18 @@ align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.row:hover { + color: var(--dsw-alias-label-secondary); } .row[data-expandable] { cursor: pointer; } -.card[data-state='running'] .row::after, -.card[data-state='preparing'] .row::after { - content: ''; - position: absolute; - inset: 0 auto 0 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-skill-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-skill-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { position: relative; flex: none; @@ -48,7 +33,7 @@ align-items: center; justify-content: center; margin-right: 6px; - color: var(--dsw-alias-label-tertiary); + color: inherit; } .leading svg { @@ -57,7 +42,7 @@ } .chevron { - color: var(--dsw-alias-label-secondary); + color: inherit; } .iconIdle { @@ -86,7 +71,6 @@ flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-secondary); } .separator { @@ -106,7 +90,6 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); } .errorSummary { @@ -210,11 +193,7 @@ } @media (prefers-reduced-motion: reduce) { - .card[data-state='running'] .row::after { - animation: none; - display: none; - } - + .row, .iconIdle, .chevronHover, .inspectButton { diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 0800bc7812..1deb1b64b2 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -1,6 +1,6 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { - IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, + IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, Shimmer, ShimmerText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StartedToolCallViewProps, ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -85,7 +85,7 @@ function disclosureLeading(open: boolean, expandable: boolean): ReactNode { ) } -/** Visually hidden state copy for the color-only running sweep and error tone. */ +/** Accessible running and failure status. */ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { switch (state) { case 'running': return t('row.running') @@ -95,7 +95,7 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null } /** - * Render one `skill` tool call as an accent summary and instructions disclosure. + * Render one `skill` tool call as a process row and instructions disclosure. * @param props - keyed toolview payload plus the skill locale seat. * @returns the dedicated skill row. */ @@ -104,7 +104,9 @@ export function SkillRow(props: SkillRowProps) {
{props.t('row.preparing')} - {props.t('row.title')} + + {props.t('row.title')} +
return @@ -116,6 +118,7 @@ function StartedSkillRow({ block, inspect, t }: Exclude { setExpanded(value => !value) @@ -140,16 +143,18 @@ function StartedSkillRow({ block, inspect, t }: Exclude - {leading} {status !== null ? {status} : null} - {t('row.title')} - - - {summary} - + {leading} + + {t('row.title')} + + + {summary} + + {open ? (
diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index a34b8ff4e9..34b143d0ef 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -110,7 +110,7 @@ describe('SkillRow', () => { }))} />) const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') - expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull() expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() expect(row.textContent).not.toContain('Check SKILL.md.') fireEvent.click(row) @@ -124,8 +124,8 @@ describe('SkillRow', () => { error: { name: 'InterruptedError', code: 'interrupted' }, }))} />) const stoppedSummary = stoppedView.getByText('skill 加载已中止') - expect(stoppedSummary.className).toContain('stoppedSummary') - expect(stoppedView.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() + expect(stoppedSummary.parentElement?.className).toContain('stoppedSummary') + expect(stoppedView.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull() expect(stoppedView.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() cleanup() diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 046b137c88..6361eb4b7b 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 7248b7230afa28f817bec7c0eb26fd9512fff405 -README.zh.md: 6dc1db064dd9a6754f6c6475dde18ccf1a17841e +README.md: a2938f0c03d7f6b2c0202e67be1b66dff08516f3 +README.zh.md: d19649e26722f506c3142a095eb82b0a08235626 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 7248b7230a..a2938f0c03 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 17 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. +`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 24 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. ## Table of Contents @@ -29,7 +29,7 @@ Users switch the color scheme and content font size from two rows in Settings (G ### Appearance and font size -The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. +The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 24 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. ### Registering a theme @@ -51,7 +51,9 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. +`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. `--dsw-alias-label-shimmer` supplies an overlay for the shared text shimmer: black at 40% alpha in the light palette and white at 45% alpha in the dark palette. `--dsw-alias-label-deep-diving` and `--dsw-alias-label-deep-diving-shimmer` supply the blue activity label and sweep; the dark palette uses a lighter, less saturated label with a brighter blue sweep. + +The `--dsw-alias-turn-trigger-*` tokens provide separate resting and hover backgrounds for Turn-trigger notices in each palette. `brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 6dc1db064d..d19649e267 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 17 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 +`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 24 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 +插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 24 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 ### 注册主题 @@ -51,7 +51,9 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 +`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。`--dsw-alias-label-shimmer` 为共享文字扫光提供叠加色:浅色配色使用 40% alpha 的黑色,深色配色使用 45% alpha 的白色。 `--dsw-alias-label-deep-diving` 和 `--dsw-alias-label-deep-diving-shimmer` 提供蓝色活动文本和扫光颜色;深色主题使用更亮、饱和度更低的文字,并以更亮的蓝色扫光。 + +`--dsw-alias-turn-trigger-*` token 分别定义深浅主题下轮次触发通知的常态和悬停底色。 `brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 8f447398fc..a6510f2951 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -211,6 +211,8 @@ body { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75); --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); + --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950)); + --dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 35%, var(--dsw-static-blue-950)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); @@ -218,6 +220,7 @@ body { --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00); --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700); + --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 40%, transparent); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600); --dsw-alias-link: var(--dsw-static-deepseek-500); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100); @@ -254,6 +257,8 @@ body { --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100); --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850); + --dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block); + --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover); --dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200); --dsw-specific-bubble: var(--dsw-static-deepseek-50); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); @@ -317,6 +322,8 @@ body[data-ds-dark-theme] { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800); --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); + --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400)); + --dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); @@ -324,6 +331,7 @@ body[data-ds-dark-theme] { --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800); --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300); + --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400); --dsw-alias-link: var(--dsw-static-deepseek-400); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800); @@ -359,6 +367,8 @@ body[data-ds-dark-theme] { --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900); --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750); + --dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover); + --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750); --dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750); --dsw-specific-bubble: var(--dsw-static-neutral-bluish-850); diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index b6dc4db464..ae57254ad6 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -45,11 +45,11 @@ body[data-ds-dark-theme] { /* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素; 重新从 Figma 导出时需要保留这一缩放。 */ /* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter - 写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量 + 写在 body 上(整数 px,12–24,默认 14)。标题与正文变体按同一像素增量 (--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash 行的标题与摘要、markdown 表格、流内次级说明等)统一读 --dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为 - 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15)。式中 + 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15、24→22)。式中 max(13px,·) 的作用是让低设置取 −1 而非 −2,不是结果下限——12 设置下 层级触底 11px。small、code 属于密集次级文本,保持固定。 */ body { diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index 11f8789d6c..a9202ffa1b 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -24,7 +24,7 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system' export const FONT_SIZE_MIN = 12 /** Largest accepted content font size (px). */ -export const FONT_SIZE_MAX = 17 +export const FONT_SIZE_MAX = 24 /** Content font size when the user-settings document has no override (px). */ export const DEFAULT_FONT_SIZE = 14 diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index 6a0141ea87..574a54dd13 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -77,8 +77,8 @@ describe('theme bootstrap row', () => { it('writes the durable content font size and defaults it to 14px', () => { mockSystemDark(false) - executeBootstrap('light', 17) - expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('17px') + executeBootstrap('light', 24) + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('24px') executeBootstrap('light') expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('14px') }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index a3309b9887..8123a71b31 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -28,11 +28,11 @@ describe('ui-theme host', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration expect(plainConfig(configuration.fiber.config)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14 }) - await configuration.update({ preference: 'dark', fontSize: 16 }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 16 }) + await configuration.update({ preference: 'dark', fontSize: 24 }) + expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 24 }) await expect(configuration.update({ preference: 'sepia' })).rejects.toThrow() await expect(configuration.update({ fontSize: 11 })).rejects.toThrow() - await expect(configuration.update({ fontSize: 18 })).rejects.toThrow() + await expect(configuration.update({ fontSize: 25 })).rejects.toThrow() await fiber.dispose() }) @@ -51,10 +51,10 @@ describe('ui-theme host', () => { expect(rowText(rows[0])).toContain('@media(prefers-color-scheme:dark)') expect(rowText(rows[1])).toContain('const preference = "system"') expect(rowText(rows[1])).toContain('"14px"') - await configuration.update({ preference: 'dark', fontSize: 17 }) + await configuration.update({ preference: 'dark', fontSize: 24 }) expect(rowText(collect(ctx)[0])).toContain('color-scheme:dark') expect(rowText(collect(ctx)[1])).toContain('const preference = "dark"') - expect(rowText(collect(ctx)[1])).toContain('"17px"') + expect(rowText(collect(ctx)[1])).toContain('"24px"') await fiber.dispose() expect(collect(ctx)).toEqual([{ kind: 'script', placement: 'head', text: 'window.afterTheme=true' }]) }) diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index f75138b44a..bc8f33062e 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -87,7 +87,11 @@ describe('FontSizeRow', () => { }) it('disables the outward arrow at each bound', () => { - mount(17) + const b = mount(23) + fireEvent.click(arrow('Increase font size')) + expect(b.setFontSize).toHaveBeenCalledWith(24) + act(() => { b.store.actions.sync(24, 1) }) + expect(screen.getByText('24')).toBeDefined() expect(arrow('Increase font size').disabled).toBe(true) expect(arrow('Decrease font size').disabled).toBe(false) cleanup() diff --git a/packages/client/ui-theme/tests/theme.client.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts index a50ed3e7e2..d00b1d5b66 100644 --- a/packages/client/ui-theme/tests/theme.client.spec.ts +++ b/packages/client/ui-theme/tests/theme.client.spec.ts @@ -36,10 +36,10 @@ describe('ThemeRuntime', () => { it('seeds the initial font size from the boot-script body variable, ignoring junk', () => { // The Host boot script writes the durable size on body before any plugin // runs; the first snapshot must match it so activation never flashes 14. - document.body.style.setProperty('--dsh-content-font-size', '16px') + document.body.style.setProperty('--dsh-content-font-size', '24px') try { - expect(make().theme.getTheme().fontSize).toBe(16) - document.body.style.setProperty('--dsh-content-font-size', '99px') + expect(make().theme.getTheme().fontSize).toBe(24) + document.body.style.setProperty('--dsh-content-font-size', '25px') expect(make().theme.getTheme().fontSize).toBe(14) } finally { document.body.style.removeProperty('--dsh-content-font-size') @@ -48,19 +48,19 @@ describe('ThemeRuntime', () => { it('setFontSize switches, writes through the scope, and republishes; same value is a no-op', () => { const { theme, events, host } = make() - theme.setFontSize(17) - expect(theme.getTheme().fontSize).toBe(17) - expect(host.set).toHaveBeenCalledWith('fontSize', 17) + theme.setFontSize(24) + expect(theme.getTheme().fontSize).toBe(24) + expect(host.set).toHaveBeenCalledWith('fontSize', 24) expect(events).toHaveLength(1) - theme.setFontSize(17) + theme.setFontSize(24) expect(events).toHaveLength(1) expect(host.set).toHaveBeenCalledOnce() }) it('rejects out-of-range and fractional font sizes', () => { const { theme, events, host } = make() - for (const px of [11, 18, 14.5, Number.NaN]) { - expect(() => { theme.setFontSize(px) }).toThrow('outside 12..17') + for (const px of [11, 25, 14.5, Number.NaN]) { + expect(() => { theme.setFontSize(px) }).toThrow('outside 12..24') } expect(events).toHaveLength(0) expect(host.set).not.toHaveBeenCalled() diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index af3b5ca741..358fa4458f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb -README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e +README.md: 1fcfa9ffda84b5e0bf76a46accbe486cfb9e9d04 +README.zh.md: 2806fa47ae4d8e48215bce9ca178984839633421 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index a00ed8e8c7..1fcfa9ffda 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -49,7 +49,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`. -This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Tool headers follow the shared [process-row colors](../ui-primitives/README.md#component-catalog) for names, summaries, file paths, ordinary counts, glyphs, and chevrons in every Work details mode, whether details are collapsed, expanded, or unavailable; error and warning summaries keep their semantic colors on hover. Running atomic rows share one left-to-right shimmer across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -72,7 +72,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. -Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. +Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index c4a6f6183b..2806fa47ae 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -49,7 +49,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 -本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。工具标题行的名称、摘要、文件路径、普通数量、图标与箭头在所有工作过程展示模式下均遵循共享的[过程行颜色](../ui-primitives/README.zh.md#component-catalog),无论详情是收起、展开还是不可展开;错误和警告摘要在悬停时保留语义色。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -72,7 +72,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调 每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 -Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。 +Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 5e54eeb7e5..a33507500d 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -15,28 +15,12 @@ flex-shrink: 0; } -/* Cordis lifecycle tools retain their generic row mechanics while carrying a - shared product accent and tool-owned action title. */ -.root[data-tool^='cordis_'] .leading, -.root[data-tool^='cordis_'] .title { - color: var(--dsw-alias-state-business-primary); -} - .root[data-tool^='cordis_'] .title { font-weight: 500; } -.root[data-tool^='cordis_'] .sep { - background: var(--dsw-alias-state-business-primary); -} - -.chevron { - color: var(--dsw-alias-label-secondary); -} - .title { font-weight: 400; - transition: color 100ms ease; } .sep { @@ -56,8 +40,6 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); - transition: color 100ms ease; } /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose @@ -71,31 +53,29 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); - transition: color 100ms ease; -} - -.row:hover .title, -.row:hover .summary:not(.errorSummary):not(.stoppedSummary), -.row:hover .summarySuffix { - color: var(--dsw-alias-label-primary); } /* Diff-row +/- totals: the code font, matching the diff body the numbers summarize; a wider gap keeps the digits from reading as part of the path. Two px under the secondary tier (still riding the axis): mono digits read - optically larger than the sans path at the same size. Caption, one step - dimmer than the suffix's tertiary, so the digits stay behind the path. The + optically larger than the sans path at the same size. The half-pixel nudge closes the baseline gap the size difference leaves under the row's box-centering (a transform, so flex layout is untouched). */ .diffStat { margin-left: 10px; font-family: var(--ds-font-family-code); font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px); - color: var(--dsw-alias-label-caption); transform: translateY(0.5px); } +.row:is(:hover, [aria-expanded='true']) .diffAdded { + color: var(--dsw-alias-state-success-primary); +} + +.row:is(:hover, [aria-expanded='true']) .diffRemoved { + color: var(--dsw-alias-state-error-primary); +} + /* File-tool path: same type as .summary, with a persistent link affordance. Dotted dimmed underline: visible enough to say "clickable", light enough that a long path doesn't read as one heavy rule under the row. Shrink-to-fit @@ -115,19 +95,12 @@ text-align: left; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-secondary); + color: inherit; text-decoration: underline dotted; - text-decoration-color: var(--dsw-alias-label-tertiary); + text-decoration-color: currentColor; text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: pointer; - transition: color 100ms ease; -} - -.row:hover .fileLink, -.fileLink:hover { - color: var(--dsw-alias-label-primary); - text-decoration-color: currentColor; } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 618344b4c7..8855a32105 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useMemo, type KeyboardEvent, type MouseEvent, type R import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, - TerminalBlock, TextShimmer, WebBlock, + TerminalBlock, ShimmerText, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -55,6 +55,7 @@ export interface ToolRowProps { errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ terminal?: TerminalCardModel | null | undefined + /** Diff card with inline totals colored on header hover and while expanded. */ diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined /** @@ -175,11 +176,7 @@ export const ToolRow = memo(function ToolRow({ const summaryText = failureLine ?? normalSummary // The tool row keeps the diff's +/- totals visible while its body is collapsed. // An explicit summarySuffix overrides the diff totals. - const diffStat = useMemo(() => { - if (diffBody === null) return null - const { added, removed } = diffTotals(diffBody.card.diffs) - return `+${added} -${removed}` - }, [diffBody]) + const diffStat = useMemo(() => diffBody === null ? null : diffTotals(diffBody.card.diffs), [diffBody]) const settledWithCue = state === 'error' || state === 'stopped' const suffix = settledWithCue ? null : summarySuffix ?? diffStat const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue @@ -203,7 +200,7 @@ export const ToolRow = memo(function ToolRow({ /* An empty summary drops the separator with it (a row that is only its title shows no trailing dot). */ <> - + {openFile !== undefined ? ( ) : ( - {summaryText} + {summaryText} )} - {suffix !== null && ( - {suffix} - )} + {suffix !== null && (typeof suffix === 'string' + ? {suffix} + : ( + + {`+${suffix.added}`} + {' '} + {`-${suffix.removed}`} + + ))} - ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) + ), [fileLinkKeyDown, openFile, state, suffix, summaryText]) const expandedContent = useMemo(() => open ? (
{askQuestionBody !== null @@ -340,7 +343,6 @@ export const ToolRow = memo(function ToolRow({ rowClassName={css.row} leadingClassName={css.leading} titleClassName={css.title} - chevronClassName={css.chevron} icon={icon} title={title} running={running} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index 9ef5b4b829..007219766c 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -89,6 +89,18 @@ align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.root:hover { + color: var(--dsw-alias-label-secondary); +} + +@media (prefers-reduced-motion: reduce) { + .root { + transition: none; + } } .leading { @@ -100,7 +112,7 @@ align-items: center; justify-content: center; margin-right: 6px; - color: var(--dsw-alias-label-tertiary); + color: inherit; } /* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides @@ -115,7 +127,7 @@ } .chevron { - color: var(--dsw-alias-label-secondary); + color: inherit; } /* Hover preview on the expandable row: the idle icon crossfades (100ms) into @@ -146,8 +158,6 @@ flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-secondary); - transition: color 100ms ease; } .sep { @@ -167,13 +177,6 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); - transition: color 100ms ease; -} - -.root:hover .title, -.root:hover .summary:not(.errorSummary):not(.stoppedSummary) { - color: var(--dsw-alias-label-primary); } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 47ae2d1b6d..68e18b8aee 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, - TerminalBlock, TextShimmer, + TerminalBlock, Shimmer, ShimmerText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -107,17 +107,19 @@ const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId onClick={expandable ? toggleExpand : undefined} onKeyDown={expandable ? toggleFromKeyboard : undefined} > - {leading} {status !== null && {status}} - {t(model.titleKey)} - - - {settlementLine ?? normalSummary} - + {leading} + + {t(model.titleKey)} + + + {settlementLine ?? normalSummary} + +
{open && (
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 3f9282b29f..7ebc5265a3 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -245,9 +245,10 @@ describe('FileMutationRow diff card', () => { meta: { diffs: [] }, }), 'write')} />) // The collapsed row already carries the card's +/- totals beside the path. - expect(view.getByText('+1 -0')).toBeTruthy() + expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toContain('+1 -0') toggleRow(view) - expect(view.getAllByText('+1 -0')).toHaveLength(1) + expect(view.getAllByText('+1')).toHaveLength(1) + expect(view.getAllByText('-0')).toHaveLength(1) expect(view.getByText('hello fixture')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index db05c7f769..399c588713 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -25,9 +25,15 @@ describe('ToolRow.module.css summary line', () => { it.each([ ['ToolRow', '../src/client/tool/components/ToolRow.module.css'], ['BashRow', '../src/client/tool/toolviews/bash-sample.module.css'], - ] as const)('%s keeps stopped and error summaries outside hover darkening', (_name, path) => { + ] as const)('%s leaves row text color to the shared disclosure header', (_name, path) => { + // The header supplies tertiary at rest and secondary on hover; a tool sheet + // that restates a row text color, or brightens the row text on hover, would + // pin one state against the shared process-row colors. The Inspect button + // keeps its own solid hover fill and is not row text. const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') - expect(sheet).toContain('.summary:not(.errorSummary):not(.stoppedSummary)') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + expect(sheet).not.toMatch(/\.(title|summary|summarySuffix)\s*\{[^{}]*\bcolor:/) + expect(sheet).not.toMatch(/:hover\s+\.(title|summary|summarySuffix)\b/) }) it('keeps the summary suffix on one line and unshrunk', () => { diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index f9338cac00..5f9c210e69 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -309,10 +309,11 @@ describe('ToolRow', () => { newText: 'start\nsecond\nthird\nnew\nfourth\nfifth\nend', }] }, }} />) - expect(view.getByText('+1 -1')).toBeTruthy() + expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toContain('+1 -1') expect(view.container.querySelector('[data-diff]')).toBeNull() fireEvent.click(view.getByRole('button')) - expect(view.getAllByText('+1 -1')).toHaveLength(1) + expect(view.getAllByText('+1')).toHaveLength(1) + expect(view.getAllByText('-1')).toHaveLength(1) expect(view.getAllByText('start')).toHaveLength(1) expect(view.getAllByText('end')).toHaveLength(1) expect(view.getByText('old', { exact: true })).toBeTruthy() @@ -344,7 +345,7 @@ describe('ToolRow', () => { it('keeps the business icon across running and error states', () => { const runningView = render() - expect(runningView.queryByTestId('tool-icon')).not.toBeNull() + expect(runningView.container.querySelector('[data-testid="tool-icon"]:not([inert] *)')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() expect(errorView.container.querySelector('[data-testid="tool-icon"]')).not.toBeNull() diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 190f136f70..270efc6c24 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -29,14 +29,14 @@ align-items: center; justify-content: center; margin-right: 0; - color: var(--dsw-alias-label-tertiary); + color: inherit; } .runTitle { overflow: hidden; flex: none; max-width: 42%; - color: var(--dsw-alias-label-secondary); + color: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); font-weight: 510; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); @@ -48,7 +48,7 @@ overflow: hidden; flex: 1; min-width: 0; - color: var(--dsw-alias-label-tertiary); + color: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; @@ -65,7 +65,7 @@ font-size: 11px; font-weight: 510; line-height: 16px; - color: var(--dsw-alias-label-secondary); + color: inherit; white-space: nowrap; } @@ -93,7 +93,7 @@ align-items: center; justify-content: center; margin-right: 0; - color: var(--dsw-alias-label-tertiary); + color: inherit; } .phaseTitle { @@ -101,7 +101,7 @@ flex: 0 1 auto; min-width: 0; max-width: 42%; - color: var(--dsw-alias-label-secondary); + color: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; @@ -112,7 +112,7 @@ overflow: hidden; flex: 1; min-width: 0; - color: var(--dsw-alias-label-tertiary); + color: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; @@ -125,7 +125,7 @@ /* Column width scales with the text (~132/13 ≈ 10px per font-size px) so larger sizes keep the same status text visible instead of truncating. */ width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10); - color: var(--dsw-alias-label-secondary); + color: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d834a613b..0e7d7b3a45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3193,6 +3193,9 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + lottie-web: + specifier: ^5.13.0 + version: 5.13.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -9182,7 +9185,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -19363,6 +19366,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lottie-web@5.13.0: + resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -22058,14 +22064,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 + http-proxy-agent: 7.0.2(supports-color@9.4.0) https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -24807,7 +24813,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2 + http-proxy-agent: 7.0.2(supports-color@9.4.0) https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -26237,7 +26243,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@9.4.0): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) @@ -26747,6 +26753,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lottie-web@5.13.0: {} + lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 2f724032d1..793df0ad81 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -135,6 +135,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] }, ] diff --git a/scripts/test-dom-environment.ts b/scripts/test-dom-environment.ts index 12c8895735..6991a6854e 100644 --- a/scripts/test-dom-environment.ts +++ b/scripts/test-dom-environment.ts @@ -1,8 +1,23 @@ /** Initial resize delivery from jsdom's modeled offsets; geometry/timing suites supply their own observers. */ import { afterAll, beforeEach } from 'vitest' +// The SVG animation player initializes a transparent 1px Canvas placeholder at import time. +const canvasPrototype = typeof HTMLCanvasElement === 'undefined' ? undefined : HTMLCanvasElement.prototype +const originalCanvasContext = canvasPrototype === undefined ? undefined : Object.getOwnPropertyDescriptor(canvasPrototype, 'getContext') +if (canvasPrototype !== undefined) { + Object.defineProperty(canvasPrototype, 'getContext', { + configurable: true, + value: (contextId: string) => { + if (contextId !== '2d') throw new Error(`test DOM: unsupported Canvas context ${contextId}`) + return { fillStyle: '', fillRect: () => {} } + }, + }) +} + const original = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver') let installed = false +const originalMatchMedia = typeof window === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(window, 'matchMedia') +let installedMatchMedia = false class TestResizeObserver implements ResizeObserver { private readonly targets = new Set() @@ -27,6 +42,13 @@ class TestResizeObserver implements ResizeObserver { } beforeEach(() => { + if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + const matchMedia: Window['matchMedia'] = media => Object.assign(new EventTarget(), { + media, matches: false, onchange: null, addListener: () => {}, removeListener: () => {}, + }) + Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: matchMedia }) + installedMatchMedia = true + } if (typeof document !== 'undefined' && typeof ResizeObserver === 'undefined') { // Establish the per-environment default, so vi.unstubAllGlobals restores it. Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, writable: true, value: TestResizeObserver }) @@ -35,7 +57,16 @@ beforeEach(() => { }) afterAll(() => { - if (!installed) return - if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver') - else Object.defineProperty(globalThis, 'ResizeObserver', original) + if (canvasPrototype !== undefined) { + if (originalCanvasContext === undefined) Reflect.deleteProperty(canvasPrototype, 'getContext') + else Object.defineProperty(canvasPrototype, 'getContext', originalCanvasContext) + } + if (installed) { + if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver') + else Object.defineProperty(globalThis, 'ResizeObserver', original) + } + if (installedMatchMedia) { + if (originalMatchMedia === undefined) Reflect.deleteProperty(window, 'matchMedia') + else Object.defineProperty(window, 'matchMedia', originalMatchMedia) + } }) diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index 1ceeeb4f1e..d3172476e3 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -9,8 +9,8 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" @@ -37,8 +37,8 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" [expanded] diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index c74b1b8193..e7f584b73c 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -8,7 +8,7 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy" - status: Stopped -- button "Stopped" [disabled] [expanded] +- button "Stopped" [expanded] - button "Ran commands" [expanded] - 'button "Failed Bash Error: tool call aborted" [expanded]' - text: "IN { \"command\": \"node -e \\\"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 7500599d2e..2f1d34dc98 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -1,7 +1,7 @@ - text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} - button "复制" -- status: 已完成工作 -- button "用时 {{duration}}" [expanded] +- status: 已完成 +- button "已完成,用时 {{duration}}" [expanded] - button "执行了命令,已写入文件,已读取文件等" [expanded] - button "思考" - button "读取 intro.md": diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index 021809ac0c..56f27b62ba 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -8,8 +8,8 @@ - tab "轨迹" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" -- status: 已完成工作 -- button "用时 {{duration}}" [disabled] +- status: 已完成 +- button "已完成,用时 {{duration}}" [disabled] - paragraph: MESSAGES_WEB_READY - button "复制" - button "好的回答" diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md index 27cdaa5150..9f26daff62 100644 --- a/snapshots/web/default-workspace/ui.expected.md +++ b/snapshots/web/default-workspace/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index f9d619e382..102788fbbe 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 716f3bd304..4f55e3e95f 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index f9d619e382..102788fbbe 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 716f3bd304..4f55e3e95f 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 0c60a10a69..ff7effe13d 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -11,8 +11,8 @@ - img "reference-1.png" - text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: UPLOAD_ROUND_OK - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0c4d334df3..de867a8745 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Think" - button "Bash Echo the test string" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 27cdaa5150..9f26daff62 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 067f2ece45..00385a4c9b 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -14,8 +14,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Ran commands and updated the plan" [expanded] - button "Think" - button "Bash List package directories deterministically" @@ -51,8 +51,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Think" - button "Bash Randomly select another package with Python" diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index d7d9ad4839..505a067cbb 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -14,8 +14,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -38,8 +38,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: @@ -94,7 +94,6 @@ - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- tooltip "Branch into a new conversation" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 761eb41613..8bf5e6fb79 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index f6a61355d8..6f51412377 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 7976fcca1c..5e83a173bc 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index ad8e9f8f2b..1fc7f0416b 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - group: - status: Retried model request (1/5) · {{duration}} - button "Analysis completed" [expanded] diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 33c058c865..abc388a29c 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 1bdf81ba91..9e5231c18f 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Add files or run commands" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index ed11835a6c..0eee19f56e 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -13,18 +13,15 @@ - button "Copy" - tooltip "Copy" - status: Stopped -- button "Stopped" [disabled] [expanded] -- button "Analysis completed" -- paragraph: I will read both files before answering. -- button "Read files" +- button "Stopped" - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" [disabled] - text: Available only on the last message of a completed turn {{clock}} Now give the final answer. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: DONE - button "Copy" - button "Good response" @@ -32,8 +29,8 @@ - button "Branch into a new conversation" - text: {{clock}} Keep this later input in the original conversation. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - paragraph: ORIGINAL ONLY - button "Copy" - button "Good response" diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index e489e916e6..0469ba0e75 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded] - text: "IN { \"command\": \"printf 'MINIMAL_BASH_CARD_OK\\\\n'\" } OUT MINIMAL_BASH_CARD_OK [Command finished with exit code 0]" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 6f978228c7..1bfe52e4e6 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 279843a249..bab8040ae6 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 08f503ab05..7aeef09f49 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,7 +1,7 @@ - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" -- status: 已完成工作 -- button "用时 {{duration}}" [expanded] +- status: 已完成 +- button "已完成,用时 {{duration}}" [expanded] - button "已完成分析" [expanded] - button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 34a2a77047..41d4680b7a 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - button "Think" - text: Failed diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 986dcf439c..3dbcb5d3ec 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Ran code, ran commands, read files" [expanded] - button "Think" - button "Code Run bash echo and catch missing file read" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 346b77ff8d..b8f6cce9a0 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Asked questions" [expanded] - button "Think" - button "Ask question 1/1 answered" [expanded] diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index be7b65aa37..6b62b305cb 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 7d5d89a078..647e99e6f5 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Asked questions" [expanded] - button "Think" - button "Ask question cancelled" [expanded] diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 64738a0101..9c17d3c73c 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Add files or run commands" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index d375969b1e..dbd34d30b8 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 2455bfb385..6d1d9a2f93 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -11,9 +11,9 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - region "To-dos": - button "To-dos 1 completed · 1 in progress" - text: Ongoing Goal Keep the composer context panels aligned diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 594d0053e8..1ec3f95c7b 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - list: - listitem: - text: Queue item to remove diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 6dc03fa797..2de2ae656f 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - list: - listitem: - text: Edited queue item diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index d014db4b09..7d06bd0b91 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -25,8 +25,8 @@ - img "queued.png" - text: Compare with this screenshot {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -34,8 +34,8 @@ - button "Branch into a new conversation" - text: {{clock}} Continue with the queued comparison {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 6496bb83f5..7ef1375487 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - list: - listitem: - img "Queued message image" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 86f3b1a9cc..1bf37d80f4 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 769c31023f..a0d3f44eed 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 6cbe989134..9c3baf29a6 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index b1842edc41..d2c53e99b2 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" @@ -19,8 +19,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 6e08ccbd47..7dde4db16f 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Skill editing-cordis-compositions" [expanded] diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 0a731c2f3e..29ee449f59 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -8,11 +8,11 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Waiting for your action" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" +- status: Deep diving +- text: Deep diving for {{duration}} - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index b04beec4bb..6ccbadfd62 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - button "Asked questions" [expanded] - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 030841b7e4..b13cb9a2f9 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index c04320b0b6..01e95cd04d 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -12,8 +12,8 @@ - tab "Trajectory" - text: Give one concrete event sourcing example. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [disabled] +- status: Completed +- button "Completed in {{duration}}" [disabled] - status: - strong: This subagent is read-only for now - text: The parent session is offline; reopen it to continue sending messages. diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 607e142b62..748cd0db25 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,7 +1,7 @@ - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 625180666b..56cb24ea87 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -15,8 +15,8 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. @@ -26,8 +26,8 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 6e67c79557..fd6280355e 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -15,8 +15,8 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -24,8 +24,8 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index c6e866c5e2..9b0d6b0309 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -11,9 +11,9 @@ - tab "Trajectory" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - list: - listitem: - text: Keep working until I stop you again. diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 75ee221050..5d309edf46 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index a31f0d0c79..353f648456 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Analysis completed" - paragraph: Reading the workspace now. - button "Ran commands" [expanded] diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 8a9f36aee9..e8cd6fbf1d 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -9,12 +9,12 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" -- status: Deep diving... -- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Analysis completed" - paragraph: Reading the workspace now. - button "Ran commands" - paragraph: partial +- status: Deep diving +- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index cbe5273748..a626148bfa 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -9,10 +9,7 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - status: Stopped -- button "Stopped" [disabled] [expanded] -- button "Analysis completed" -- paragraph: Reading the workspace now. -- button "Ran commands" +- button "Stopped" - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 8ced72edd1..791695b15c 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" +- status: Completed +- button "Completed in {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index f229309b8c..92cc1ce25e 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Searched the web" [expanded] - button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search" - paragraph: SEARCH_DONE diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 12ffccb420..c760b4962d 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,7 +1,7 @@ - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" -- status: Worked -- button "Took {{duration}}" [expanded] +- status: Completed +- button "Completed in {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Run workflow snapshot-flow" diff --git a/tsconfig.host.json b/tsconfig.host.json index 2d31d0df31..08de88abdc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", + "apps/web/tests/shimmer.ts", "apps/web/tests/model-input-layout.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/startup-rpc-budget.e2e.ts", From 7c8a44ac314556dc0eb730cf507a80ac60c486eb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:21:46 +0800 Subject: [PATCH 14/14] Revert "feat(web): restore the process-row polish on the grouped work details" This reverts commit 036d53fb612c785a17d9e6068f58b155d87120c1. --- THIRD_PARTY_NOTICES.md | 1 - apps/web/tests/assembled-boot.ts | 15 --- apps/web/tests/built-boot.expected.e2e.ts | 6 +- .../clickable-links-gallery/ui.expected.md | 4 +- .../expected/cordis-history/ui.expected.md | 12 +- .../expected/fork-mid-turn/ui.expected.md | 8 +- .../conversation-expanded.expected.md | 4 +- .../conversation.expected.md | 4 +- .../markdown-cjk-strong/ui.expected.md | 4 +- .../expected/markdown-images/ui.expected.md | 4 +- .../markdown-inline-code-links/ui.expected.md | 4 +- .../expected/math-rendering/ui.expected.md | 4 +- .../reference-composer/order.expected.md | 4 +- .../skill-user-invoke/ui-expanded.expected.md | 4 +- .../expected/skill-user-invoke/ui.expected.md | 4 +- .../stats-paged-history/ui.expected.md | 112 +++++++++--------- .../expected/steer-all/mid-steer.expected.md | 4 +- .../steer-all/settled-expanded.expected.md | 4 +- .../expected/steer-all/settled.expected.md | 4 +- apps/web/tests/goal-multi-turn-actions.e2e.ts | 29 ----- apps/web/tests/idle-submission-handoff.e2e.ts | 12 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/live-interactions.e2e.ts | 2 +- apps/web/tests/reasoning-preview.e2e.ts | 47 +------- apps/web/tests/seeded-history.e2e.ts | 1 + apps/web/tests/shimmer.ts | 66 ----------- .../mid-stream.expected.md | 4 +- apps/web/tests/support.ts | 8 +- apps/web/tests/turn-tail-actions.e2e.ts | 92 +------------- apps/web/tests/workflow-run.e2e.ts | 8 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- packages/client/ui-chat/package.json | 7 +- .../src/client/chat/ChatGroupSeat.module.css | 25 ++-- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 8 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 - .../src/client/chat/ChatView.module.css | 71 +---------- .../ui-chat/src/client/chat/ChatView.tsx | 2 - .../client/chat/GenericCommandCard.module.css | 12 ++ .../src/client/chat/GenericCommandCard.tsx | 10 +- .../src/client/chat/MessageItem.module.css | 64 +++++++--- .../ui-chat/src/client/chat/MessageItem.tsx | 7 +- .../src/client/chat/ReasoningRow.module.css | 33 ++++++ .../ui-chat/src/client/chat/ReasoningRow.tsx | 10 +- .../ui-chat/src/client/chat/RunningStatus.tsx | 82 ------------- .../chat/TurnProcessNodeView.module.css | 14 ++- .../src/client/chat/TurnProcessNodeView.tsx | 40 ++++--- .../chat/TurnTriggerNodeView.module.css | 18 ++- .../src/client/chat/lottie-player.d.ts | 6 - .../src/client/chat/whale-tail.lottie.json | 1 - .../src/client/contract/turn-process.ts | 4 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 8 +- .../client/conversation-nodes/README.zh.md | 8 +- packages/client/ui-chat/src/client/locale.ts | 14 +-- .../ui-chat/tests/chat-view.client.spec.tsx | 87 ++++++-------- .../tests/reasoning-row.client.spec.tsx | 6 +- .../tests/turn-tail-spacing.client.spec.ts | 4 +- packages/client/ui-chat/tsconfig.client.json | 4 +- .../src/client/PresentRow.module.css | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 5 +- packages/client/ui-primitives/README.zh.md | 5 +- .../src/DisclosureRow.module.css | 26 +--- .../ui-primitives/src/DisclosureRow.tsx | 12 +- .../ui-primitives/src/Shimmer.module.css | 88 -------------- packages/client/ui-primitives/src/Shimmer.tsx | 66 ----------- .../ui-primitives/src/TextShimmer.module.css | 27 +++++ .../client/ui-primitives/src/TextShimmer.tsx | 23 ++++ .../client/ui-primitives/src/icons/index.tsx | 6 +- packages/client/ui-primitives/src/index.ts | 4 +- .../tests/shimmer.client.spec.tsx | 41 ------- .../tests/text-shimmer-styles.client.spec.ts | 16 +++ .../tests/text-shimmer.client.spec.tsx | 29 +++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../ui-skill/src/client/SkillRow.module.css | 39 ++++-- .../client/ui-skill/src/client/SkillRow.tsx | 31 ++--- .../ui-skill/tests/skill-row.client.spec.tsx | 6 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 8 +- packages/client/ui-theme/README.zh.md | 8 +- .../ui-theme/src/styles/design-platform.css | 10 -- .../src/styles/gradient-shadow-text.css | 4 +- .../client/ui-theme/src/theme-settings.ts | 2 +- .../ui-theme/tests/boot-theme.client.spec.ts | 4 +- .../client/ui-theme/tests/config.host.spec.ts | 10 +- .../tests/font-size-row.client.spec.tsx | 6 +- .../ui-theme/tests/theme.client.spec.ts | 18 +-- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 4 +- packages/client/ui-tool/README.zh.md | 4 +- .../client/tool/components/ToolRow.module.css | 49 ++++++-- .../src/client/tool/components/ToolRow.tsx | 30 +++-- .../tool/toolviews/bash-sample.module.css | 25 ++-- .../src/client/tool/toolviews/bash-sample.tsx | 24 ++-- .../ui-tool/tests/diff-card.client.spec.tsx | 5 +- .../tests/tool-row-styles.client.spec.ts | 10 +- .../ui-tool/tests/tool-row.client.spec.tsx | 7 +- .../src/client/WorkflowRunPanel.module.css | 16 +-- pnpm-lock.yaml | 18 +-- scripts/rescope-vendor.ts | 1 - scripts/test-dom-environment.ts | 37 +----- .../web/auto-review-denial/ui.expected.md | 8 +- snapshots/web/bash-abort-row/ui.expected.md | 2 +- .../web/changed-files-turn/ui.expected.md | 4 +- .../web/deepseek-messages-chat/ui.expected.md | 4 +- .../web/default-workspace/ui.expected.md | 4 +- .../feedback-command/ack-expanded.expected.md | 4 +- .../web/feedback-command/ack.expected.md | 4 +- .../feedback-release/ack-expanded.expected.md | 4 +- .../web/feedback-release/ack.expected.md | 4 +- .../web/file-upload-round/ui.expected.md | 4 +- .../fresh-round-trip/ui-expanded.expected.md | 4 +- snapshots/web/fresh-round-trip/ui.expected.md | 4 +- .../ui-expanded.expected.md | 8 +- .../goal-multi-turn-actions/ui.expected.md | 9 +- .../reloaded-expanded.expected.md | 4 +- .../web/lifecycle-chrome/reloaded.expected.md | 4 +- .../web/live-interactions/loading.expected.md | 4 +- .../retry-expanded.expected.md | 4 +- .../web/live-interactions/retry.expected.md | 4 +- .../running-draft.expected.md | 4 +- snapshots/web/message-actions/ui.expected.md | 13 +- snapshots/web/minimal-preset/ui.expected.md | 4 +- .../plan-review/approved-expanded.expected.md | 4 +- .../web/plan-review/approved.expected.md | 4 +- snapshots/web/present-svg/ui.expected.md | 4 +- snapshots/web/present/ui.expected.md | 4 +- snapshots/web/ptc-round/ui.expected.md | 4 +- .../answered-expanded.expected.md | 4 +- .../question-composer/answered.expected.md | 4 +- .../question-composer/cancelled.expected.md | 4 +- .../web/queue-actions/collapsed.expected.md | 4 +- .../web/queue-actions/editing.expected.md | 4 +- .../web/queue-actions/layout.expected.md | 4 +- .../web/queue-actions/sending.expected.md | 4 +- snapshots/web/queue-actions/ui.expected.md | 4 +- .../web/queued-image/delivered.expected.md | 8 +- snapshots/web/queued-image/queued.expected.md | 4 +- .../seeded-history/command-row.expected.md | 8 +- .../seeded-history/feedback-row.expected.md | 8 +- .../seeded-history/ui-expanded.expected.md | 8 +- snapshots/web/seeded-history/ui.expected.md | 8 +- snapshots/web/skill-tool-row/ui.expected.md | 4 +- snapshots/web/steering/mid-steer.expected.md | 4 +- .../web/steering/settled-expanded.expected.md | 4 +- snapshots/web/steering/settled.expected.md | 4 +- .../subagent-conversation/nested.expected.md | 4 +- .../sidebar-chat.expected.md | 4 +- .../ui-expanded.expected.md | 8 +- .../web/subagent-conversation/ui.expected.md | 8 +- .../offline-composer.expected.md | 4 +- .../turn-tail-actions/completed.expected.md | 4 +- .../web/turn-tail-actions/focused.expected.md | 4 +- .../web/turn-tail-actions/running.expected.md | 4 +- .../web/turn-tail-actions/settled.expected.md | 5 +- .../usage-expanded.expected.md | 4 +- snapshots/web/web-search-round/ui.expected.md | 4 +- snapshots/web/workflow-run/ui.expected.md | 4 +- tsconfig.host.json | 1 - 163 files changed, 783 insertions(+), 1280 deletions(-) delete mode 100644 apps/web/tests/shimmer.ts delete mode 100644 packages/client/ui-chat/src/client/chat/RunningStatus.tsx delete mode 100644 packages/client/ui-chat/src/client/chat/lottie-player.d.ts delete mode 100644 packages/client/ui-chat/src/client/chat/whale-tail.lottie.json delete mode 100644 packages/client/ui-primitives/src/Shimmer.module.css delete mode 100644 packages/client/ui-primitives/src/Shimmer.tsx create mode 100644 packages/client/ui-primitives/src/TextShimmer.module.css create mode 100644 packages/client/ui-primitives/src/TextShimmer.tsx delete mode 100644 packages/client/ui-primitives/tests/shimmer.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3edabbb639..862299b9a2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -96,7 +96,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`lexical`](https://github.com/facebook/lexical) | MIT | -| [`lottie-web`](https://github.com/airbnb/lottie-web) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 34f67f4a2c..b7ab90a8e2 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -218,7 +218,6 @@ export function installAssembledBootEnv(): void { }) } let fontsDescriptor: PropertyDescriptor | undefined - let canvasContextDescriptor: PropertyDescriptor | undefined beforeEach(() => { fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() }) @@ -234,18 +233,6 @@ export function installAssembledBootEnv(): void { vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => { callback(0) }, 0) as unknown as number) vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) - vi.stubGlobal('matchMedia', (media: string) => Object.assign(new EventTarget(), { - media, matches: false, onchange: null, addListener: () => {}, removeListener: () => {}, - })) - // Lottie's SVG player creates one transparent Canvas placeholder while importing. - canvasContextDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext') - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: (contextId: string) => { - if (contextId !== '2d') throw new Error(`assembled boot: unsupported Canvas context ${contextId}`) - return { fillStyle: '', fillRect: () => {} } - }, - }) }) afterEach(async () => { @@ -276,8 +263,6 @@ export function installAssembledBootEnv(): void { delete ownNavigator.languages delete ownNavigator.language vi.unstubAllGlobals() - if (canvasContextDescriptor === undefined) Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext') - else Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', canvasContextDescriptor) if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') else Object.defineProperty(document, 'fonts', fontsDescriptor) if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index f645214133..7ab01184fc 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -129,8 +129,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write' && row.textContent?.includes('new-demo.txt')) if (writeRow === undefined) throw new Error('fixture write row missing') - expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1) - expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) expect(writeRow.querySelector('[data-diff]')).toBeNull() for (const row of mutationRows) { const toggle = row.querySelector('[data-expandable]') @@ -141,8 +140,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const writeDiff = writeRow.querySelector('[data-diff]') if (writeDiff === null) throw new Error('expanded fixture write diff missing') within(writeDiff).getByText('hello fixture', { exact: true }) - expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1) - expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1) + expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) expect(writeDiff.textContent).not.toContain('+1 -0') // The web render intent reaches the assembled boot graph: the fixture's diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index a3494849d0..25c6502dad 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Wrote files, called tools, searched code, etc." [expanded] - button "Write site/report.html +1 -0": - text: Write diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 3de6c560dd..45381454fa 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -11,8 +11,8 @@ - button "Jump to turn 3" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Inspect plugins 0 dynamic plugins" @@ -42,8 +42,8 @@ - button "Plugin status updated 9/1 {{clock}}": - text: Plugin status updated - time: 9/1 {{clock}} -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: The Cordis Plugin is running. - button "Copy" - button "Good response" @@ -51,8 +51,8 @@ - button "Branch into a new conversation" - text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools" [expanded] - text: Stop Cordis Plugin snap-1 - button "Inspect" diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 4cecf35241..98f72fecb8 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -1,11 +1,11 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 4abdc53e16..5f580db1a6 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -19,8 +19,8 @@ - button "GitHub event received {{clock}}": - text: GitHub event received - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 4abdc53e16..5f580db1a6 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -19,8 +19,8 @@ - button "GitHub event received {{clock}}": - text: GitHub event received - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 2eda5d3afb..e113cb6b3d 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index d6bea9a4f8..31dee5c21a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Markdown images" [level=2] - paragraph: - 'button "View full image: Remote test image"': diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index c9d932b478..bf25c8d4ae 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -6,8 +6,8 @@ - tab "Trajectory" - text: Show the local preview URL. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 3d4b08ccd1..3fb277318a 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Render this mathematical proof. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 779c3e1f20..589d2954d1 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 09a6526be6..930e9eb385 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -10,8 +10,8 @@ - button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 09a6526be6..930e9eb385 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -10,8 +10,8 @@ - button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index e6a96742a4..5af4333df1 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -36,8 +36,8 @@ - button "Jump to turn 28" - text: m1 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r1 - button "Copy" - button "Good response" @@ -45,8 +45,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m2 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r2 - button "Copy" - button "Good response" @@ -54,8 +54,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m3 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r3 - button "Copy" - button "Good response" @@ -63,8 +63,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m4 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r4 - button "Copy" - button "Good response" @@ -72,8 +72,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m5 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r5 - button "Copy" - button "Good response" @@ -81,8 +81,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m6 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r6 - button "Copy" - button "Good response" @@ -90,8 +90,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m7 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r7 - button "Copy" - button "Good response" @@ -99,8 +99,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m8 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r8 - button "Copy" - button "Good response" @@ -108,8 +108,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m9 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r9 - button "Copy" - button "Good response" @@ -117,8 +117,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m10 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r10 - button "Copy" - button "Good response" @@ -126,8 +126,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m11 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r11 - button "Copy" - button "Good response" @@ -135,8 +135,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m12 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r12 - button "Copy" - button "Good response" @@ -144,8 +144,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m13 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r13 - button "Copy" - button "Good response" @@ -153,8 +153,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m14 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r14 - button "Copy" - button "Good response" @@ -162,8 +162,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m15 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r15 - button "Copy" - button "Good response" @@ -171,8 +171,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m16 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r16 - button "Copy" - button "Good response" @@ -180,8 +180,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m17 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r17 - button "Copy" - button "Good response" @@ -189,8 +189,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m18 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r18 - button "Copy" - button "Good response" @@ -198,8 +198,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m19 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r19 - button "Copy" - button "Good response" @@ -207,8 +207,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m20 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r20 - button "Copy" - button "Good response" @@ -216,8 +216,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m21 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r21 - button "Copy" - button "Good response" @@ -225,8 +225,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m22 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r22 - button "Copy" - button "Good response" @@ -234,8 +234,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m23 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r23 - button "Copy" - button "Good response" @@ -243,8 +243,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m24 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r24 - button "Copy" - button "Good response" @@ -252,8 +252,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m25 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r25 - button "Copy" - button "Good response" @@ -261,8 +261,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m26 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r26 - button "Copy" - button "Good response" @@ -270,8 +270,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m27 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r27 - button "Copy" - button "Good response" @@ -279,8 +279,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r28 - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 55222e9624..6a14e1ea0f 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -8,13 +8,13 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Analyzing the request" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - text: "Interjection: include the word ORANGE in your final reply." - button "Copy" -- status: Deep diving -- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 5282565384..3e00422aa2 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - button "Asked questions" [expanded] - button "Think" - button "Ask question 1/1 answered" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index c92baa21e1..f419491430 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index be9e5fc341..1361a476d3 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -155,32 +155,6 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) - const trigger = page.locator('[data-turn-trigger]').first() - const process = page.locator('[data-turn-process]').first() - const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')) - await process.click() - try { - for (const fontSize of [14, 17, 24]) { - await scaffold!.ctx.settings.update('ui-theme', { fontSize }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) - .toBe(`${String(fontSize)}px`) - const group = page.locator('[data-process-activity]:visible').first() - const groupFontSize = await group.evaluate(element => getComputedStyle(element).fontSize) - expect(groupFontSize).toBe(`${String(fontSize)}px`) - expect(await process.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize) - expect(await trigger.getByText('Continuing goal', { exact: true }) - .evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize) - expect(await process.evaluate(element => element.getBoundingClientRect().top)) - .toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16) - expect(await group.evaluate(element => element.getBoundingClientRect().top)) - .toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) - } - } finally { - await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) - .toBe(originalFontSize) - await process.click() - } expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( '[data-chat-flow-kind="system-prompt"]', @@ -189,10 +163,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled')))) .toEqual([null, null]) - await page.mouse.move(0, 0) await branchButtons.last().focus() - await page.locator('[data-composer-input]').first().focus() - await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/idle-submission-handoff.e2e.ts b/apps/web/tests/idle-submission-handoff.e2e.ts index 2fb9930fcb..9d27909d4d 100644 --- a/apps/web/tests/idle-submission-handoff.e2e.ts +++ b/apps/web/tests/idle-submission-handoff.e2e.ts @@ -144,10 +144,8 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first if (order === 'transcript-first') { await expect.poll(() => gate.turnStartDelivery()).toBeDefined() - const start = gate.turnStartDelivery()! - if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start') - gate.releaseThrough(start) - await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' }) + gate.releaseThrough(gate.turnStartDelivery()!) + await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) trace.push({ phase: 'turn-start', ...await placement(page) }) await expect.poll(() => gate.admissionDelivery()).toBeDefined() @@ -168,10 +166,8 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first if (order === 'inbox-first') { await expect.poll(() => gate.turnStartDelivery()).toBeDefined() - const start = gate.turnStartDelivery()! - if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start') - gate.releaseThrough(start) - await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' }) + gate.releaseThrough(gate.turnStartDelivery()!) + await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) trace.push({ phase: 'turn-start', ...await placement(page) }) } diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index f06f73ffea..ecedf4ed73 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -344,7 +344,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await reasoningComplete.promise expect(await thinking.getAttribute('data-preview')).toBeNull() expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(await thinking.locator('[data-streaming]:not([inert] *)').isVisible()).toBe(false) + expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false) } observedReasoning.resolve(undefined) return await settled diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index ef9667049a..6592322148 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(), + () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), { timeout: 10_000 }, ).toBe(true) await page.locator('[data-streaming="true"]') diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 7820173e55..9571b51d1c 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -8,7 +8,6 @@ import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts' -import { expectSharedShimmer } from './shimmer.ts' const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}` const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] @@ -50,7 +49,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f const browser = await chromium.launch() try { const page = await newEnglishPage(browser) - const pageConsole = watchConsole(page) + const console = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) await page.setViewportSize({ width: 480, height: 1000 }) @@ -69,32 +68,9 @@ it('shows completed paragraph first lines across blank lines with a right-edge f first.proceed.resolve(undefined) await second.arrived.promise - const preview = reasoning.locator('[data-streaming]:not([inert] *)') + const preview = reasoning.locator('[data-streaming]') await expect.poll(() => preview.textContent()).toBe('First paragraph') expect(await preview.isVisible()).toBe(true) - for (const fontSize of [14, 24]) { - await scaffold.ctx.settings.update('ui-theme', { fontSize }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) - .toBe(`${String(fontSize)}px`) - const geometry = await preview.evaluate((element) => { - const text = element.firstElementChild - const header = element.closest('[data-disclosure-row]')! - const range = document.createRange() - range.selectNodeContents(text!) - return { - fadeStart: element.getBoundingClientRect().right - 48, - textRight: range.getBoundingClientRect().right, - previewRight: element.getBoundingClientRect().right, - headerRight: header.getBoundingClientRect().right, - headerHeight: header.getBoundingClientRect().height, - } - }) - expect(geometry.textRight).toBeLessThanOrEqual(geometry.fadeStart) - expect(geometry.previewRight).toBe(geometry.headerRight) - expect(geometry.headerHeight).toBe(24 + fontSize - 14) - } - await scaffold.ctx.settings.update('ui-theme', { fontSize: 14 }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))).toBe('14px') await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') }) second.proceed.resolve(undefined) @@ -103,30 +79,15 @@ it('shows completed paragraph first lines across blank lines with a right-edge f expect(await preview.getAttribute('data-retained-preview')).toBe('true') expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient') expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) - await expectSharedShimmer(reasoning) - const group = page.locator('[data-process-activity]').filter({ has: page.locator('[data-shimmer="true"]') }) - await expectSharedShimmer(group) - await group.hover() - await expectSharedShimmer(group) expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false') await compareOrRefreshGolden(UI_EXPECTED, await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode()) - await group.click() - expect(await group.getAttribute('aria-expanded')).toBe('false') - await expectSharedShimmer(group) - await page.emulateMedia({ reducedMotion: 'reduce' }) - try { - expect(await group.locator('[inert]').evaluate(element => getComputedStyle(element).display)).toBe('none') - expect(await group.innerText()).toBe('Analyzing the request') - } finally { - await page.emulateMedia({ reducedMotion: null }) - } third.proceed.resolve(undefined) await settled await page.getByText('Done', { exact: true }).waitFor() - expect(pageConsole.pageErrors).toEqual([]) - expect(pageConsole.warnings).toEqual([]) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) } finally { adapter.release() await browser.close() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 5c24765398..8419aae826 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -372,6 +372,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => element.getBoundingClientRect().top) + // Collapsed control row keeps its own 8px margin plus the 8px flow gap. expect(answerTop).toBe(processBottom + 16) expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are diff --git a/apps/web/tests/shimmer.ts b/apps/web/tests/shimmer.ts deleted file mode 100644 index 660b811125..0000000000 --- a/apps/web/tests/shimmer.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Browser geometry for one moving mask over text beside retained row icons. */ -import type { Locator } from 'playwright' -import { expect } from 'vitest' - -/** - * Check glyph alignment and scroll overflow across a running row's shared sweep. - * @param row - visible row containing exactly one active shimmer. - * @returns after restoring the row's animation playback. - */ -export async function expectSharedShimmer(row: Locator): Promise { - const shimmer = row.locator('[data-shimmer="true"]') - expect(await shimmer.count()).toBe(1) - const frames = await shimmer.evaluate((root) => { - const base = root.firstElementChild! - const decoration = root.querySelector(':scope > [inert]')! - const mask = decoration.firstElementChild! - const highlight = mask.firstElementChild! - const animations = root.getAnimations({ subtree: true }) - .filter(animation => animation instanceof CSSAnimation && animation.animationName.includes('dsh-row-shimmer-')) - const original = animations.map(animation => ({ animation, time: animation.currentTime, state: animation.playState })) - try { - return [400, 800, 1200, 1301, 1799, 1801].map((time) => { - for (const animation of animations) { - animation.pause() - animation.currentTime = time - } - const originals = [...base.querySelectorAll('svg, span, button')] - const copies = [...highlight.querySelectorAll('svg, span, button')] - return { - animations: animations.length, - mask: getComputedStyle(mask).maskImage, - hidden: decoration.getAttribute('aria-hidden'), - maskLeft: mask.getBoundingClientRect().left, - width: root.getBoundingClientRect().width, - clientWidth: root.clientWidth, - scrollWidth: root.scrollWidth, - icons: base.querySelectorAll('svg').length, - copyCount: copies.length, - baseCount: originals.length, - glyphOffsets: originals.map((element, index) => { - const a = element.getBoundingClientRect() - const b = copies[index]!.getBoundingClientRect() - return [a.left - b.left, a.top - b.top, a.width - b.width, a.height - b.height] - }).flat(), - } - }) - } finally { - for (const { animation, time, state } of original) { - animation.currentTime = time - if (state === 'running') animation.play() - } - } - }) - for (const frame of frames) { - expect(frame.animations).toBe(2) - expect(frame.mask).toContain('linear-gradient') - expect(frame.hidden).toBe('true') - expect(frame.width).toBeGreaterThan(0) - expect(frame.scrollWidth).toBe(frame.clientWidth) - expect(frame.icons).toBe(0) - expect(frame.copyCount).toBe(frame.baseCount) - expect(frame.glyphOffsets.every(offset => Math.abs(offset) < 0.1)).toBe(true) - } - expect(frames[0]!.maskLeft).toBeLessThan(frames[1]!.maskLeft) - expect(frames[1]!.maskLeft).toBeLessThan(frames[2]!.maskLeft) -} diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 743e96c4f8..a2443a2e47 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -8,12 +8,12 @@ - tab "Trajectory" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - text: ts - button "Wrap lines" [pressed] - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" -- status: Deep diving -- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 0918814111..884f160418 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -85,7 +85,7 @@ export async function expandTurnProcesses(page: Page): Promise { } /** - * Expand the settled Turn process and secondary group containing a hidden descendant. + * Expand the Turn process and secondary group containing a hidden descendant. * @param page - page containing the Chat view. * @param target - descendant whose outer process disclosures should open. */ @@ -94,10 +94,8 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn) if (turn !== undefined) { const control = page.locator(`[data-turn-process="${turn}"]`) - if (await control.count() > 0) { - await control.waitFor({ state: 'visible', timeout: 10_000 }) - if (await control.getAttribute('aria-expanded') === 'false') await control.click() - } + await control.waitFor({ state: 'visible', timeout: 10_000 }) + if (await control.getAttribute('aria-expanded') === 'false') await control.click() } const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]') const header = group.locator('[data-process-activity]').first() diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index d4bbccbc65..7a7bfe9293 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -13,7 +13,6 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { expectSharedShimmer } from './shimmer.ts' import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -145,84 +144,16 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) const runningProcess = page.locator('[data-turn-process]') - expect(await runningProcess.count()).toBe(0) + expect(await runningProcess.count()).toBe(1) + expect(await runningProcess.isDisabled()).toBe(true) + expect(await runningProcess.getAttribute('aria-expanded')).toBe('true') await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(), + () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), { timeout: 10_000 }, ).toBe(true) await page.locator('[data-streaming="true"]') .getByText('partial', { exact: true }) .waitFor({ timeout: 10_000 }) - const runningStatus = page.locator('[data-chat-running]') - expect(await runningStatus.innerText()).toMatch(/Deep diving for \d+s/) - await expectSharedShimmer(runningStatus) - expect(await runningStatus.evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') - const contentBottom = await page.locator('[data-streaming="true"]').evaluate(element => - element.closest('[data-chat-flow-kind="assistant-step"]')!.getBoundingClientRect().bottom) - expect(await runningStatus.evaluate(element => element.getBoundingClientRect().top)).toBe(contentBottom + 32) - const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')) - try { - for (const fontSize of [14, 17, 24]) { - await scaffold!.ctx.settings.update('ui-theme', { fontSize }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) - .toBe(`${String(fontSize)}px`) - const layout = await runningStatus.evaluate((status) => { - const icon = status.querySelector('svg')! - const label = status.querySelector('[data-shimmer] > span > span')! - const group = document.querySelector('[data-process-activity]')! - const groupIcon = group.querySelector('[data-step-process-icon]')! - return { - iconSize: icon.getBoundingClientRect().width, - groupIconSize: groupIcon.getBoundingClientRect().width, - gap: label.getBoundingClientRect().left - icon.getBoundingClientRect().right, - iconColor: getComputedStyle(icon).color, - textColor: getComputedStyle(label).color, - } - }) - expect(layout.iconSize).toBe(layout.groupIconSize - 2) - expect(layout.gap).toBe(6) - expect(layout.iconColor).toBe(layout.textColor) - } - } finally { - await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) }) - await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))) - .toBe(originalFontSize) - } - const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') - try { - await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) - const colors = await runningStatus.evaluate((element) => { - const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true })! - const rgb = (color: string) => { - context.fillStyle = color - context.fillRect(0, 0, 1, 1) - return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3).map(channel => channel / 255) - } - const luminance = (channels: number[]) => channels - .map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4) - .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0) - const base = rgb(getComputedStyle(element).color) - const sweep = rgb(getComputedStyle(element.querySelector('[data-shimmer] > [inert] > span')!).color) - const max = Math.max(...base) - const min = Math.min(...base) - return { - baseLight: luminance(base), - sweepLight: luminance(sweep), - saturation: (max - min) / (1 - Math.abs(max + min - 1)), - blueSweep: sweep[2]! > sweep[1]! && sweep[1]! > sweep[0]!, - } - }) - expect(colors.saturation).toBeLessThan(0.7) - expect(colors.baseLight).toBeGreaterThan(0.25) - expect(colors.sweepLight).toBeGreaterThan(colors.baseLight * 1.3) - expect(colors.blueSweep).toBe(true) - await expectSharedShimmer(runningStatus) - } finally { - await page.evaluate((value) => { - if (value === null) document.body.removeAttribute('data-ds-dark-theme') - else document.body.setAttribute('data-ds-dark-theme', value) - }, originalDarkTheme) - } // Only the user bubble owns a footer (clock + copy; user bubbles carry no // branch action): the narration is not the answer yet. const copyButtons = page.getByRole('button', { name: 'Copy' }) @@ -239,19 +170,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await settled expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted']) await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 }) - const stopped = page.getByRole('button', { name: 'Stopped', exact: true }) - expect(await stopped.isEnabled()).toBe(true) - expect(await stopped.evaluate(element => getComputedStyle(element).fontSize)).toBe('14px') - expect(await stopped.getAttribute('aria-expanded')).toBe('false') - expect(await runningStatus.count()).toBe(0) - const processGroup = page.locator('[data-process-activity]').first() - expect(await processGroup.isVisible()).toBe(false) - await stopped.click() - expect(await stopped.getAttribute('aria-expanded')).toBe('true') - expect(await processGroup.isVisible()).toBe(true) - await stopped.click() - expect(await stopped.getAttribute('aria-expanded')).toBe('false') - expect(await processGroup.isVisible()).toBe(false) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) await copyButtons.last().focus() @@ -317,7 +235,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { element.closest('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom) const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => element.closest('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top) - expect(answerTop).toBe((processBottom ?? 0) + 16) + expect(answerTop).toBe((processBottom ?? 0) + 8) await process.focus() const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 8c497e0db7..42c5b1d8fc 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -134,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const disclosures = element.querySelectorAll('[data-disclosure-row]') const runHeader = disclosures[0] const phaseHeader = disclosures[1] - const phaseTitle = phaseHeader?.querySelector('[data-shimmer] > span > span') ?? null + const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null const phaseStatus = element.querySelector('[data-phase-status-text]') const originalPhaseTitle = phaseTitle?.textContent ?? '' if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout' @@ -153,10 +153,6 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, phaseTitleRight, phaseStatusLeft, - phaseStatusRight: phaseStatus?.getBoundingClientRect().right ?? 0, - phaseHeaderRight: phaseHeader?.getBoundingClientRect().right ?? 0, - contentGap: phaseTitle?.parentElement === null || phaseTitle?.parentElement === undefined - ? '' : getComputedStyle(phaseTitle.parentElement).columnGap, } }) expect(darkNarrow.clientWidth).toBe(356) @@ -169,8 +165,6 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = expect(darkNarrow.runHeight).toBe(32) expect(darkNarrow.phaseHeight).toBe(32) expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft) - expect(darkNarrow.phaseStatusRight).toBe(darkNarrow.phaseHeaderRight) - expect(darkNarrow.contentGap).toBe('6px') await page.locator('[data-workflow-run]').evaluate((element) => { (element as HTMLElement).style.removeProperty('width') document.body.removeAttribute('data-ds-dark-theme') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 693a7fed3d..01fae81ea8 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 463fd1d0ea3ad97623eabf1c4d57371fd7310927 -README.zh.md: 8e3d4455e74f55c872ec5011f0b8ac59935d3b3b +README.md: cca209016220560b7f39831e11ebb677da515ea9 +README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 463fd1d0ea..cca2090162 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -40,7 +40,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. +Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat. When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. @@ -99,8 +99,6 @@ Each group owns local `useDisclosure` state that survives mode changes while its The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. -Process rows share one text and icon color in every Work details mode: tertiary at rest and secondary on hover, while error and warning text keeps its semantic color. Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [Shimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Non-response rows sit 8px apart; anything beside an Assistant response is 16px away, and a closed Turn control keeps its answer 16px below the summary. Leading icons, disclosure chevrons, and their boxes scale with the content font-size delta. - ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8e3d4455e7..0b97e490f5 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## Chat 隐藏的行 -Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,使用细边框和与周围有区分度的中性底色,其他命令行仍保留在 Chat 中。 +Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -99,8 +99,6 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 -过程行的文字与图标在所有工作过程展示模式下共用一种颜色:默认 tertiary、悬停 secondary,错误与警告文字保留语义色。运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [Shimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。非回复条目之间相隔 8px;与 Assistant 回复相邻的条目相隔 16px,收起的整轮控件与其回答之间保持 16px。前置图标、折叠箭头及其容器随正文字号增量缩放。 - ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index ca98b7dc89..61174be32d 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -82,18 +82,17 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-stats": "workspace:*", "@deepseek-ai/dsh-session-turn-outline": "workspace:*", - "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "lottie-web": "^5.13.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-settings": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css index ad2e0379d2..56687cd84e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -10,7 +10,7 @@ padding: 0; border: 0; background: none; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); font: inherit; font-size: var(--dsh-content-font-size, 14px); text-align: left; @@ -19,30 +19,18 @@ } .title:hover { - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary); } -/* The leading box and its glyph ride the body font-size delta so the icon - keeps its optical share of the row at every Settings size. */ .leading { position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); - color: inherit; -} - -.leading svg { - width: calc(14px + var(--dsh-content-font-delta, 0px)); - height: calc(14px + var(--dsh-content-font-delta, 0px)); -} - -.leading svg[width="16"] { - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); + width: 16px; + height: 16px; + color: var(--dsw-alias-label-tertiary); } .activityIcon, @@ -80,7 +68,7 @@ } .title[aria-expanded="true"] { - padding-bottom: 8px; + padding-bottom: 16px; } .body { @@ -133,6 +121,7 @@ margin-top: var(--dsh-chat-flow-gap, 8px); } .expandedBody { + --dsh-chat-flow-gap: 16px; max-height: none; overflow: visible; scrollbar-gutter: auto; diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 2ca89d0ef2..2944f85c38 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -4,7 +4,7 @@ import { IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, - IconThinkOutlineRegular, Shimmer, ShimmerText, + IconThinkOutlineRegular, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ProcessActivity } from '../contract/process-groups.ts' @@ -120,9 +120,7 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG {open ? : } - - {title} - + {title} ) }) @@ -143,7 +141,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none') const reason = turnLocation?.end?.data.reason.kind const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true - || reason === 'error' + || reason === 'aborted' || reason === 'error' const spec = presentation?.spec const selectStored = useCallback((state: Readonly) => turn === undefined ? undefined : storedTurnProcessEntry(state, turn), [turn]) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 5051844c96..5bb003bdfa 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -153,7 +153,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ data-chat-node-key={routedNode.key} data-chat-group-part={groupPart} data-chat-flow-kind={routedNode.kind} - data-chat-flow-response={groupPart === 'response' || undefined} data-chat-turn={turn} data-turn-process-member={processMember || undefined} data-turn-process-hidden={processHidden || undefined} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index d652100e90..a6b3955f8c 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -67,17 +67,7 @@ hidden and empty Seats do not contribute spacing. */ .column > :not([hidden]):not(.flowItem:empty) ~ :not([hidden]):not(.flowItem:empty) { - margin-top: var(--dsh-chat-flow-gap, 8px); -} - -/* Assistant responses have 16px clearance from adjacent flow items. */ -.flowItem[data-chat-flow-response] { - --dsh-chat-flow-gap: 16px; -} - -.column > [data-chat-flow-response]:not([hidden]):not(:empty) - + :not([hidden]):not(.flowItem:empty):not(.running) { - --dsh-chat-flow-gap: 16px; + margin-top: var(--dsh-chat-flow-gap, 16px); } /* Settled-flow identity boundary. It is neutral until it becomes the natural @@ -86,11 +76,10 @@ min-width: 0; } -/* The Turn header has 16px clearance from its input and following content. */ -.flowItem[data-chat-flow-kind="turn-process"], -.flowItem[data-turn-process-answer], -.column > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:empty):not(.running) { - --dsh-chat-flow-gap: 16px; +/* A closed process reads as one summary immediately followed by its answer. + Expanded process rows return to the ordinary 16px rhythm. */ +.flowItem[data-turn-process-answer] { + --dsh-chat-flow-gap: 8px; } /* Empty Seats retain ordered layout positions for Turn lookup without @@ -112,56 +101,6 @@ line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } -.running { - display: flex; - align-items: center; - justify-content: center; - column-gap: 16px; - --dsh-chat-flow-gap: 32px; - --dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer); - color: var(--dsw-alias-label-deep-diving); - font-size: calc(var(--dsh-content-font-size, 14px) - 2px); - line-height: calc(22px + var(--dsh-content-font-delta, 0px)); -} - -.running::before, -.running::after { - content: ''; - flex: 0 0 32px; - border-top: 0.5px solid var(--dsw-alias-border-l3); -} - -.scroll:has(.running) { - padding-bottom: 24px; -} - -.runningContent { - display: inline-flex; - align-items: center; - min-width: 0; - gap: 6px; -} - -.runningText { - min-width: 0; -} - -.runningNumber { - font-family: var(--ds-font-family-code); - font-variant-numeric: tabular-nums; -} - -.runningIcon { - display: inline-flex; - flex: none; - width: calc(14px + var(--dsh-content-font-delta, 0px)); - height: calc(14px + var(--dsh-content-font-delta, 0px)); -} - -.runningIcon path[stroke] { - stroke: currentColor; -} - .openError { color: var(--dsw-alias-state-error-primary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 49d69a729e..0514068aed 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -18,7 +18,6 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx' import { chatRenderKey } from './render-entry.ts' import { assertNever } from '@deepseek-ai/dsh-util-values' import { TurnNavigator } from './TurnNavigator.tsx' -import { RunningStatus } from './RunningStatus.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -270,7 +269,6 @@ export function ChatView({ t={t} /> - {running && } {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index e05c9ff13c..a4bb6ef0cc 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -7,8 +7,13 @@ flex-shrink: 0; } +.chevron { + color: var(--dsw-alias-label-secondary); +} + .title { font-weight: 400; + transition: color 100ms ease; } .separator { @@ -24,10 +29,17 @@ min-width: 0; overflow: hidden; flex: 1 1 auto; + color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not([data-error]) { + color: var(--dsw-alias-label-primary); } .summary[data-error], diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 769e4977f4..0b0749881b 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo, useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutlineRegular, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' import css from './GenericCommandCard.module.css' @@ -42,12 +42,12 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru const toggle = useCallback(() => { setExpanded(value => !value) }, []) const collapsedContent = useMemo(() => ( <> - + - {summary} + {summary} - ), [state, summary]) + ), [running, state, summary]) const content = useMemo(() => open ?
{body}
: undefined, [body, open, state]) @@ -56,8 +56,10 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru {state === 'running' && {t('row.running')}} {state === 'error' && {t('row.failed')}} - - {status} + + {t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 402a9effc1..f59c04e404 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -9,6 +9,7 @@ } .row { + position: relative; overflow: hidden; } @@ -21,10 +22,35 @@ background: var(--dsw-alias-bg-base); } +.root[data-state='running'] .row::after { + content: ''; + position: absolute; + inset-block: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-reasoning-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-reasoning-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + .leading { flex-shrink: 0; } +.chevron { + color: var(--dsw-alias-label-secondary); +} + .title { font-weight: 400; } @@ -45,6 +71,7 @@ min-width: 0; overflow: hidden; flex: 1 1 auto; + color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap; @@ -75,3 +102,9 @@ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); min-width: 0; } + +@media (prefers-reduced-motion: reduce) { + .root[data-state='running'] .row::after { + animation: none; + } +} diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index dd27a47b5c..74a801d6dd 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,6 +1,6 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ import { memo, useMemo } from 'react' -import { DisclosureRow, IconThinkOutlineRegular, MarkdownText, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' @@ -59,11 +59,9 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese && (running || policy.settledReasoningPreview)) const collapsedContent = useMemo(() => ( <> - + - - {summary} - + {summary} ), [running, summary]) @@ -86,9 +84,9 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese rowClassName={css.row} leadingClassName={css.leading} titleClassName={css.title} + chevronClassName={css.chevron} icon={THINK_ICON} title={t('message.think')} - running={running} open={expanded} expandable expandOnRowClick diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx deleted file mode 100644 index a76c7210b3..0000000000 --- a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** Running Turn clock isolated from the transcript's render cycle. */ -import { memo, useEffect, useRef, useState } from 'react' -import { Shimmer, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives' -import lottie from 'lottie-web/build/player/lottie_light.js' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts' -import whaleTail from './whale-tail.lottie.json' with { type: 'json' } -import a11yCss from './accessibility.module.css' -import css from './ChatView.module.css' - -/** - * Show live elapsed time after the current Turn's content without announcing ticks. - * @param props - Chat timeline selector and localized copy. - * @returns the blue running indicator; mount only while the Session is running. - */ -export const RunningStatus = memo(function RunningStatus({ useChat, t }: Pick) { - const startTime = useChat(({ timeline }) => { - const latest = timeline.turnOrder.at(-1) - const turn = latest === undefined ? undefined : timeline.turns.get(latest) - return turn?.status === 'open' ? turn.start?.time : undefined - }) - const [now, setNow] = useState(Date.now) - useEffect(() => { - if (startTime === undefined) return - setNow(Date.now()) - const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) - return () => { clearInterval(timer) } - }, [startTime]) - const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', { - duration: formatLiveRunDuration(Math.max(1000, now - startTime), t), - }) - return ( -
- {t('chat.deepDiving')} - - - - - {label.split(/(\d+)/).map((part, index) => ( - {part} - ))} - - - -
- ) -}) - -/** Loop once per two text sweeps (3s), with a 0.3s delay; hold the initial pose for reduced motion. */ -function RunningWhaleTail() { - const container = useRef(null) - useEffect(() => { - if (container.current === null) return - const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') - const animation = lottie.loadAnimation({ - container: container.current, - renderer: 'svg', - loop: true, - autoplay: false, - animationData: structuredClone(whaleTail), - rendererSettings: { focusable: false }, - }) - let playTimer: number | undefined - const syncPlayback = () => { - window.clearTimeout(playTimer) - animation.goToAndStop(0, true) - if (!reducedMotion.matches) { - playTimer = window.setTimeout(() => { animation.play() }, 300) - } - } - animation.addEventListener('DOMLoaded', syncPlayback) - reducedMotion.addEventListener('change', syncPlayback) - syncPlayback() - return () => { - window.clearTimeout(playTimer) - reducedMotion.removeEventListener('change', syncPlayback) - animation.removeEventListener('DOMLoaded', syncPlayback) - animation.destroy() - } - }, []) - return )} - - {title} - {(keepContentWhenOpen || !open) && collapsedContent} - + {title} + {(keepContentWhenOpen || !open) && collapsedContent}
{open && children}
diff --git a/packages/client/ui-primitives/src/Shimmer.module.css b/packages/client/ui-primitives/src/Shimmer.module.css deleted file mode 100644 index 5131ecd653..0000000000 --- a/packages/client/ui-primitives/src/Shimmer.module.css +++ /dev/null @@ -1,88 +0,0 @@ -.root { - position: relative; - display: inline-grid; - width: max-content; - max-width: 100%; - min-width: 0; - vertical-align: top; - color: inherit; -} - -.content { - display: flex; - align-items: center; - min-width: 0; -} - -.text { - color: inherit; -} - -.text[data-shimmer-text]::after { - content: attr(data-shimmer-text); -} - -.decoration { - position: absolute; - inset: 0; - /* The moving mask must not enlarge any ancestor's scrollable area. */ - overflow: clip; - pointer-events: none; - user-select: none; -} - -.sweep { - position: absolute; - inset: 0; - overflow: hidden; - color: var(--dsw-alias-label-shimmer); - mask-image: linear-gradient(105deg, transparent 0%, black 40% 60%, transparent 100%); - transform: translateX(-100%); - animation-name: dsh-row-shimmer-sweep; -} - -/* Semantic base colors stay on the real content; the overlay supplies one tint. */ -.sweep * { - color: inherit !important; -} - -.sweep [data-shimmer-decoration] { - background: currentColor; -} - -/* Opposite transforms keep every glyph aligned while their shared mask moves. */ -.highlight { - width: 100%; - height: 100%; - transform: translateX(100%); - animation-name: dsh-row-shimmer-highlight; -} - -.sweep, -.highlight { - animation-duration: 1.5s; - animation-delay: 0.3s; - animation-timing-function: steps(48, end); - animation-iteration-count: infinite; -} - -@keyframes dsh-row-shimmer-sweep { - 0% { transform: translateX(-100%); } - 66.6667%, 100% { transform: translateX(100%); } -} - -@keyframes dsh-row-shimmer-highlight { - 0% { transform: translateX(100%); } - 66.6667%, 100% { transform: translateX(-100%); } -} - -@media (prefers-reduced-motion: reduce) { - .decoration { - display: none; - } - - .sweep, - .highlight { - animation: none; - } -} diff --git a/packages/client/ui-primitives/src/Shimmer.tsx b/packages/client/ui-primitives/src/Shimmer.tsx deleted file mode 100644 index 3c6e02b40d..0000000000 --- a/packages/client/ui-primitives/src/Shimmer.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** One activity highlight across a row's text and separators. */ -import { createContext, memo, useContext, type ReactNode } from 'react' -import clsx from 'clsx' -import css from './Shimmer.module.css' - -const DecorativeCopy = createContext(false) - -/** Presentational content shared by the visible row and its inert highlight. */ -export interface ShimmerProps { - /** Text and layout elements; keep icons outside and use ShimmerText for text. No effects or element ids. */ - children: ReactNode - /** Whether the row's owning operation is running. */ - active: boolean - /** Additional class applied to the retained wrapper. */ - className?: string | undefined - /** Layout class applied equally to the base and decorative content. */ - contentClassName?: string | undefined -} - -/** - * Sweep one highlight across the visible width of a row's text; place icons outside. - * Children also render in an inert, aria-hidden decoration while active; keep - * state and subscriptions above this component and supply presentational nodes. - * The decoration is clipped to the row without extending its scrollable area. - * @param props - row content, activity, and layout classes. - * @returns the stable base row and optional masked decoration. - */ -export const Shimmer = memo(function Shimmer({ children, active, className, contentClassName }: ShimmerProps) { - return ( - - {children} - {/* React 18 emits inert as a string attribute; React 19 requires boolean true. */} - {active && ( - - )} - - ) -}) - -/** Text rendered once for selection and accessibility, with generated decoration. */ -export interface ShimmerTextProps { - /** Visible, already-localized text. */ - children: string - /** Typography and truncation class applied to both copies. */ - className?: string | undefined -} - -/** - * Render row text without duplicating its selectable or accessible content. - * @param props - text and owner styling. - * @returns text in the base row or generated text in its decoration. - */ -export const ShimmerText = memo(function ShimmerText({ children, className }: ShimmerTextProps) { - const decorative = useContext(DecorativeCopy) - return ( - - {decorative ? null : children} - - ) -}) diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css new file mode 100644 index 0000000000..bdc2de9a44 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.module.css @@ -0,0 +1,27 @@ +.root[data-text-shimmer] { + background-image: linear-gradient( + 90deg, + currentColor calc(50% - var(--dsh-text-shimmer-spread)), + color-mix(in oklab, currentColor 50%, transparent), + currentColor calc(50% + var(--dsh-text-shimmer-spread)) + ); + background-position: 100% center; + background-repeat: no-repeat; + background-size: 250% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: dsh-text-shimmer 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite; +} + +@keyframes dsh-text-shimmer { + 66.6667%, 100% { background-position: 0% center; } +} + +@media (prefers-reduced-motion: reduce) { + .root[data-text-shimmer] { + background-image: none; + -webkit-text-fill-color: currentColor; + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx new file mode 100644 index 0000000000..8249626435 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.tsx @@ -0,0 +1,23 @@ +/** Text-only activity animation with a stable span across lifecycle changes. */ +import { memo, useMemo, type CSSProperties } from 'react' +import clsx from 'clsx' +import css from './TextShimmer.module.css' + +/** Text and activity supplied by the owning row. */ +export interface TextShimmerProps { + children: string + active: boolean + className?: string | undefined +} + +/** + * Render text with an optional moving highlight; inactive text keeps the same node. + * @param props - localized text, running state, and owner styling. + * @returns the retained text span. + */ +export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) { + const style = useMemo(() => ({ + '--dsh-text-shimmer-spread': `${children.length * 8}px`, + } as CSSProperties), [children.length]) + return {children} +}) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index cf7a39c381..1629d28a35 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -449,9 +449,9 @@ export const IconEditOutlineMedium = (props: IconProps) => ( const IconThinkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d8f4e1928f..ab82a9f6a5 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -7,8 +7,8 @@ export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' export type { DisclosureRowProps } from './DisclosureRow.tsx' -export { Shimmer, ShimmerText } from './Shimmer.tsx' -export type { ShimmerProps, ShimmerTextProps } from './Shimmer.tsx' +export { TextShimmer } from './TextShimmer.tsx' +export type { TextShimmerProps } from './TextShimmer.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' diff --git a/packages/client/ui-primitives/tests/shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/shimmer.client.spec.tsx deleted file mode 100644 index d2afcf0e71..0000000000 --- a/packages/client/ui-primitives/tests/shimmer.client.spec.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Shimmer, ShimmerText } from '../src/Shimmer.tsx' - -afterEach(cleanup) - -describe('Shimmer', () => { - it('retains real text and controls while one inert decoration covers icons and text', () => { - const open = vi.fn() - const row = (active: boolean, title: string) => ( - - - {title} - - - ) - const view = render(row(true, 'Read')) - const title = view.getByText('Read') - const button = view.getByRole('button', { name: 'file.ts' }) - const decoration = view.container.querySelector('[inert]')! - expect(decoration.getAttribute('aria-hidden')).toBe('true') - expect(decoration.querySelector('svg')).not.toBeNull() - expect([...decoration.querySelectorAll('[data-shimmer-text]')].map(node => node.getAttribute('data-shimmer-text'))) - .toEqual(['Read', 'file.ts']) - expect(view.container.textContent).toBe('Readfile.ts') - fireEvent.click(button) - expect(open).toHaveBeenCalledOnce() - - view.rerender(row(true, 'Reading')) - expect(view.getByText('Reading')).toBe(title) - expect(view.getByRole('button', { name: 'file.ts' })).toBe(button) - expect(view.container.querySelector('[inert]')).toBe(decoration) - - view.rerender(row(false, 'Read')) - expect(view.getByText('Read')).toBe(title) - expect(view.getByRole('button', { name: 'file.ts' })).toBe(button) - expect(view.container.querySelector('[inert]')).toBeNull() - expect(view.container.querySelectorAll('svg')).toHaveLength(1) - }) -}) diff --git a/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts new file mode 100644 index 0000000000..b416a1705f --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts @@ -0,0 +1,16 @@ +/** CSS activity and reduced-motion rules that jsdom does not execute. */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(new URL('../src/TextShimmer.module.css', import.meta.url), 'utf8') + +describe('TextShimmer styles', () => { + it('animates active text and disables motion and transparent fill for reduced motion', () => { + expect(css).toMatch(/\.root\[data-text-shimmer\]\s*\{[^}]*animation: dsh-text-shimmer/s) + expect(css).toMatch(/@keyframes dsh-text-shimmer\s*\{[^}]*background-position: 0% center/s) + const reduced = /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.root\[data-text-shimmer\]\s*\{([^}]+)\}/.exec(css)?.[1] + expect(reduced).toContain('background-image: none;') + expect(reduced).toContain('-webkit-text-fill-color: currentColor;') + expect(reduced).toContain('animation: none;') + }) +}) diff --git a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx new file mode 100644 index 0000000000..5f6efdf805 --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TextShimmer } from '../src/TextShimmer.tsx' + +afterEach(cleanup) + +describe('TextShimmer', () => { + it('retains its text span across activity, text, and owner-class changes', () => { + const view = render(Run) + const span = view.getByText('Run') + expect(span.tagName).toBe('SPAN') + expect(span.dataset.textShimmer).toBe('true') + expect(span.classList.contains('title')).toBe(true) + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') + + view.rerender(End) + expect(view.getByText('End')).toBe(span) + expect(span.hasAttribute('data-text-shimmer')).toBe(false) + expect(span.classList.contains('title')).toBe(false) + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px') + + view.rerender(Running) + expect(view.getByText('Running')).toBe(span) + expect(span.dataset.textShimmer).toBe('true') + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px') + }) + +}) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 784ab58cab..c0203ad861 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 9e9bc11f76bce8159e9f63a011c686c62db39776 -README.zh.md: 93d0f1ce318c404fcff512168d6092b21b91743e +README.md: 0234ce8fb6c2737ea242ac5590e471c3e4c91834 +README.zh.md: 5b039ec1c5bea818e6396f4896c05b8175beb08a diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 9e9bc11f76..0234ce8fb6 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -35,7 +35,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves The `preparing` stage shows only the skill icon and title, with no requested skill name or expansion. The `start` and `result` stages use the recorded arguments and output described below; preparation does not consult the installed catalog. -A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. The header follows the shared [process-row text and icon colors](../ui-primitives/README.md#component-catalog). Every lifecycle state retains the ordinary skill glyph; running calls share one left-to-right shimmer across the title, separator, and skill name, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. +A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 93d0f1ce31..5b039ec1c5 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" `preparing` 阶段只显示技能图标与标题,不显示请求加载的技能名称,也不能展开。`start` 和 `result` 阶段使用下述记录参数与输出;准备阶段不查询已安装目录。 -收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。标题行遵循共享的[过程行文字与图标颜色](../ui-primitives/README.zh.md#component-catalog)。所有生命周期状态都保留普通 skill 图标;运行中的调用以同一道高光从左到右扫过标题、分隔符与 skill 名称,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。 diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css index 39a73a614c..9fad40a2d0 100644 --- a/packages/client/ui-skill/src/client/SkillRow.module.css +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -12,18 +12,33 @@ align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; - color: var(--dsw-alias-label-tertiary); - transition: color 100ms ease; -} - -.row:hover { - color: var(--dsw-alias-label-secondary); } .row[data-expandable] { cursor: pointer; } +.card[data-state='running'] .row::after, +.card[data-state='preparing'] .row::after { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-skill-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-skill-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + .leading { position: relative; flex: none; @@ -33,7 +48,7 @@ align-items: center; justify-content: center; margin-right: 6px; - color: inherit; + color: var(--dsw-alias-label-tertiary); } .leading svg { @@ -42,7 +57,7 @@ } .chevron { - color: inherit; + color: var(--dsw-alias-label-secondary); } .iconIdle { @@ -71,6 +86,7 @@ flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-secondary); } .separator { @@ -90,6 +106,7 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-tertiary); } .errorSummary { @@ -193,7 +210,11 @@ } @media (prefers-reduced-motion: reduce) { - .row, + .card[data-state='running'] .row::after { + animation: none; + display: none; + } + .iconIdle, .chevronHover, .inspectButton { diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 1deb1b64b2..0800bc7812 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -1,6 +1,6 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { - IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, Shimmer, ShimmerText, + IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StartedToolCallViewProps, ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -85,7 +85,7 @@ function disclosureLeading(open: boolean, expandable: boolean): ReactNode { ) } -/** Accessible running and failure status. */ +/** Visually hidden state copy for the color-only running sweep and error tone. */ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { switch (state) { case 'running': return t('row.running') @@ -95,7 +95,7 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null } /** - * Render one `skill` tool call as a process row and instructions disclosure. + * Render one `skill` tool call as an accent summary and instructions disclosure. * @param props - keyed toolview payload plus the skill locale seat. * @returns the dedicated skill row. */ @@ -104,9 +104,7 @@ export function SkillRow(props: SkillRowProps) {
{props.t('row.preparing')} - - {props.t('row.title')} - + {props.t('row.title')}
return @@ -118,7 +116,6 @@ function StartedSkillRow({ block, inspect, t }: Exclude { setExpanded(value => !value) @@ -143,18 +140,16 @@ function StartedSkillRow({ block, inspect, t }: Exclude - {status !== null ? {status} : null} {leading} - - {t('row.title')} - - - {summary} - - + {status !== null ? {status} : null} + {t('row.title')} + + + {summary} + {open ? (
diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 34b143d0ef..a34b8ff4e9 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -110,7 +110,7 @@ describe('SkillRow', () => { }))} />) const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') - expect(view.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() expect(row.textContent).not.toContain('Check SKILL.md.') fireEvent.click(row) @@ -124,8 +124,8 @@ describe('SkillRow', () => { error: { name: 'InterruptedError', code: 'interrupted' }, }))} />) const stoppedSummary = stoppedView.getByText('skill 加载已中止') - expect(stoppedSummary.parentElement?.className).toContain('stoppedSummary') - expect(stoppedView.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull() + expect(stoppedSummary.className).toContain('stoppedSummary') + expect(stoppedView.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() expect(stoppedView.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() cleanup() diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 6361eb4b7b..046b137c88 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: a2938f0c03d7f6b2c0202e67be1b66dff08516f3 -README.zh.md: d19649e26722f506c3142a095eb82b0a08235626 +README.md: 7248b7230afa28f817bec7c0eb26fd9512fff405 +README.zh.md: 6dc1db064dd9a6754f6c6475dde18ccf1a17841e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index a2938f0c03..7248b7230a 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 24 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. +`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 17 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`. ## Table of Contents @@ -29,7 +29,7 @@ Users switch the color scheme and content font size from two rows in Settings (G ### Appearance and font size -The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 24 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. +The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. ### Registering a theme @@ -51,9 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. `--dsw-alias-label-shimmer` supplies an overlay for the shared text shimmer: black at 40% alpha in the light palette and white at 45% alpha in the dark palette. `--dsw-alias-label-deep-diving` and `--dsw-alias-label-deep-diving-shimmer` supply the blue activity label and sweep; the dark palette uses a lighter, less saturated label with a brighter blue sweep. - -The `--dsw-alias-turn-trigger-*` tokens provide separate resting and hover backgrounds for Turn-trigger notices in each palette. +`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. `brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index d19649e267..6dc1db064d 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 24 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 +`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 17 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 24 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 +插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 ### 注册主题 @@ -51,9 +51,7 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。`--dsw-alias-label-shimmer` 为共享文字扫光提供叠加色:浅色配色使用 40% alpha 的黑色,深色配色使用 45% alpha 的白色。 `--dsw-alias-label-deep-diving` 和 `--dsw-alias-label-deep-diving-shimmer` 提供蓝色活动文本和扫光颜色;深色主题使用更亮、饱和度更低的文字,并以更亮的蓝色扫光。 - -`--dsw-alias-turn-trigger-*` token 分别定义深浅主题下轮次触发通知的常态和悬停底色。 +`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 `brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index a6510f2951..8f447398fc 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -211,8 +211,6 @@ body { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75); --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); - --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950)); - --dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 35%, var(--dsw-static-blue-950)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); @@ -220,7 +218,6 @@ body { --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00); --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700); - --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 40%, transparent); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600); --dsw-alias-link: var(--dsw-static-deepseek-500); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100); @@ -257,8 +254,6 @@ body { --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100); --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850); - --dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block); - --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover); --dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200); --dsw-specific-bubble: var(--dsw-static-deepseek-50); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); @@ -322,8 +317,6 @@ body[data-ds-dark-theme] { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800); --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); - --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400)); - --dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); @@ -331,7 +324,6 @@ body[data-ds-dark-theme] { --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800); --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300); - --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400); --dsw-alias-link: var(--dsw-static-deepseek-400); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800); @@ -367,8 +359,6 @@ body[data-ds-dark-theme] { --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900); --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750); - --dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover); - --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active); --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750); --dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750); --dsw-specific-bubble: var(--dsw-static-neutral-bluish-850); diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index ae57254ad6..b6dc4db464 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -45,11 +45,11 @@ body[data-ds-dark-theme] { /* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素; 重新从 Figma 导出时需要保留这一缩放。 */ /* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter - 写在 body 上(整数 px,12–24,默认 14)。标题与正文变体按同一像素增量 + 写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量 (--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash 行的标题与摘要、markdown 表格、流内次级说明等)统一读 --dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为 - 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15、24→22)。式中 + 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15)。式中 max(13px,·) 的作用是让低设置取 −1 而非 −2,不是结果下限——12 设置下 层级触底 11px。small、code 属于密集次级文本,保持固定。 */ body { diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index a9202ffa1b..11f8789d6c 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -24,7 +24,7 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system' export const FONT_SIZE_MIN = 12 /** Largest accepted content font size (px). */ -export const FONT_SIZE_MAX = 24 +export const FONT_SIZE_MAX = 17 /** Content font size when the user-settings document has no override (px). */ export const DEFAULT_FONT_SIZE = 14 diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index 574a54dd13..6a0141ea87 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -77,8 +77,8 @@ describe('theme bootstrap row', () => { it('writes the durable content font size and defaults it to 14px', () => { mockSystemDark(false) - executeBootstrap('light', 24) - expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('24px') + executeBootstrap('light', 17) + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('17px') executeBootstrap('light') expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('14px') }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 8123a71b31..a3309b9887 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -28,11 +28,11 @@ describe('ui-theme host', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration expect(plainConfig(configuration.fiber.config)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14 }) - await configuration.update({ preference: 'dark', fontSize: 24 }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 24 }) + await configuration.update({ preference: 'dark', fontSize: 16 }) + expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 16 }) await expect(configuration.update({ preference: 'sepia' })).rejects.toThrow() await expect(configuration.update({ fontSize: 11 })).rejects.toThrow() - await expect(configuration.update({ fontSize: 25 })).rejects.toThrow() + await expect(configuration.update({ fontSize: 18 })).rejects.toThrow() await fiber.dispose() }) @@ -51,10 +51,10 @@ describe('ui-theme host', () => { expect(rowText(rows[0])).toContain('@media(prefers-color-scheme:dark)') expect(rowText(rows[1])).toContain('const preference = "system"') expect(rowText(rows[1])).toContain('"14px"') - await configuration.update({ preference: 'dark', fontSize: 24 }) + await configuration.update({ preference: 'dark', fontSize: 17 }) expect(rowText(collect(ctx)[0])).toContain('color-scheme:dark') expect(rowText(collect(ctx)[1])).toContain('const preference = "dark"') - expect(rowText(collect(ctx)[1])).toContain('"24px"') + expect(rowText(collect(ctx)[1])).toContain('"17px"') await fiber.dispose() expect(collect(ctx)).toEqual([{ kind: 'script', placement: 'head', text: 'window.afterTheme=true' }]) }) diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index bc8f33062e..f75138b44a 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -87,11 +87,7 @@ describe('FontSizeRow', () => { }) it('disables the outward arrow at each bound', () => { - const b = mount(23) - fireEvent.click(arrow('Increase font size')) - expect(b.setFontSize).toHaveBeenCalledWith(24) - act(() => { b.store.actions.sync(24, 1) }) - expect(screen.getByText('24')).toBeDefined() + mount(17) expect(arrow('Increase font size').disabled).toBe(true) expect(arrow('Decrease font size').disabled).toBe(false) cleanup() diff --git a/packages/client/ui-theme/tests/theme.client.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts index d00b1d5b66..a50ed3e7e2 100644 --- a/packages/client/ui-theme/tests/theme.client.spec.ts +++ b/packages/client/ui-theme/tests/theme.client.spec.ts @@ -36,10 +36,10 @@ describe('ThemeRuntime', () => { it('seeds the initial font size from the boot-script body variable, ignoring junk', () => { // The Host boot script writes the durable size on body before any plugin // runs; the first snapshot must match it so activation never flashes 14. - document.body.style.setProperty('--dsh-content-font-size', '24px') + document.body.style.setProperty('--dsh-content-font-size', '16px') try { - expect(make().theme.getTheme().fontSize).toBe(24) - document.body.style.setProperty('--dsh-content-font-size', '25px') + expect(make().theme.getTheme().fontSize).toBe(16) + document.body.style.setProperty('--dsh-content-font-size', '99px') expect(make().theme.getTheme().fontSize).toBe(14) } finally { document.body.style.removeProperty('--dsh-content-font-size') @@ -48,19 +48,19 @@ describe('ThemeRuntime', () => { it('setFontSize switches, writes through the scope, and republishes; same value is a no-op', () => { const { theme, events, host } = make() - theme.setFontSize(24) - expect(theme.getTheme().fontSize).toBe(24) - expect(host.set).toHaveBeenCalledWith('fontSize', 24) + theme.setFontSize(17) + expect(theme.getTheme().fontSize).toBe(17) + expect(host.set).toHaveBeenCalledWith('fontSize', 17) expect(events).toHaveLength(1) - theme.setFontSize(24) + theme.setFontSize(17) expect(events).toHaveLength(1) expect(host.set).toHaveBeenCalledOnce() }) it('rejects out-of-range and fractional font sizes', () => { const { theme, events, host } = make() - for (const px of [11, 25, 14.5, Number.NaN]) { - expect(() => { theme.setFontSize(px) }).toThrow('outside 12..24') + for (const px of [11, 18, 14.5, Number.NaN]) { + expect(() => { theme.setFontSize(px) }).toThrow('outside 12..17') } expect(events).toHaveLength(0) expect(host.set).not.toHaveBeenCalled() diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 358fa4458f..af3b5ca741 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 1fcfa9ffda84b5e0bf76a46accbe486cfb9e9d04 -README.zh.md: 2806fa47ae4d8e48215bce9ca178984839633421 +README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb +README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 1fcfa9ffda..a00ed8e8c7 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -49,7 +49,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`. -This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Tool headers follow the shared [process-row colors](../ui-primitives/README.md#component-catalog) for names, summaries, file paths, ordinary counts, glyphs, and chevrons in every Work details mode, whether details are collapsed, expanded, or unavailable; error and warning summaries keep their semantic colors on hover. Running atomic rows share one left-to-right shimmer across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -72,7 +72,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. -Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. +Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 2806fa47ae..c4a6f6183b 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -49,7 +49,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 -本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。工具标题行的名称、摘要、文件路径、普通数量、图标与箭头在所有工作过程展示模式下均遵循共享的[过程行颜色](../ui-primitives/README.zh.md#component-catalog),无论详情是收起、展开还是不可展开;错误和警告摘要在悬停时保留语义色。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -72,7 +72,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调 每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 -Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 +Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index a33507500d..5e54eeb7e5 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -15,12 +15,28 @@ flex-shrink: 0; } +/* Cordis lifecycle tools retain their generic row mechanics while carrying a + shared product accent and tool-owned action title. */ +.root[data-tool^='cordis_'] .leading, +.root[data-tool^='cordis_'] .title { + color: var(--dsw-alias-state-business-primary); +} + .root[data-tool^='cordis_'] .title { font-weight: 500; } +.root[data-tool^='cordis_'] .sep { + background: var(--dsw-alias-state-business-primary); +} + +.chevron { + color: var(--dsw-alias-label-secondary); +} + .title { font-weight: 400; + transition: color 100ms ease; } .sep { @@ -40,6 +56,8 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; } /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose @@ -53,29 +71,31 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not(.errorSummary):not(.stoppedSummary), +.row:hover .summarySuffix { + color: var(--dsw-alias-label-primary); } /* Diff-row +/- totals: the code font, matching the diff body the numbers summarize; a wider gap keeps the digits from reading as part of the path. Two px under the secondary tier (still riding the axis): mono digits read - optically larger than the sans path at the same size. The + optically larger than the sans path at the same size. Caption, one step + dimmer than the suffix's tertiary, so the digits stay behind the path. The half-pixel nudge closes the baseline gap the size difference leaves under the row's box-centering (a transform, so flex layout is untouched). */ .diffStat { margin-left: 10px; font-family: var(--ds-font-family-code); font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px); + color: var(--dsw-alias-label-caption); transform: translateY(0.5px); } -.row:is(:hover, [aria-expanded='true']) .diffAdded { - color: var(--dsw-alias-state-success-primary); -} - -.row:is(:hover, [aria-expanded='true']) .diffRemoved { - color: var(--dsw-alias-state-error-primary); -} - /* File-tool path: same type as .summary, with a persistent link affordance. Dotted dimmed underline: visible enough to say "clickable", light enough that a long path doesn't read as one heavy rule under the row. Shrink-to-fit @@ -95,12 +115,19 @@ text-align: left; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: inherit; + color: var(--dsw-alias-label-secondary); text-decoration: underline dotted; - text-decoration-color: currentColor; + text-decoration-color: var(--dsw-alias-label-tertiary); text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: pointer; + transition: color 100ms ease; +} + +.row:hover .fileLink, +.fileLink:hover { + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 8855a32105..618344b4c7 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useMemo, type KeyboardEvent, type MouseEvent, type R import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, - TerminalBlock, ShimmerText, WebBlock, + TerminalBlock, TextShimmer, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -55,7 +55,6 @@ export interface ToolRowProps { errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ terminal?: TerminalCardModel | null | undefined - /** Diff card with inline totals colored on header hover and while expanded. */ diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined /** @@ -176,7 +175,11 @@ export const ToolRow = memo(function ToolRow({ const summaryText = failureLine ?? normalSummary // The tool row keeps the diff's +/- totals visible while its body is collapsed. // An explicit summarySuffix overrides the diff totals. - const diffStat = useMemo(() => diffBody === null ? null : diffTotals(diffBody.card.diffs), [diffBody]) + const diffStat = useMemo(() => { + if (diffBody === null) return null + const { added, removed } = diffTotals(diffBody.card.diffs) + return `+${added} -${removed}` + }, [diffBody]) const settledWithCue = state === 'error' || state === 'stopped' const suffix = settledWithCue ? null : summarySuffix ?? diffStat const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue @@ -200,7 +203,7 @@ export const ToolRow = memo(function ToolRow({ /* An empty summary drops the separator with it (a row that is only its title shows no trailing dot). */ <> - + {openFile !== undefined ? ( ) : ( - {summaryText} + {summaryText} )} - {suffix !== null && (typeof suffix === 'string' - ? {suffix} - : ( - - {`+${suffix.added}`} - {' '} - {`-${suffix.removed}`} - - ))} + {suffix !== null && ( + {suffix} + )} - ), [fileLinkKeyDown, openFile, state, suffix, summaryText]) + ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) const expandedContent = useMemo(() => open ? (
{askQuestionBody !== null @@ -343,6 +340,7 @@ export const ToolRow = memo(function ToolRow({ rowClassName={css.row} leadingClassName={css.leading} titleClassName={css.title} + chevronClassName={css.chevron} icon={icon} title={title} running={running} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index 007219766c..9ef5b4b829 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -89,18 +89,6 @@ align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; - color: var(--dsw-alias-label-tertiary); - transition: color 100ms ease; -} - -.root:hover { - color: var(--dsw-alias-label-secondary); -} - -@media (prefers-reduced-motion: reduce) { - .root { - transition: none; - } } .leading { @@ -112,7 +100,7 @@ align-items: center; justify-content: center; margin-right: 6px; - color: inherit; + color: var(--dsw-alias-label-tertiary); } /* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides @@ -127,7 +115,7 @@ } .chevron { - color: inherit; + color: var(--dsw-alias-label-secondary); } /* Hover preview on the expandable row: the idle icon crossfades (100ms) into @@ -158,6 +146,8 @@ flex: none; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-secondary); + transition: color 100ms ease; } .sep { @@ -177,6 +167,13 @@ white-space: nowrap; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.root:hover .title, +.root:hover .summary:not(.errorSummary):not(.stoppedSummary) { + color: var(--dsw-alias-label-primary); } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 68e18b8aee..47ae2d1b6d 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, - TerminalBlock, Shimmer, ShimmerText, + TerminalBlock, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -107,19 +107,17 @@ const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId onClick={expandable ? toggleExpand : undefined} onKeyDown={expandable ? toggleFromKeyboard : undefined} > - {status !== null && {status}} {leading} - - {t(model.titleKey)} - - - {settlementLine ?? normalSummary} - - + {status !== null && {status}} + {t(model.titleKey)} + + + {settlementLine ?? normalSummary} +
{open && (
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 7ebc5265a3..3f9282b29f 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -245,10 +245,9 @@ describe('FileMutationRow diff card', () => { meta: { diffs: [] }, }), 'write')} />) // The collapsed row already carries the card's +/- totals beside the path. - expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toContain('+1 -0') + expect(view.getByText('+1 -0')).toBeTruthy() toggleRow(view) - expect(view.getAllByText('+1')).toHaveLength(1) - expect(view.getAllByText('-0')).toHaveLength(1) + expect(view.getAllByText('+1 -0')).toHaveLength(1) expect(view.getByText('hello fixture')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index 399c588713..db05c7f769 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -25,15 +25,9 @@ describe('ToolRow.module.css summary line', () => { it.each([ ['ToolRow', '../src/client/tool/components/ToolRow.module.css'], ['BashRow', '../src/client/tool/toolviews/bash-sample.module.css'], - ] as const)('%s leaves row text color to the shared disclosure header', (_name, path) => { - // The header supplies tertiary at rest and secondary on hover; a tool sheet - // that restates a row text color, or brightens the row text on hover, would - // pin one state against the shared process-row colors. The Inspect button - // keeps its own solid hover fill and is not row text. + ] as const)('%s keeps stopped and error summaries outside hover darkening', (_name, path) => { const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') - .replace(/\/\*[\s\S]*?\*\//g, ' ') - expect(sheet).not.toMatch(/\.(title|summary|summarySuffix)\s*\{[^{}]*\bcolor:/) - expect(sheet).not.toMatch(/:hover\s+\.(title|summary|summarySuffix)\b/) + expect(sheet).toContain('.summary:not(.errorSummary):not(.stoppedSummary)') }) it('keeps the summary suffix on one line and unshrunk', () => { diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 5f9c210e69..f9338cac00 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -309,11 +309,10 @@ describe('ToolRow', () => { newText: 'start\nsecond\nthird\nnew\nfourth\nfifth\nend', }] }, }} />) - expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toContain('+1 -1') + expect(view.getByText('+1 -1')).toBeTruthy() expect(view.container.querySelector('[data-diff]')).toBeNull() fireEvent.click(view.getByRole('button')) - expect(view.getAllByText('+1')).toHaveLength(1) - expect(view.getAllByText('-1')).toHaveLength(1) + expect(view.getAllByText('+1 -1')).toHaveLength(1) expect(view.getAllByText('start')).toHaveLength(1) expect(view.getAllByText('end')).toHaveLength(1) expect(view.getByText('old', { exact: true })).toBeTruthy() @@ -345,7 +344,7 @@ describe('ToolRow', () => { it('keeps the business icon across running and error states', () => { const runningView = render() - expect(runningView.container.querySelector('[data-testid="tool-icon"]:not([inert] *)')).not.toBeNull() + expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() expect(errorView.container.querySelector('[data-testid="tool-icon"]')).not.toBeNull() diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 270efc6c24..190f136f70 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -29,14 +29,14 @@ align-items: center; justify-content: center; margin-right: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); } .runTitle { overflow: hidden; flex: none; max-width: 42%; - color: inherit; + color: var(--dsw-alias-label-secondary); font-size: var(--dsh-content-font-size-secondary, 13px); font-weight: 510; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); @@ -48,7 +48,7 @@ overflow: hidden; flex: 1; min-width: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; @@ -65,7 +65,7 @@ font-size: 11px; font-weight: 510; line-height: 16px; - color: inherit; + color: var(--dsw-alias-label-secondary); white-space: nowrap; } @@ -93,7 +93,7 @@ align-items: center; justify-content: center; margin-right: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); } .phaseTitle { @@ -101,7 +101,7 @@ flex: 0 1 auto; min-width: 0; max-width: 42%; - color: inherit; + color: var(--dsw-alias-label-secondary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; @@ -112,7 +112,7 @@ overflow: hidden; flex: 1; min-width: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; @@ -125,7 +125,7 @@ /* Column width scales with the text (~132/13 ≈ 10px per font-size px) so larger sizes keep the same status text visible instead of truncating. */ width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10); - color: inherit; + color: var(--dsw-alias-label-secondary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e7d7b3a45..9d834a613b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3193,9 +3193,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - lottie-web: - specifier: ^5.13.0 - version: 5.13.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -9185,7 +9182,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -19366,9 +19363,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lottie-web@5.13.0: - resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} - lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -22064,14 +22058,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(patch_hash=b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f)(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -24813,7 +24807,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -26243,7 +26237,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@9.4.0): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) @@ -26753,8 +26747,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - lottie-web@5.13.0: {} - lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 793df0ad81..2f724032d1 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -135,7 +135,6 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] }, - { file: 'packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] }, { file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] }, ] diff --git a/scripts/test-dom-environment.ts b/scripts/test-dom-environment.ts index 6991a6854e..12c8895735 100644 --- a/scripts/test-dom-environment.ts +++ b/scripts/test-dom-environment.ts @@ -1,23 +1,8 @@ /** Initial resize delivery from jsdom's modeled offsets; geometry/timing suites supply their own observers. */ import { afterAll, beforeEach } from 'vitest' -// The SVG animation player initializes a transparent 1px Canvas placeholder at import time. -const canvasPrototype = typeof HTMLCanvasElement === 'undefined' ? undefined : HTMLCanvasElement.prototype -const originalCanvasContext = canvasPrototype === undefined ? undefined : Object.getOwnPropertyDescriptor(canvasPrototype, 'getContext') -if (canvasPrototype !== undefined) { - Object.defineProperty(canvasPrototype, 'getContext', { - configurable: true, - value: (contextId: string) => { - if (contextId !== '2d') throw new Error(`test DOM: unsupported Canvas context ${contextId}`) - return { fillStyle: '', fillRect: () => {} } - }, - }) -} - const original = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver') let installed = false -const originalMatchMedia = typeof window === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(window, 'matchMedia') -let installedMatchMedia = false class TestResizeObserver implements ResizeObserver { private readonly targets = new Set() @@ -42,13 +27,6 @@ class TestResizeObserver implements ResizeObserver { } beforeEach(() => { - if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { - const matchMedia: Window['matchMedia'] = media => Object.assign(new EventTarget(), { - media, matches: false, onchange: null, addListener: () => {}, removeListener: () => {}, - }) - Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: matchMedia }) - installedMatchMedia = true - } if (typeof document !== 'undefined' && typeof ResizeObserver === 'undefined') { // Establish the per-environment default, so vi.unstubAllGlobals restores it. Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, writable: true, value: TestResizeObserver }) @@ -57,16 +35,7 @@ beforeEach(() => { }) afterAll(() => { - if (canvasPrototype !== undefined) { - if (originalCanvasContext === undefined) Reflect.deleteProperty(canvasPrototype, 'getContext') - else Object.defineProperty(canvasPrototype, 'getContext', originalCanvasContext) - } - if (installed) { - if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver') - else Object.defineProperty(globalThis, 'ResizeObserver', original) - } - if (installedMatchMedia) { - if (originalMatchMedia === undefined) Reflect.deleteProperty(window, 'matchMedia') - else Object.defineProperty(window, 'matchMedia', originalMatchMedia) - } + if (!installed) return + if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver') + else Object.defineProperty(globalThis, 'ResizeObserver', original) }) diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index d3172476e3..1ceeeb4f1e 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -9,8 +9,8 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" @@ -37,8 +37,8 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" [expanded] diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index e7f584b73c..c74b1b8193 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -8,7 +8,7 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy" - status: Stopped -- button "Stopped" [expanded] +- button "Stopped" [disabled] [expanded] - button "Ran commands" [expanded] - 'button "Failed Bash Error: tool call aborted" [expanded]' - text: "IN { \"command\": \"node -e \\\"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 2f1d34dc98..7500599d2e 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -1,7 +1,7 @@ - text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} - button "复制" -- status: 已完成 -- button "已完成,用时 {{duration}}" [expanded] +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] - button "执行了命令,已写入文件,已读取文件等" [expanded] - button "思考" - button "读取 intro.md": diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index 56f27b62ba..021809ac0c 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -8,8 +8,8 @@ - tab "轨迹" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" -- status: 已完成 -- button "已完成,用时 {{duration}}" [disabled] +- status: 已完成工作 +- button "用时 {{duration}}" [disabled] - paragraph: MESSAGES_WEB_READY - button "复制" - button "好的回答" diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md index 9f26daff62..27cdaa5150 100644 --- a/snapshots/web/default-workspace/ui.expected.md +++ b/snapshots/web/default-workspace/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 102788fbbe..f9d619e382 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 4f55e3e95f..716f3bd304 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 102788fbbe..f9d619e382 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 4f55e3e95f..716f3bd304 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index ff7effe13d..0c60a10a69 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -11,8 +11,8 @@ - img "reference-1.png" - text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: UPLOAD_ROUND_OK - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index de867a8745..0c4d334df3 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Think" - button "Bash Echo the test string" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 9f26daff62..27cdaa5150 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 00385a4c9b..067f2ece45 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -14,8 +14,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Ran commands and updated the plan" [expanded] - button "Think" - button "Bash List package directories deterministically" @@ -51,8 +51,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Think" - button "Bash Randomly select another package with Python" diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 505a067cbb..d7d9ad4839 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -14,8 +14,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -38,8 +38,8 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: @@ -94,6 +94,7 @@ - button "Good response" - button "Bad response" - button "Branch into a new conversation" +- tooltip "Branch into a new conversation" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 8bf5e6fb79..761eb41613 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: LIGHTHOUSE diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 6f51412377..f6a61355d8 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 5e83a173bc..7976fcca1c 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 1fc7f0416b..ad8e9f8f2b 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - group: - status: Retried model request (1/5) · {{duration}} - button "Analysis completed" [expanded] diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index abc388a29c..33c058c865 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 9e5231c18f..1bdf81ba91 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Add files or run commands" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 0eee19f56e..ed11835a6c 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -13,15 +13,18 @@ - button "Copy" - tooltip "Copy" - status: Stopped -- button "Stopped" +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" +- paragraph: I will read both files before answering. +- button "Read files" - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" [disabled] - text: Available only on the last message of a completed turn {{clock}} Now give the final answer. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: DONE - button "Copy" - button "Good response" @@ -29,8 +32,8 @@ - button "Branch into a new conversation" - text: {{clock}} Keep this later input in the original conversation. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: ORIGINAL ONLY - button "Copy" - button "Good response" diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 0469ba0e75..e489e916e6 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Ran commands" [expanded] - button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded] - text: "IN { \"command\": \"printf 'MINIMAL_BASH_CARD_OK\\\\n'\" } OUT MINIMAL_BASH_CARD_OK [Command finished with exit code 0]" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 1bfe52e4e6..6f978228c7 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index bab8040ae6..279843a249 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 7aeef09f49..08f503ab05 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,7 +1,7 @@ - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" -- status: 已完成 -- button "已完成,用时 {{duration}}" [expanded] +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] - button "已完成分析" [expanded] - button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 41d4680b7a..34a2a77047 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools, ran code, ran commands" [expanded] - button "Think" - text: Failed diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 3dbcb5d3ec..986dcf439c 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Ran code, ran commands, read files" [expanded] - button "Think" - button "Code Run bash echo and catch missing file read" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index b8f6cce9a0..346b77ff8d 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Asked questions" [expanded] - button "Think" - button "Ask question 1/1 answered" [expanded] diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 6b62b305cb..be7b65aa37 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 647e99e6f5..7d5d89a078 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Asked questions" [expanded] - button "Think" - button "Ask question cancelled" [expanded] diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 9c17d3c73c..64738a0101 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Add files or run commands" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index dbd34d30b8..d375969b1e 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 6d1d9a2f93..2455bfb385 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -11,9 +11,9 @@ - button "Continuing goal {{clock}}": - text: Continuing goal - time: {{clock}} +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - region "To-dos": - button "To-dos 1 completed · 1 in progress" - text: Ongoing Goal Keep the composer context panels aligned diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 1ec3f95c7b..594d0053e8 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - list: - listitem: - text: Queue item to remove diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 2de2ae656f..6dc03fa797 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - list: - listitem: - text: Edited queue item diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 7d06bd0b91..d014db4b09 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -25,8 +25,8 @@ - img "queued.png" - text: Compare with this screenshot {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -34,8 +34,8 @@ - button "Branch into a new conversation" - text: {{clock}} Continue with the queued comparison {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 7ef1375487..6496bb83f5 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -8,9 +8,9 @@ - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - list: - listitem: - img "Queued message image" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 1bf37d80f4..86f3b1a9cc 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index a0d3f44eed..769c31023f 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 9c3baf29a6..6cbe989134 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Read files" [expanded] - button "Think" - button "Read a.txt": @@ -28,8 +28,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index d2c53e99b2..b1842edc41 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -10,8 +10,8 @@ - button "Jump to turn 2" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" @@ -19,8 +19,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 7dde4db16f..6e08ccbd47 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -7,8 +7,8 @@ - tab "Trajectory" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Skill editing-cordis-compositions" [expanded] diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 29ee449f59..0a731c2f3e 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -8,11 +8,11 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Waiting for your action" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" -- status: Deep diving -- text: Deep diving for {{duration}} - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 6ccbadfd62..b04beec4bb 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - button "Asked questions" [expanded] - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index b13cb9a2f9..030841b7e4 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index 01e95cd04d..c04320b0b6 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -12,8 +12,8 @@ - tab "Trajectory" - text: Give one concrete event sourcing example. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [disabled] +- status: Worked +- button "Took {{duration}}" [disabled] - status: - strong: This subagent is read-only for now - text: The parent session is offline; reopen it to continue sending messages. diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 748cd0db25..607e142b62 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,7 +1,7 @@ - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 56cb24ea87..625180666b 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -15,8 +15,8 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. @@ -26,8 +26,8 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" [expanded] - button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index fd6280355e..6e67c79557 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -15,8 +15,8 @@ - button "Jump to turn 2" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -24,8 +24,8 @@ - button "Branch into a new conversation" - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 9b0d6b0309..c6e866c5e2 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -11,9 +11,9 @@ - tab "Trajectory" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - list: - listitem: - text: Keep working until I stop you again. diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 5d309edf46..75ee221050 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 353f648456..a31f0d0c79 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Analysis completed" - paragraph: Reading the workspace now. - button "Ran commands" [expanded] diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index e8cd6fbf1d..8a9f36aee9 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -9,12 +9,12 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - button "Analysis completed" - paragraph: Reading the workspace now. - button "Ran commands" - paragraph: partial -- status: Deep diving -- text: Deep diving for {{duration}} - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index a626148bfa..cbe5273748 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -9,7 +9,10 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - status: Stopped -- button "Stopped" +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" +- paragraph: Reading the workspace now. +- button "Ran commands" - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 791695b15c..8ced72edd1 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 92cc1ce25e..f229309b8c 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -8,8 +8,8 @@ - tab "Trajectory" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Searched the web" [expanded] - button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search" - paragraph: SEARCH_DONE diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index c760b4962d..12ffccb420 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,7 +1,7 @@ - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" -- status: Completed -- button "Completed in {{duration}}" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] - button "Called tools" [expanded] - button "Think" - button "Run workflow snapshot-flow" diff --git a/tsconfig.host.json b/tsconfig.host.json index 08de88abdc..2d31d0df31 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,7 +29,6 @@ "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", - "apps/web/tests/shimmer.ts", "apps/web/tests/model-input-layout.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/startup-rpc-budget.e2e.ts",