diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml
new file mode 100644
index 0000000000..b97fb2d1e8
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-08-18-client-build-environment.md
+2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431
+2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328
diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
new file mode 100644
index 0000000000..45ed6c8bc6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
@@ -0,0 +1,37 @@
+# Agent Note: Build-time public environment variables for client business code
+
+Status: implemented
+
+English | [中文](2026-08-18-client-build-environment.zh.md)
+
+## Problem
+
+Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type.
+
+Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published.
+
+## Decision
+
+`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`.
+
+The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability.
+
+The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it.
+
+The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record.
+
+## Alternatives considered
+
+**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`.
+
+**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable.
+
+**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices.
+
+**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path.
+
+## Consequences
+
+The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption.
+
+Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism.
diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md
new file mode 100644
index 0000000000..bb96337214
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Client 业务代码使用构建期公开环境变量
+
+Status: implemented
+
+[English](2026-08-18-client-build-environment.md) | 中文
+
+## Problem
+
+浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。
+
+浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。
+
+## Decision
+
+`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。
+
+Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。
+
+`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。
+
+根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。
+
+## Alternatives considered
+
+**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。
+
+**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。
+
+**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。
+
+**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。
+
+## Consequences
+
+Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。
+
+任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。
diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml
index 02c04257b0..ba17869f29 100644
--- a/.github/workflows/build-exe-for-python-sdk.yml
+++ b/.github/workflows/build-exe-for-python-sdk.yml
@@ -219,6 +219,8 @@ jobs:
}
- name: Build single-exe
+ env:
+ DSH_BUILD_CLIENT_PROFILE: official
run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
- name: Resolve platform outputs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 771fe0eb31..741a6c4d5a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -299,6 +299,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run compatibility smokes
+ env:
+ DSH_BUILD_CLIENT_PROFILE: official
run: pnpm run check:node-compat
python-sdk:
diff --git a/.github/workflows/e2b-e2e.yml b/.github/workflows/e2b-e2e.yml
index abbd0482ed..0442b0e093 100644
--- a/.github/workflows/e2b-e2e.yml
+++ b/.github/workflows/e2b-e2e.yml
@@ -46,7 +46,7 @@ jobs:
# The Loader smoke runs package exports under plain Node in lib mode.
- name: Build (lib for the E2B Loader smoke)
- run: pnpm run build
+ run: pnpm run build:official
- name: E2B tests (live sandbox)
env:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index a9c67fca42..3cd2f515ab 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -104,7 +104,7 @@ jobs:
# the built artifact under plain Node, resolving plugins through real package
# exports — the shape a real consumer runs. That requires a prior build.
- name: Build (lib for the e2e example bins)
- run: pnpm run build
+ run: pnpm run build:official
# Real-API end-to-end tests only. The keyless gates (lint/typecheck/
# coverage/snapshot/etc.) already run in ci.yml on every push/PR.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2e20c215fd..08296468bf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,7 +75,7 @@ jobs:
run: pnpm run release:verify --family dsh
- name: Build
- run: pnpm run build
+ run: pnpm run build:official
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml
index 192cbbd524..d13e4f7c50 100644
--- a/.github/workflows/sandbox.yml
+++ b/.github/workflows/sandbox.yml
@@ -117,7 +117,7 @@ jobs:
# together so registry state cannot mask source/package drift.
- name: Build packages for the pack rehearsal
if: matrix.runner == 'landlock'
- run: pnpm run build
+ run: pnpm run build:official
- name: Packed-distribution e2e (pack → install → confine)
if: matrix.runner == 'landlock'
diff --git a/.gitignore b/.gitignore
index 3d0fd8e322..70c355e391 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ python/**/__pycache__/
python/**/.pytest_cache/
apps/web/dist/
.artifacts/
+.dsh-build/
.playwright-mcp/
.orig
.worktrees/
diff --git a/GUIDELINES.md b/GUIDELINES.md
new file mode 100644
index 0000000000..02831eb26d
--- /dev/null
+++ b/GUIDELINES.md
@@ -0,0 +1,23 @@
+# DeepSeek Harness 品牌素材使用规范
+
+为了维护 DeepSeek Harness 生态的长期健康发展,避免用户混淆,方便用户对相关资源进行检索和识别,我们特别制定本规范,希望大家共同遵守:
+
+- 在项目的描述性文字中,您可以使用“DeepSeek Harness ”真实、准确地说明您的项目与 DeepSeek Harness 的关系,例如“基于 DeepSeek Harness 构建”或“兼容 DeepSeek Harness”等。这类说明符合许可证的要求,也有助于用户理解项目的定位。
+- 如果您希望项目名称能体现与 DeepSeek Harness 生态的关联,我们建议使用缩写的 **“DSH”** 标识来命名,这样既清晰又便于社区内的交流。
+- 在项目命名时,请避免直接使用完整的 **“DeepSeek Harness”** 商标。**“DeepSeek Harness”** 是深度求索公司的注册商标,未经授权用于项目名,容易引发用户的误解和混淆,从而影响整个生态的清晰度。同时,也可能涉及商标侵权行为。
+- 此外,请您避免在宣传或展示时,以容易引起误解的方式使用官方品牌素材,以免让用户产生官方背书、合作或授权等不实印象。
+
+我们相信,一个清晰、有序的社区环境,能让每一位开发者的努力都更容易被看见和认可。对于少数不符合上述规范的情况,我们可能会联系相关方进行适当的调整,以维护生态整体的秩序。感谢大家的理解与支持,让我们一起构建一个更友好、更可持续发展的开源社区。
+
+# DeepSeek Harness Brand Asset Usage Guidelines
+
+
+To maintain the long\-term healthy development of the DeepSeek Harness ecosystem, avoid user confusion, and facilitate the retrieval and identification of related resources, we have established these specifications and hope that everyone will adhere to them:
+
+- In your project's descriptive text, you may use "DeepSeek Harness" to truthfully and accurately describe your project's relationship with DeepSeek Harness, for example, "built on DeepSeek Harness" or "compatible with DeepSeek Harness\." Such descriptions comply with license requirements and help users understand your project's positioning\.
+- If you wish your project name to reflect its association with the DeepSeek Harness ecosystem, we recommend using the abbreviated "DSH" designation for naming, which is both clear and facilitates communication within the community\.
+- When naming your project, please avoid using the full "DeepSeek Harness" trademark directly\. "DeepSeek Harness" is a registered trademark of DeepSeek\. Unauthorized use in project names can easily lead to user misunderstanding and confusion, thereby affecting the clarity of the entire ecosystem\. It may also involve trademark infringement\.
+- Additionally, please avoid using official brand materials in your promotions or presentations in a way that could cause misunderstanding, so as not to give users the false impression of official endorsement, cooperation, or authorization\.
+
+We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\.
+
diff --git a/apps/web/index.html b/apps/web/index.html
index 1ce5ff35ff..75c4e5bac6 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -5,7 +5,7 @@
-
DeepSeek Harness
+ DSH Local Build
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 3d7bb3c249..0e48e08ec0 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -10,17 +10,40 @@
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
+import { resolve } from 'node:path'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
+const buildEnvironmentModulePath = '../../../scripts/client-build-environment.ts'
+const buildEnvironmentModule: unknown = await import(buildEnvironmentModulePath)
+if (typeof buildEnvironmentModule !== 'object' || buildEnvironmentModule === null) {
+ throw new TypeError('client build environment module must be an object')
+}
+const readClientBuildRecord: unknown = Reflect.get(buildEnvironmentModule, 'readClientBuildRecord')
+if (!isBuildRecordReader(readClientBuildRecord)) {
+ throw new TypeError('client build environment module must export readClientBuildRecord')
+}
+const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..'))
+if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object')
+const clientBuildEnvironment: unknown = Reflect.get(record, 'environment')
+if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) {
+ throw new TypeError('client build record environment must be an object')
+}
+
+function isBuildRecordReader(value: unknown): value is (root: string) => unknown {
+ return typeof value === 'function'
+}
+
it('boots the built plugin graph and renders a fixture session end to end', async () => {
mountAssembledApp()
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+ expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull()
+ expect(screen.queryByText('DSH Local Build')).toBeNull()
// The compact layout dropped group session counts; the fixture workspace
// group row renders immediately with its sessions beneath it.
const fixtureGroup = (await within(tree).findAllByText('fixture'))
diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts
index 99a1105fa6..0f0418c5d1 100644
--- a/apps/web/tests/hmr-live.e2e.ts
+++ b/apps/web/tests/hmr-live.e2e.ts
@@ -1,6 +1,6 @@
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
-import { existsSync } from 'node:fs'
+import { existsSync, globSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +10,7 @@ import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { readClientBuildRecord } from '../../../scripts/client-build-environment.ts'
import { REPO_ROOT } from './support.ts'
function spawnSpec(argv: readonly string[], cwd: string, env?: Record): SubprocessSpawnSpec {
@@ -70,11 +71,13 @@ async function stopTree(child: SubprocessHandle): Promise {
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
- const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
+ const clientBuildEnvironment = readClientBuildRecord(REPO_ROOT).environment
+ const clientBundlePaths = globSync('packages/*/*/lib/client.js{,.map}', { cwd: REPO_ROOT })
+ .map(path => join(REPO_ROOT, path))
+ const originalClientBundles = await Promise.all(clientBundlePaths.map(async path => [path, await readFile(path)] as const))
const originalSource = await readFile(sourcePath)
- const originalBundle = await readFile(bundlePath)
const oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
@@ -89,7 +92,11 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
- watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
+ watcher = subprocessCtx.subprocess.spawn(spawnSpec(
+ ['pnpm', 'run', 'dev:web'],
+ REPO_ROOT,
+ { ...clientBuildEnvironment },
+ ))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--no-open', '--port', '0'],
@@ -122,7 +129,9 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
} finally {
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
- await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
+ await Promise.all(originalClientBundles.map(async ([path, content]) => {
+ await writeFile(path, content).catch((error: unknown) => failures.push(error))
+ }))
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
await browser?.close().catch((error: unknown) => failures.push(error))
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index 2f3df14071..3ac6706a6f 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -541,6 +541,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await connectFreshWorkspace(page, sessionsDir)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
+ const productTitle = await page.title()
await screen(page, '02-empty-state')
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
await input.fill(prompt)
@@ -550,8 +551,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([])
await page.waitForFunction(
- () => document.title !== 'DeepSeek Harness' && document.title.endsWith(' — DeepSeek Harness'),
- undefined,
+ expected => document.title !== expected && document.title.endsWith(` — ${expected}`),
+ productTitle,
{ timeout: 15_000 },
)
await expect.poll(async () => (await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})).items.length, {
@@ -562,8 +563,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
if (sessionId === undefined) throw new Error('created Web session was not listed')
const durableTitle = await waitForProviderTitle(baseUrl, sessionId)
await page.waitForFunction(
- expected => document.title === `${expected} — DeepSeek Harness`,
- durableTitle,
+ ({ expected, product }) => document.title === `${expected} — ${product}`,
+ { expected: durableTitle, product: productTitle },
{ timeout: 15_000 },
)
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts
index 23771924bc..b39e720600 100644
--- a/apps/web/tests/startup-auto-selection.e2e.ts
+++ b/apps/web/tests/startup-auto-selection.e2e.ts
@@ -69,8 +69,8 @@ describe('web e2e: startup auto-selection', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
const headline = page.getByText('Into the Unknown', { exact: true })
- const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
- const fishHitbox = fish.locator('..')
+ const fishHitbox = headline.locator('xpath=preceding-sibling::span[1]')
+ const fish = fishHitbox.locator('svg')
expect(await fish.evaluate(node => getComputedStyle(node).color))
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
await fishHitbox.hover()
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 33c4a048ae..cd27136cb7 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -2,11 +2,29 @@ import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
+import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
+const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
+
+/** Escape build-time text before placing it in the HTML title element. */
+function escapeHtmlText(value: string): string {
+ return value.replace(/&/g, '&').replace(//g, '>')
+}
+
+/** Project the public build title into the initial HTML document. */
+function clientDocumentTitle(): Plugin {
+ const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE)
+ return {
+ name: 'dsh-client-document-title',
+ transformIndexHtml(html) {
+ return html.replace('DSH Local Build', `${title}`)
+ },
+ }
+}
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
@@ -90,7 +108,7 @@ function npmPackageOf(id: string): string | undefined {
}
export default defineConfig({
- plugins: [rejectStandaloneServe(), react()],
+ plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
build: {
sourcemap: true,
rollupOptions: {
@@ -146,6 +164,7 @@ export default defineConfig({
],
},
define: {
+ ...clientBuildEnvironmentDefines(process.env),
// vendored loader internal.ts: fromInternal() probes the Node major —
// "0.0.0" takes neither branch, returning undefined (exactly the empty
// internal slot the shell boot fills with the client module loader).
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 3194d54998..9243513ecb 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.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/config-catalog.md
-config-catalog.md: b865806bcc4d3e494a0ebf2a9331928991f9e6d1
-config-catalog.zh.md: 6e4ce4cc4d3bfa856987c0d698fddc1c8c7712a9
+config-catalog.md: d9c4eff5a206ac17b54957d5364fb7b3d175d97c
+config-catalog.zh.md: 010878c582eec6263ea604c880a15407ebd777b6
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index b865806bcc..d9c4eff5a2 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3214,6 +3214,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-brand-official` ([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts))
- `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 6e4ce4cc4d..010878c582 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3218,6 +3218,7 @@ export interface Config {
- `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-brand-official`([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts))
- `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml
index 6a67241ba0..3dd3b32c87 100644
--- a/docs/development.i18n.yaml
+++ b/docs/development.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/development.md
-development.md: 4ef2a195fdf525599ad48da32ee4bccfbd9a8c1d
-development.zh.md: 66799906e22c3dfc164459ba9f4c97e3972ef94a
+development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a
+development.zh.md: fe160763ffc80bda9bbc7317fb7b3b69df1b3f88
diff --git a/docs/development.md b/docs/development.md
index 4ef2a195fd..904245f935 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -75,6 +75,8 @@ Both tsdown passes use the same complete workspace match. They neither scan buil
Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.
+`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build.
+
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.
Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.
diff --git a/docs/development.zh.md b/docs/development.zh.md
index 66799906e2..fe160763ff 100644
--- a/docs/development.zh.md
+++ b/docs/development.zh.md
@@ -75,6 +75,8 @@ pnpm run build:web
Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。
+`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。
+
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。
业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 9ef34df65a..83709a6f2b 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.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/module-graph.md
-module-graph.md: ac5faf74ca156b859859c08d0c9872c332b7fd1c
-module-graph.zh.md: 71de2ac3d566bec5f878619ca68ea779ddabc387
+module-graph.md: 69d751f69ee254f4c2842216179f0072b208a49d
+module-graph.zh.md: c93c95aae022d040498025530304344e001de2f0
diff --git a/docs/module-graph.md b/docs/module-graph.md
index ac5faf74ca..69d751f69e 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -125,6 +125,7 @@ flowchart TD
pkg_client_runtime["client-runtime"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_attachment["client-ui-attachment"]
+ pkg_client_ui_brand_official["client-ui-brand-official"]
pkg_client_ui_commands["client-ui-commands"]
pkg_client_ui_conversation["client-ui-conversation"]
pkg_client_ui_deliverables["client-ui-deliverables"]
@@ -1306,6 +1307,10 @@ flowchart TD
pkg_client_ui_attachment --> pkg_client_runtime
pkg_client_ui_attachment --> pkg_client_ui_conversation
pkg_client_ui_attachment --> pkg_invariants
+ pkg_client_ui_brand_official --> pkg_client_runtime
+ pkg_client_ui_brand_official --> pkg_client_ui_conversation
+ pkg_client_ui_brand_official --> pkg_client_ui_sidebar
+ pkg_client_ui_brand_official --> pkg_invariants
pkg_client_ui_commands --> pkg_api_remotes
pkg_client_ui_commands --> pkg_client_locale
pkg_client_ui_commands --> pkg_client_runtime
@@ -1652,6 +1657,7 @@ flowchart TD
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 71de2ac3d5..c93c95aae0 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -127,6 +127,7 @@ flowchart TD
pkg_client_runtime["client-runtime"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_attachment["client-ui-attachment"]
+ pkg_client_ui_brand_official["client-ui-brand-official"]
pkg_client_ui_commands["client-ui-commands"]
pkg_client_ui_conversation["client-ui-conversation"]
pkg_client_ui_deliverables["client-ui-deliverables"]
@@ -1308,6 +1309,10 @@ flowchart TD
pkg_client_ui_attachment --> pkg_client_runtime
pkg_client_ui_attachment --> pkg_client_ui_conversation
pkg_client_ui_attachment --> pkg_invariants
+ pkg_client_ui_brand_official --> pkg_client_runtime
+ pkg_client_ui_brand_official --> pkg_client_ui_conversation
+ pkg_client_ui_brand_official --> pkg_client_ui_sidebar
+ pkg_client_ui_brand_official --> pkg_invariants
pkg_client_ui_commands --> pkg_api_remotes
pkg_client_ui_commands --> pkg_client_locale
pkg_client_ui_commands --> pkg_client_runtime
@@ -1654,6 +1659,7 @@ flowchart TD
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/knip.json b/knip.json
index 9edea9ab23..8733835187 100644
--- a/knip.json
+++ b/knip.json
@@ -25,7 +25,11 @@
".": {
"entry": [
"scripts/**/*.mjs",
- "scripts/**/*.cjs"
+ "scripts/**/*.cjs",
+ "scripts/types/client-build-environment/index.d.ts"
+ ],
+ "ignoreUnresolved": [
+ "client-build-environment"
],
"project": [
"scripts/**/*.ts",
diff --git a/package.json b/package.json
index 3ec9a6c1c5..d42108564d 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"website"
],
"scripts": {
- "build": "npm run build:lib && npm run build:web",
+ "build": "tsx scripts/build.ts",
+ "build:official": "tsx scripts/build.ts --profile official",
"build:lib": "npm run build:lib:host && npm run build:lib:client",
"build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host",
"build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client",
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 03d04782bb..61151bdc65 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -209,6 +209,10 @@
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
+ # Official occupants for the generic sidebar and conversation brand slots.
+ - id: ui-brand-official
+ name: '@deepseek-ai/dsh-client-ui-brand-official'
+
- id: ui-attachment
name: '@deepseek-ai/dsh-client-ui-attachment'
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 1f6ede4009..c753882a98 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index 86a23bbf12..9baa35ebd0 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -66,6 +66,10 @@ Npm sections describe installation and development relationships; each build fac
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
+## Build-time browser environment
+
+Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
+
## Shared modules and the module graph
A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot.
diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml
index 3928f30f1b..6036e9160e 100644
--- a/packages/client/README.i18n.yaml
+++ b/packages/client/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/README.md
-README.md: fe57d58a0f4fa1c9bff2699ffb363c80197fc5ed
-README.zh.md: c721710c9f20ba10f20392c207e1de4169ec0e12
+README.md: b18aad486cd7d3fafe8261fee61fa9e26a7feaa6
+README.zh.md: e840493b0135cfa4f8e294efe8c612471a94e026
diff --git a/packages/client/README.md b/packages/client/README.md
index fe57d58a0f..b18aad486c 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -20,6 +20,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. |
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
+| [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks. |
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md
index c721710c9f..e840493b01 100644
--- a/packages/client/README.zh.md
+++ b/packages/client/README.zh.md
@@ -20,6 +20,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
| [`ui-attachment/`](ui-attachment/README.md) | 注册输入框与消息图片的附件呈现。 |
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示工作区与会话导航。 |
+| [`ui-brand-official/`](ui-brand-official/README.md) | 使用官方名称和标记填充通用浏览器品牌 slot。 |
| [`ui-workspace/`](ui-workspace/README.md) | 提供工作区选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前对话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
diff --git a/packages/client/runtime/src/env.d.ts b/packages/client/runtime/src/env.d.ts
deleted file mode 100644
index 54d3d9d8f3..0000000000
--- a/packages/client/runtime/src/env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/**
- * Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers
- * never evaluate a bare `process`. tsconfig carries no node types on purpose.
- */
-declare const process: { env: { NODE_ENV?: string } }
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 30c157edf1..44aab8cf1b 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -17,6 +17,7 @@ import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { optionalStringArray } from './modules/src/client/manifest.ts'
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
+import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -470,6 +471,7 @@ function clientConfig(id: string, entry: string): UserConfig {
// key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
// the truthiness probe would otherwise survive as an empty import.meta.
define: {
+ ...clientBuildEnvironmentDefines(process.env),
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml
new file mode 100644
index 0000000000..9d438ad955
--- /dev/null
+++ b/packages/client/ui-brand-official/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-brand-official/README.md
+README.md: 7197bc7f4731cd3e6471549900e4868ad6d4796d
+README.zh.md: 14d3ee3f1aa2f12ddd20b851d4034e1417baf5c9
diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md
new file mode 100644
index 0000000000..7197bc7f47
--- /dev/null
+++ b/packages/client/ui-brand-official/README.md
@@ -0,0 +1,20 @@
+# @deepseek-ai/dsh-client-ui-brand-official
+
+English | [中文](README.zh.md)
+
+This package fills `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` only when `DSH_CLIENT_BUILD_PROFILE` is `official`. Other builds load the plugin but register no occupants, leaving the shell fallbacks visible.
+
+The three occupants install as one declaration-aware registration set through nested `slots.inject()` calls. The package therefore works whether its row activates before or after the sidebar and conversation declarers, withdraws all occupants when either declaration collapses, and leaves no partial brand mix during HMR. It retains no runtime state. The node half is an empty Loader seat, and the browser title remains a build-environment concern outside this package.
+
+## Model Experience
+
+None, as the package contributes browser presentation only; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **The package supplies one occupant set** — alternative presentation belongs in another Cordis package occupying the same slots.
+- **The browser title is independent** — `DSH_CLIENT_TITLE` selects title text at build time rather than through a UI slot.
diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md
new file mode 100644
index 0000000000..14d3ee3f1a
--- /dev/null
+++ b/packages/client/ui-brand-official/README.zh.md
@@ -0,0 +1,20 @@
+# @deepseek-ai/dsh-client-ui-brand-official
+
+[English](README.md) | 中文
+
+仅当 `DSH_CLIENT_BUILD_PROFILE` 为 `official` 时,本包才填充 `sidebar.brand.mark`、`sidebar.brand.name` 和 `conversation.hero.brand.mark`。其他构建仍会加载插件,但不注册 occupant,因此显示 shell fallback。
+
+三个占位者通过嵌套的 `slots.inject()` 作为一组声明感知注册安装。因此无论该包的条目先于还是后于侧边栏和会话声明方激活,它都能工作;任一声明折叠时会撤回全部占位者,HMR 期间不会留下混合品牌。它不保留运行时状态。node 半边是空的 Loader seat;浏览器标题仍属于本包之外的构建环境事项。
+
+## 模型体验
+
+无,因为本包只贡献浏览器呈现;这里没有任何内容进入模型请求。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送 provider 请求。
+
+## 已知限制与暂缓事项
+
+- **本包只提供一组 occupant** —— 其他呈现应由占用相同 slot 的另一个 Cordis 包提供。
+- **浏览器标题相互独立** —— `DSH_CLIENT_TITLE` 在构建期选择标题文字,而不经过 UI slot。
diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json
new file mode 100644
index 0000000000..25c1c93cab
--- /dev/null
+++ b/packages/client/ui-brand-official/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "@deepseek-ai/dsh-client-ui-brand-official",
+ "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots",
+ "version": "0.1.0-rc.7",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/ui-brand-official"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-client-runtime",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-sidebar"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-client-runtime": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/cordis": "workspace:^"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-client-runtime": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/cordis": "workspace:^",
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "~18.3.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts"
+ ]
+}
diff --git a/packages/client/ui-brand-official/src/client/Brand.tsx b/packages/client/ui-brand-official/src/client/Brand.tsx
new file mode 100644
index 0000000000..4e0a60fd26
--- /dev/null
+++ b/packages/client/ui-brand-official/src/client/Brand.tsx
@@ -0,0 +1,22 @@
+import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client'
+
+type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps
+
+/**
+ * Render the official mark with the presentation requested by its host surface.
+ * @param props - Host-supplied mark presentation.
+ * @returns the official whale mark.
+ */
+export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) {
+ return
+}
+
+/**
+ * Render the official name artwork without its independently slotted mark.
+ * @returns the official name wordmark.
+ */
+export function OfficialBrandName() {
+ return
+}
diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts
new file mode 100644
index 0000000000..b291bda3e7
--- /dev/null
+++ b/packages/client/ui-brand-official/src/client/index.ts
@@ -0,0 +1,23 @@
+/** Official DeepSeek Harness occupants for the generic browser-brand slots. */
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
+
+/** Required service: the UI slot registry. */
+export const inject = ['slots']
+
+/**
+ * Fill every shipped brand slot as one declaration-aware registration set.
+ * @param ctx - Client root context.
+ */
+export function apply(ctx: ClientContext): void {
+ if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return
+ ctx.slots.inject('sidebar.brand.mark', () =>
+ ctx.slots.inject('sidebar.brand.name', () =>
+ ctx.slots.inject('conversation.hero.brand.mark', function* () {
+ yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark)
+ yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName)
+ yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark)
+ })))
+}
diff --git a/packages/client/ui-brand-official/src/index.ts b/packages/client/ui-brand-official/src/index.ts
new file mode 100644
index 0000000000..df38f3cfa5
--- /dev/null
+++ b/packages/client/ui-brand-official/src/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Official browser-brand plugin, node half. The empty apply gives Loader a
+ * host-side row while the browser half ships through `exports["./client"]`.
+ */
+
+/** Host plugin body — this package contributes browser presentation only. */
+export function apply(): void {}
diff --git a/packages/client/ui-brand-official/src/invariant.ts b/packages/client/ui-brand-official/src/invariant.ts
new file mode 100644
index 0000000000..574054c383
--- /dev/null
+++ b/packages/client/ui-brand-official/src/invariant.ts
@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-brand-official`.
+ * @module @deepseek-ai/dsh-client-ui-brand-official/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-brand-official'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-brand-official-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the package retains no mutable state, and its three
+ * slot occupants install and leave through one transactional effect.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx
new file mode 100644
index 0000000000..ee5275b9b7
--- /dev/null
+++ b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx
@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
+import { apply, inject } from '../src/client/index.ts'
+import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx'
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllEnvs()
+})
+
+const HOLES = [
+ 'sidebar.brand.mark',
+ 'sidebar.brand.name',
+ 'conversation.hero.brand.mark',
+] as const
+
+async function bench(declare = true) {
+ const ctx = new Context()
+ await ctx.plugin(SlotRegistry).await()
+ const slots = ctx.get('slots') as SlotRegistry
+ const declareHoles = () => slots.register({
+ name: 'root',
+ children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
+ } as never, () => null)
+ const disposeHoles = declare ? declareHoles() : undefined
+ return { ctx, slots, declareHoles, disposeHoles }
+}
+
+describe('official browser-brand plugin', () => {
+ it('declares only the slot service it uses', () => {
+ expect(inject).toEqual(['slots'])
+ })
+
+ it('leaves every slot empty outside the official build profile', async () => {
+ vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'local')
+ const subject = await bench()
+ await subject.ctx.plugin({ inject: [...inject], apply }).await()
+ for (const hole of HOLES) expect(subject.slots.entries(hole)).toHaveLength(0)
+ })
+
+ it('fills declarations before or after apply and removes every occupant on teardown', async () => {
+ vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official')
+ const before = await bench()
+ const fiber = before.ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
+
+ before.disposeHoles?.()
+ for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
+ before.declareHoles()
+ await Promise.resolve()
+ for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
+
+ await fiber.dispose()
+ for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
+
+ const after = await bench(false)
+ await after.ctx.plugin({ inject: [...inject], apply }).await()
+ for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
+ after.declareHoles()
+ await Promise.resolve()
+ for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
+ })
+
+ it('renders the official name independently from both requested mark sizes', () => {
+ const name = render()
+ expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24')
+ name.unmount()
+
+ const mark = render()
+ expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34')
+ expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark')
+ mark.rerender()
+ expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24')
+ })
+})
diff --git a/packages/client/ui-brand-official/tests/invariant.client.spec.ts b/packages/client/ui-brand-official/tests/invariant.client.spec.ts
new file mode 100644
index 0000000000..d8d3dd2d95
--- /dev/null
+++ b/packages/client/ui-brand-official/tests/invariant.client.spec.ts
@@ -0,0 +1,18 @@
+import { Context } from '@deepseek-ai/cordis'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import { describe, expect, it } from 'vitest'
+import * as BrandInvariant from '../src/invariant.ts'
+import { apply as nodeApply } from '../src/index.ts'
+
+describe('official brand invariant companion', () => {
+ it('reserves package ownership with an empty installer', async () => {
+ const ctx = new Context()
+ await ctx.plugin(InvariantRegistry, { enabled: true })
+
+ await expect(ctx.plugin(BrandInvariant).await()).resolves.toBeDefined()
+ })
+
+ it('keeps the node half as an inert Loader seat', () => {
+ expect(() => { nodeApply() }).not.toThrow()
+ })
+})
diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json
new file mode 100644
index 0000000000..f98c0a8b2f
--- /dev/null
+++ b/packages/client/ui-brand-official/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../runtime"
+ },
+ {
+ "path": "../ui-conversation"
+ },
+ {
+ "path": "../ui-primitives"
+ },
+ {
+ "path": "../ui-sidebar"
+ },
+ {
+ "path": "../ui-slots"
+ }
+ ]
+}
diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts
new file mode 100644
index 0000000000..abc830c1f6
--- /dev/null
+++ b/packages/client/ui-brand-official/tsdown.config.ts
@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js', 'lib/types/invariant.js'])
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 216c60587a..8131af3495 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/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-conversation/README.md
-README.md: cbd57ba05a96da36ac9198817e78594c1a2cefff
-README.zh.md: cbe13efd43abda020edae41a0adee09d991314fe
+README.md: dae46f4398dcbb6dcd50c0d7bdfac13be14d5dc2
+README.zh.md: 0b7af0b42eee060f7c827cdc9e06bc07b7bcc4ef
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index cbd57ba05a..dae46f4398 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable.
-The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)).
+The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. The Hero's leading mark is the independent root-scoped `conversation.hero.brand.mark` slot, with the fish mark as its fallback. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)).
Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index cbe13efd43..0b7af0b42e 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -6,7 +6,7 @@
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。
-常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。
+常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。Hero 前方的标记是独立的根作用域 `conversation.hero.brand.mark` slot,未被占用时回退到鱼形标记。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 59b010f93a..294009a09b 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -206,6 +206,7 @@ export function apply(ctx: Context): void {
'conversation.composer.dock': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
+ 'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
},
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 6eb3442925..4ea0c79402 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -166,6 +166,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* reads the global workspace list.
*/
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
+ /**
+ * Brand mark leading the blank-session headline. Declared by this
+ * package's `conversation` entry; the shell supplies a fish fallback.
+ */
+ 'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps }
/**
* The agent-preset chip beside the workspace picker on the new-session
* screen. Root scope: no session exists yet, so the choice is staged for
@@ -598,6 +603,14 @@ export interface ComposerChainProps {
session: ConversationSnapshot | undefined
}
+/** Presentation props supplied to the blank-session brand-mark occupant. */
+export interface HeroBrandMarkOwnerProps {
+ /** Requested square edge in pixels. */
+ size: number
+ /** Host CSS class for preserving the default hero mark color and hover motion. */
+ className?: string | undefined
+}
+
/**
* Full conversation-slot component props: runtime & child-render (view ring
* + composer chain/bar + input-region + hero picker slots) & store & injected
@@ -610,6 +623,7 @@ export type ConversationSlotProps =
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
+ | 'conversation.hero.brand.mark'
| 'conversation.hero.workspace'
| 'conversation.hero.agentPreset'
>
diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
index 4a8b27acbb..814734411b 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/packages/client/ui-conversation/src/client/index.ts
@@ -32,7 +32,7 @@ export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
- ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
+ ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, HeroBrandMarkOwnerProps,
MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index ef1221cd99..07655d7dba 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -159,7 +159,7 @@ export function ConversationRoot({
const composerBar = (
{hero && }
- {hero && }
+ {hero && }
{hero && heroWorkspaceRow}
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
index 4865ceecfa..9db09e3141 100644
--- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
@@ -102,6 +102,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) {
export interface HeroShellProps {
/** The owner's locale seat, passed down as a plain prop. */
t: HeroTranslate
+ /** Authorized renderer for the hero brand-mark slot. */
+ renderSlot: ConversationSlotProps['renderSlot']
/** Overlay content after the stack (modals). */
children?: ReactNode
}
@@ -112,14 +114,16 @@ export interface HeroShellProps {
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
-export function HeroShell({ t, children }: HeroShellProps) {
+export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
return (
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
-
+ {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
+ fallback: ,
+ })}
{t('hero.headline')}{t('hero.preview')}
diff --git a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx
index c9f2dedb14..64f4dadb96 100644
--- a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx
@@ -91,6 +91,7 @@ describe('apply wiring', () => {
// The hero holes ride the conversation entry's children declaration (the
// empty-state occupant is gone). Both are root-scoped: the new-session
// screen precedes the session either would belong to.
+ expect(b.slots.spec('conversation.hero.brand.mark')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
index 20a72b1503..c88ffd6b5b 100644
--- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
@@ -22,6 +22,7 @@ import { en, zh } from '../src/client/locales.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
+import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
@@ -259,9 +260,19 @@ function mount(
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
- const view = render()
+ const renderSlot = vi.fn(() => null)
+ const view = render()
expect(view.getByText('Into the Unknown')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
+ expect(renderSlot).toHaveBeenCalledOnce()
+ expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark')
+ const brandMarkOwner = renderSlot.mock.calls[0]?.[1]
+ if (brandMarkOwner === undefined || !('size' in brandMarkOwner) || !('className' in brandMarkOwner)) {
+ throw new Error('hero brand-mark owner must provide size and className')
+ }
+ expect(brandMarkOwner.size).toBe(34)
+ expect(brandMarkOwner.className).toBeTypeOf('string')
+ expect(renderSlot.mock.calls[0]?.[2]?.fallback).toBeTruthy()
})
})
diff --git a/packages/client/ui-primitives/src/BrandWordmark.tsx b/packages/client/ui-primitives/src/BrandWordmark.tsx
index 768fcdf92c..ed86980646 100644
--- a/packages/client/ui-primitives/src/BrandWordmark.tsx
+++ b/packages/client/ui-primitives/src/BrandWordmark.tsx
@@ -5,19 +5,27 @@
import type { IconProps } from './icons/props.ts'
+/** Display options for the official brand wordmark. */
+export interface BrandWordmarkProps extends IconProps {
+ /** Whether to include the leading whale mark; defaults to true. */
+ includeMark?: boolean | undefined
+}
+
/**
* Render the full brand wordmark.
- * @param props.size - height in px (default 24; width keeps the 182:24 ratio).
+ * @param props.size - height in px (default 24; width follows the selected artwork).
* @param props.className - extra class for layout placement.
+ * @param props.includeMark - whether to include the leading whale mark.
* @returns the wordmark svg (aria-hidden decorative brand art).
*/
-export function BrandWordmark({ size = 24, className }: IconProps) {
+export function BrandWordmark({ size = 24, className, includeMark = true }: BrandWordmarkProps) {
+ const width = includeMark ? 182 : 156
return (