From 93cbb3799d4f3ff1a7242fe4f2fae65e1a89f79c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:33:56 +0800 Subject: [PATCH 1/6] feat(client): inject public build environment --- ...6-08-18-client-build-environment.i18n.yaml | 6 ++ .../2026-08-18-client-build-environment.md | 35 ++++++++ .../2026-08-18-client-build-environment.zh.md | 35 ++++++++ .../workflows/build-exe-for-python-sdk.yml | 1 + .github/workflows/ci.yml | 1 + .github/workflows/e2b-e2e.yml | 1 + .github/workflows/e2e.yml | 1 + .github/workflows/release.yml | 1 + .github/workflows/sandbox.yml | 1 + apps/web/vite.config.ts | 2 + knip.json | 6 +- packages/client/AGENTS.md | 4 + packages/client/runtime/src/env.d.ts | 5 -- packages/client/tsdown.client.ts | 2 + .../client-build-environment.client.spec.ts | 83 +++++++++++++++++++ scripts/client-build-environment.ts | 24 ++++++ .../types/client-build-environment/index.d.ts | 7 ++ tsconfig.base.client.json | 3 +- tsconfig.client.json | 2 + tsconfig.host.json | 1 + 20 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md create mode 100644 .agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md delete mode 100644 packages/client/runtime/src/env.d.ts create mode 100644 scripts/client-build-environment.client.spec.ts create mode 100644 scripts/client-build-environment.ts create mode 100644 scripts/types/client-build-environment/index.d.ts 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..cc9b43da5d --- /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: 771fdb936d71daad982cb25f58033284941df679 +2026-08-18-client-build-environment.zh.md: 2a5948d64f585f8eade49cff7d925b6a1c73f129 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..771fdb936d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -0,0 +1,35 @@ +# 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. + +## 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. CI workflows that produce DSH client artifacts set the required variables explicitly; workflows that do not produce those artifacts do not need them. + +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..2a5948d64f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -0,0 +1,35 @@ +# 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 使用的值不得使用该前缀。 + +## 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_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。生成 DSH client 产物的 CI workflow 显式提供所需变量;不生成这些产物的 workflow 不需要携带它们。 + +任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 02c04257b0..12f6daeeae 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -49,6 +49,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771fe0eb31..264843264a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). diff --git a/.github/workflows/e2b-e2e.yml b/.github/workflows/e2b-e2e.yml index abbd0482ed..7e8d33072e 100644 --- a/.github/workflows/e2b-e2e.yml +++ b/.github/workflows/e2b-e2e.yml @@ -9,6 +9,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a9c67fca42..227033892c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e20c215fd..b4fab5e1f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ concurrency: cancel-in-progress: false env: + DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' DSH_TELEMETRY_DISABLED: '1' diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 192cbbd524..bbc19cf49e 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -19,6 +19,7 @@ permissions: contents: read env: + DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 33c4a048ae..5e57eee177 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,6 +2,7 @@ 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__. ' @@ -146,6 +147,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/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/packages/client/AGENTS.md b/packages/client/AGENTS.md index 86a23bbf12..0a3e21852a 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. 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/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/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts new file mode 100644 index 0000000000..2f4f6cb178 --- /dev/null +++ b/scripts/client-build-environment.client.spec.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import yaml from 'js-yaml' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clientBuildEnvironmentDefines } from './client-build-environment.ts' +import { clientBundle } from '../packages/client/tsdown.client.ts' + +const root = resolve(import.meta.dirname, '..') +const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST' +const PROBE_KEY = `process.env.${PROBE_NAME}` +const originalProbe = process.env[PROBE_NAME] +const dshBuildWorkflows = [ + 'build-exe-for-python-sdk.yml', + 'ci.yml', + 'e2b-e2e.yml', + 'e2e.yml', + 'release.yml', + 'sandbox.yml', +] + +afterEach(() => { + if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME) + else process.env[PROBE_NAME] = originalProbe + vi.resetModules() +}) + +describe('client build environment', () => { + it('defines only public client values over a non-enumerable fallback', () => { + expect(clientBuildEnvironmentDefines({ + PATH: '/bin', + DSH_TEST_API_KEY: 'secret', + DSH_CLIENT_VARIANT: 'quoted "value"', + DSH_CLIENT_EMPTY: '', + DSH_CLIENT_UNSET: undefined, + })).toEqual({ + 'process.env': '{}', + 'process.env.DSH_CLIENT_EMPTY': '""', + 'process.env.DSH_CLIENT_VARIANT': '"quoted \\"value\\""', + }) + }) + + it('feeds the same build-process value to dynamic tsdown bundles and the Vite shell', async () => { + process.env[PROBE_NAME] = 'shared-value' + + const configs = clientBundle('@deepseek-ai/dsh-client-ui-sidebar', [ + 'lib/types/index.js', + 'lib/types/invariant.js', + ])({ env: { DSH_BUILD_FACE: 'client' } }) + if (!Array.isArray(configs)) throw new TypeError('client bundle config must be an array') + const dynamic = configs.find(config => config.name === '@deepseek-ai/dsh-client-ui-sidebar/client') + expect(dynamic?.define).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + + const viteConfigPath = '../apps/web/vite.config.ts' + const viteModule: unknown = await import(viteConfigPath) + if (typeof viteModule !== 'object' || viteModule === null) { + throw new TypeError('web Vite config module must be an object') + } + const viteConfig: unknown = Reflect.get(viteModule, 'default') + if (typeof viteConfig === 'function') throw new TypeError('web Vite config must be an object') + if (typeof viteConfig !== 'object' || viteConfig === null) { + throw new TypeError('web Vite config must be an object') + } + expect(Reflect.get(viteConfig, 'define')).toMatchObject({ + 'process.env': '{}', + [PROBE_KEY]: '"shared-value"', + }) + }) + + it('sets the official client build variant in DSH artifact build workflows', () => { + for (const name of dshBuildWorkflows) { + const path = `.github/workflows/${name}` + const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) + if (typeof document !== 'object' || document === null || Array.isArray(document)) { + throw new TypeError(`${path} must contain a workflow object`) + } + const environment: unknown = Reflect.get(document, 'env') + expect(environment, path).toMatchObject({ DSH_CLIENT_BRAND: 'official' }) + } + }) +}) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts new file mode 100644 index 0000000000..037e491790 --- /dev/null +++ b/scripts/client-build-environment.ts @@ -0,0 +1,24 @@ +/** Prefix reserved for build-time values that may be embedded in browser artifacts. */ +const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_' + +/** + * Create bundler substitutions for public client build environment variables. + * + * The empty `process.env` fallback makes an unset static property read + * evaluate to `undefined` without providing a browser `process` global. + * Exact substitutions remain longer matches than that fallback. Dynamic + * property reads and enumeration deliberately observe the empty object. + * + * @param environment - environment inherited by the build process. + * @returns deterministic Vite/tsdown `define` expressions. + */ +export function clientBuildEnvironmentDefines( + environment: NodeJS.ProcessEnv, +): Record { + const defines: Record = { 'process.env': '{}' } + for (const [name, value] of Object.entries(environment).sort(([left], [right]) => left.localeCompare(right))) { + if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || value === undefined) continue + defines[`process.env.${name}`] = JSON.stringify(value) + } + return defines +} diff --git a/scripts/types/client-build-environment/index.d.ts b/scripts/types/client-build-environment/index.d.ts new file mode 100644 index 0000000000..db7b9f24fe --- /dev/null +++ b/scripts/types/client-build-environment/index.d.ts @@ -0,0 +1,7 @@ +/** Build-time values that bundlers replace before client code reaches a browser. */ +declare const process: { + readonly env: { + readonly NODE_ENV?: string + readonly [name: `DSH_CLIENT_${string}`]: string | undefined + } +} diff --git a/tsconfig.base.client.json b/tsconfig.base.client.json index a6efecbe2d..9222c69f26 100644 --- a/tsconfig.base.client.json +++ b/tsconfig.base.client.json @@ -6,6 +6,7 @@ "compilerOptions": { "jsx": "react-jsx", "lib": ["ES2024", "DOM", "DOM.Iterable"], - "types": [] + "typeRoots": ["./scripts/types", "./node_modules/@types"], + "types": ["client-build-environment"] } } diff --git a/tsconfig.client.json b/tsconfig.client.json index 17dbaff070..de5dc5acf8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -26,6 +26,8 @@ // Client package outside packages/client with a local CSS module face. "packages/extensions/ui-cordis/src/css-modules.d.ts", "packages/client/tsdown.client.ts", + "scripts/client-build-environment.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], diff --git a/tsconfig.host.json b/tsconfig.host.json index bb885e7ecc..a57f9e6901 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -107,6 +107,7 @@ "packages/*/*/tests/**/*.client.spec.ts", "packages/*/*/tests/**/*.client.spec.tsx", "packages/client/tsdown.client.ts", + "scripts/*.client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" From 66a7081c158e5840c0ef717f6c242111482cc563 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:51:58 +0800 Subject: [PATCH 2/6] feat(build): bind client artifacts to build profiles --- ...6-08-18-client-build-environment.i18n.yaml | 4 +- .../2026-08-18-client-build-environment.md | 4 +- .../2026-08-18-client-build-environment.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 3 +- .github/workflows/ci.yml | 3 +- .github/workflows/e2b-e2e.yml | 3 +- .github/workflows/e2e.yml | 3 +- .github/workflows/release.yml | 3 +- .github/workflows/sandbox.yml | 3 +- .gitignore | 1 + apps/web/tests/built-boot.snapshot.ts | 21 ++ apps/web/tests/hmr-live.e2e.ts | 19 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 + docs/development.zh.md | 2 + package.json | 3 +- packages/client/AGENTS.md | 2 +- scripts/build.ts | 55 ++++ scripts/clean.spec.ts | 2 + scripts/clean.ts | 2 + .../client-build-environment.client.spec.ts | 104 ++++++- scripts/client-build-environment.ts | 291 +++++++++++++++++- scripts/run-gates.spec.ts | 6 + scripts/run-gates.ts | 28 +- 24 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 scripts/build.ts 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 index cc9b43da5d..b97fb2d1e8 100644 --- 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 @@ -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-08-18-client-build-environment.md -2026-08-18-client-build-environment.md: 771fdb936d71daad982cb25f58033284941df679 -2026-08-18-client-build-environment.zh.md: 2a5948d64f585f8eade49cff7d925b6a1c73f129 +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 index 771fdb936d..45ed6c8bc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -18,6 +18,8 @@ The Vite config and the shared tsdown preset for dynamic client bundles use one 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`. @@ -30,6 +32,6 @@ The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, p ## 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. CI workflows that produce DSH client artifacts set the required variables explicitly; workflows that do not produce those artifacts do not need them. +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 index 2a5948d64f..bb96337214 100644 --- 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 @@ -18,6 +18,8 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define `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` 的浏览器。 @@ -30,6 +32,6 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define ## Consequences -Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。生成 DSH client 产物的 CI workflow 显式提供所需变量;不生成这些产物的 workflow 不需要携带它们。 +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 12f6daeeae..ba17869f29 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -49,7 +49,6 @@ permissions: contents: read env: - DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' @@ -220,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 264843264a..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,6 @@ permissions: contents: read env: - DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). @@ -300,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 7e8d33072e..0442b0e093 100644 --- a/.github/workflows/e2b-e2e.yml +++ b/.github/workflows/e2b-e2e.yml @@ -9,7 +9,6 @@ permissions: contents: read env: - DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' @@ -47,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 227033892c..3cd2f515ab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,7 +47,6 @@ permissions: contents: read env: - DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' @@ -105,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 b4fab5e1f2..08296468bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,6 @@ concurrency: cancel-in-progress: false env: - DSH_CLIENT_BRAND: official PRIMARY_NODE_VERSION: '24' DSH_TELEMETRY_DISABLED: '1' @@ -76,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 bbc19cf49e..d13e4f7c50 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -19,7 +19,6 @@ permissions: contents: read env: - DSH_CLIENT_BRAND: official # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). DSH_TELEMETRY_DISABLED: '1' @@ -118,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/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 3d7bb3c249..2b5cb35886 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -10,12 +10,33 @@ // 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() 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/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/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/client/AGENTS.md b/packages/client/AGENTS.md index 0a3e21852a..9baa35ebd0 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -68,7 +68,7 @@ Npm sections describe installation and development relationships; each build fac ## 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. Use runtime configuration for choices that must change after build. +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 diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000000..b6f12b0564 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,55 @@ +/** Run the complete repository build and bind its client artifacts to their public environment. */ + +import { spawnSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { + CLIENT_BUILD_RECORD_PATH, + clientBuildProcessEnvironment, + repositoryCommitHash, + resolveClientBuildEnvironment, + writeClientBuildRecord, +} from './client-build-environment.ts' + +/** Run one package script through the package manager that invoked this build. */ +function runScript(script: string, environment: NodeJS.ProcessEnv): void { + const packageManager = process.env.npm_execpath + if (packageManager === undefined || packageManager === '') { + throw new Error('build: npm_execpath is unavailable; invoke the build through a package script') + } + const result = spawnSync(process.execPath, [packageManager, 'run', script], { + cwd: resolve(import.meta.dirname, '..'), + env: environment, + stdio: 'inherit', + }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`build: ${script} exited with ${String(result.status ?? result.signal)}`) + } +} + +/** Run the full build selected by `--profile` or `DSH_BUILD_CLIENT_PROFILE`. */ +function main(): void { + const { values } = parseArgs({ + options: { profile: { type: 'string' } }, + allowPositionals: false, + }) + const root = resolve(import.meta.dirname, '..') + const parentEnvironment = { + ...process.env, + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env), + } + const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile) + const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment) + + rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true }) + runScript('build:lib', buildEnvironment) + runScript('build:web', buildEnvironment) + const record = writeClientBuildRecord(root, clientEnvironment) + console.log( + `build: recorded ${String(record.artifacts.fileCount)} client artifact(s) with ${String(Object.keys(record.environment).length)} public value(s)`, + ) +} + +if (import.meta.main) main() diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index c724453283..bd0c3be9af 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -38,6 +38,7 @@ describe('RepositoryCleaner', () => { write(join(root, 'products/shell/lib/types/index.js')) write(join(root, 'products/shell/lib/index.js')) write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, '.dsh-build/client-build-environment.json')) write(join(root, 'root.tsbuildinfo')) write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) @@ -46,6 +47,7 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, '.dsh-build'))).toBe(false) expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) }) diff --git a/scripts/clean.ts b/scripts/clean.ts index 68e4ff4e71..c0f7d71763 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -67,6 +67,8 @@ export class RepositoryCleaner { const unsafeOrphans: string[] = [] const canonicalRoot = await realpath(this.root) + await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot) + // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot) for (const entry of await readdir(this.root, { withFileTypes: true })) { diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index 2f4f6cb178..cce5cff2a0 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -1,14 +1,25 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import yaml from 'js-yaml' import { afterEach, describe, expect, it, vi } from 'vitest' -import { clientBuildEnvironmentDefines } from './client-build-environment.ts' +import { + assertClientBuildEnvironment, + clientBuildEnvironmentDefines, + clientBuildProcessEnvironment, + readClientBuildRecord, + repositoryCommitHash, + resolveClientBuildEnvironment, + writeClientBuildRecord, +} from './client-build-environment.ts' import { clientBundle } from '../packages/client/tsdown.client.ts' const root = resolve(import.meta.dirname, '..') const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST' +const COMMIT_HASH = '0123456789abcdef0123456789abcdef01234567' const PROBE_KEY = `process.env.${PROBE_NAME}` const originalProbe = process.env[PROBE_NAME] +const roots: string[] = [] const dshBuildWorkflows = [ 'build-exe-for-python-sdk.yml', 'ci.yml', @@ -22,9 +33,74 @@ afterEach(() => { if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME) else process.env[PROBE_NAME] = originalProbe vi.resetModules() + for (const fixtureRoot of roots.splice(0)) rmSync(fixtureRoot, { recursive: true, force: true }) }) +function write(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function buildFixture(environment: Record): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'apps/web/dist/index.html'), '
') + write(join(fixtureRoot, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(fixtureRoot, environment) + return fixtureRoot +} + describe('client build environment', () => { + it('requires an exact public environment for a named artifact profile', () => { + const expected = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + } as const + + expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow() + expect(() => { assertClientBuildEnvironment({}, expected) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { assertClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Other' }, expected) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { + assertClientBuildEnvironment({ ...expected, DSH_CLIENT_UNDECLARED: 'value' }, expected) + }).toThrow(/DSH_CLIENT_UNDECLARED/) + }) + + it('inherits public values by default and isolates an explicit official profile', () => { + const parent = { + PATH: '/bin', + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_BUILD_PROFILE: 'local', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'Local title', + DSH_CLIENT_EXTRA: 'local-extra', + } + + expect(resolveClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Local title' })).toEqual({ + DSH_CLIENT_TITLE: 'Local title', + }) + expect(resolveClientBuildEnvironment(parent)).toEqual({ + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + }) + expect(() => { + resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' }) + }).toThrow(/DSH_CLIENT_COMMIT_HASH/) + expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/) + expect(clientBuildProcessEnvironment(parent, { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + })).toEqual({ + PATH: '/bin', + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + }) + expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7)) + }) + it('defines only public client values over a non-enumerable fallback', () => { expect(clientBuildEnvironmentDefines({ PATH: '/bin', @@ -69,15 +145,31 @@ describe('client build environment', () => { }) }) - it('sets the official client build variant in DSH artifact build workflows', () => { + it('binds the recorded environment to a complete set of client artifacts', () => { + const officialEnvironment = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_TITLE: 'DeepSeek Harness', + } + const official = buildFixture(officialEnvironment) + const defaultBuild = buildFixture({}) + + expect(readClientBuildRecord(official, officialEnvironment).environment).toEqual(officialEnvironment) + expect(() => { readClientBuildRecord(defaultBuild, officialEnvironment) }).toThrow(/DSH_CLIENT_/) + expect(() => { readClientBuildRecord(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) + + write(join(official, 'apps/web/dist/index.html'), '
changed
') + expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/) + }) + + it('keeps public client values out of workflow-wide environments', () => { for (const name of dshBuildWorkflows) { const path = `.github/workflows/${name}` const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) if (typeof document !== 'object' || document === null || Array.isArray(document)) { throw new TypeError(`${path} must contain a workflow object`) } - const environment: unknown = Reflect.get(document, 'env') - expect(environment, path).toMatchObject({ DSH_CLIENT_BRAND: 'official' }) + expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_') } }) }) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts index 037e491790..2331db5f42 100644 --- a/scripts/client-build-environment.ts +++ b/scripts/client-build-environment.ts @@ -1,6 +1,172 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { + existsSync, + globSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, resolve } from 'node:path' + /** Prefix reserved for build-time values that may be embedded in browser artifacts. */ const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_' +/** Non-public selector used by build orchestration to request a named client profile. */ +export const CLIENT_BUILD_PROFILE_SELECTOR = 'DSH_BUILD_CLIENT_PROFILE' + +/** Public client environment required by official DSH artifacts. */ +const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = { + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_TITLE: 'DeepSeek Harness', +} as const + +/** Public variable carrying the source commit embedded in client artifacts. */ +const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH' + +/** Repository-relative path of the complete client build record. */ +export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json' + +const CLIENT_BUILD_RECORD_FORMAT = 1 +const CLIENT_ARTIFACT_PATTERNS = [ + 'apps/web/dist/**/*', + 'packages/*/*/lib/client.js', + 'packages/*/*/lib/client.js.map', +] as const + +/** Public values embedded in one set of client artifacts. */ +export type ClientBuildEnvironment = Readonly> + +/** + * Resolve the short source commit used by browser build metadata. + * @param root - repository root used when no explicit value is supplied. + * @param environment - environment that may already carry a commit value. + * @returns lowercase 7-character Git commit prefix. + */ +export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEnv = process.env): string { + const explicit = environment[CLIENT_COMMIT_HASH_VARIABLE] + const value = explicit ?? execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (!/^[0-9a-f]{7,40}$/iu.test(value)) { + throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} must be a Git commit hash; got ${JSON.stringify(value)}`) + } + return value.slice(0, 7).toLowerCase() +} + +/** + * Resolve the exact public values required by an official build at one commit. + * @param root - repository root whose HEAD must match the built source. + * @param environment - optional explicit commit source for non-Git build environments. + * @returns complete official client environment. + */ +export function officialClientBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): Readonly> { + return { + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, + } +} + +/** Digest of every client artifact produced by the complete root build. */ +interface ClientArtifactDigest { + /** Number of files covered by the digest. */ + readonly fileCount: number + /** Lowercase SHA-256 digest of sorted paths and file contents. */ + readonly sha256: string +} + +/** Durable description of one complete root client build. */ +export interface ClientBuildRecord { + /** Record schema version. */ + readonly formatVersion: number + /** Exact public environment embedded by Vite and tsdown. */ + readonly environment: ClientBuildEnvironment + /** Digest that binds the environment to the current artifacts. */ + readonly artifacts: ClientArtifactDigest +} + +/** + * Collect the public client environment in deterministic key order. + * @param environment - environment inherited by the build process. + * @returns defined `DSH_CLIENT_*` values only. + */ +function clientBuildEnvironment(environment: NodeJS.ProcessEnv): ClientBuildEnvironment { + return Object.fromEntries(Object.entries(environment) + .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined) + .sort(([left], [right]) => left.localeCompare(right))) as Record +} + +/** + * Resolve the exact public environment selected for a complete client build. + * @param environment - parent process environment. + * @param profile - explicit profile, or the non-public selector when omitted. + * @returns the inherited public values when no profile is selected, otherwise the named profile. + */ +export function resolveClientBuildEnvironment( + environment: NodeJS.ProcessEnv, + profile: string | undefined = environment[CLIENT_BUILD_PROFILE_SELECTOR], +): ClientBuildEnvironment { + if (profile === undefined) return clientBuildEnvironment(environment) + if (profile === 'official') { + const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE] + if (commitHash === undefined) { + throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`) + } + return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT } + } + throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`) +} + +/** + * Construct a subprocess environment containing exactly the selected public values. + * @param environment - parent process environment. + * @param clientEnvironment - complete public environment selected for the build. + * @returns the parent environment with selectors and inherited public values replaced. + */ +export function clientBuildProcessEnvironment( + environment: NodeJS.ProcessEnv, + clientEnvironment: ClientBuildEnvironment, +): NodeJS.ProcessEnv { + const child: NodeJS.ProcessEnv = {} + for (const [name, value] of Object.entries(environment)) { + if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith(CLIENT_BUILD_ENV_PREFIX)) continue + child[name] = value + } + return { ...child, ...clientEnvironment } +} + +/** + * Require the public client environment to match an artifact profile exactly. + * + * An exact key set matters because every prefixed value is eligible for + * inlining: an unexpected variable can change published bytes just as surely + * as a missing or incorrect required value. + * + * @param environment - public environment from a build process or build record. + * @param expected - complete public client environment for the artifact profile. + */ +export function assertClientBuildEnvironment( + environment: Readonly>, + expected: Readonly>, +): void { + const actual = Object.fromEntries(Object.entries(environment) + .filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined) + .sort(([left], [right]) => left.localeCompare(right))) + const normalizedExpected = Object.fromEntries(Object.entries(expected) + .sort(([left], [right]) => left.localeCompare(right))) + if (JSON.stringify(actual) === JSON.stringify(normalizedExpected)) return + + const names = [...new Set([...Object.keys(actual), ...Object.keys(normalizedExpected)])].sort() + const differences = names.filter(name => actual[name] !== normalizedExpected[name]) + throw new Error(`client build environment differs from the required artifact profile: ${differences.join(', ')}`) +} + /** * Create bundler substitutions for public client build environment variables. * @@ -16,9 +182,130 @@ export function clientBuildEnvironmentDefines( environment: NodeJS.ProcessEnv, ): Record { const defines: Record = { 'process.env': '{}' } - for (const [name, value] of Object.entries(environment).sort(([left], [right]) => left.localeCompare(right))) { - if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || value === undefined) continue + for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) { defines[`process.env.${name}`] = JSON.stringify(value) } return defines } + +/** + * Write the build record after a complete root build succeeds. + * @param root - repository root containing the generated artifacts. + * @param environment - exact public environment supplied to both bundlers. + * @returns the record written to disk. + */ +export function writeClientBuildRecord( + root: string, + environment: ClientBuildEnvironment, +): ClientBuildRecord { + const record: ClientBuildRecord = { + formatVersion: CLIENT_BUILD_RECORD_FORMAT, + environment: clientBuildEnvironment(environment), + artifacts: clientArtifactDigest(root), + } + const path = resolve(root, CLIENT_BUILD_RECORD_PATH) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`) + return record +} + +/** + * Read a complete build record and prove it still describes the current artifacts. + * @param root - repository root containing the record and generated artifacts. + * @param expected - optional exact public environment required by a consumer. + * @returns the parsed and artifact-verified record. + */ +export function readClientBuildRecord( + root: string, + expected?: Readonly>, +): ClientBuildRecord { + const path = resolve(root, CLIENT_BUILD_RECORD_PATH) + if (!existsSync(path)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is missing; run a complete pnpm run build first`) + } + + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is invalid JSON: ${detail}`) + } + const record = parseClientBuildRecord(parsed) + if (expected !== undefined) assertClientBuildEnvironment(record.environment, expected) + + const current = clientArtifactDigest(root) + if (current.fileCount !== record.artifacts.fileCount || current.sha256 !== record.artifacts.sha256) { + throw new Error( + `client artifacts differ from ${CLIENT_BUILD_RECORD_PATH}; run a complete pnpm run build before consuming them`, + ) + } + return record +} + +/** Return the deterministic digest of every artifact affected by the public client environment. */ +function clientArtifactDigest(root: string): ClientArtifactDigest { + const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root }) + .map(path => path.replaceAll('\\', '/')) + .filter(path => statSync(resolve(root, path)).isFile()) + .sort() + if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts') + + const digest = createHash('sha256') + for (const path of paths) { + const content = readFileSync(resolve(root, path)) + digest.update(`${Buffer.byteLength(path)}:`) + digest.update(path) + digest.update(`${content.byteLength}:`) + digest.update(content) + } + return { fileCount: paths.length, sha256: digest.digest('hex') } +} + +/** Parse and validate the persisted record before any consumer trusts it. */ +function parseClientBuildRecord(value: unknown): ClientBuildRecord { + if (!isObject(value) || !hasExactKeys(value, ['artifacts', 'environment', 'formatVersion'])) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid top-level schema`) + } + if (value.formatVersion !== CLIENT_BUILD_RECORD_FORMAT) { + throw new Error( + `client build record ${CLIENT_BUILD_RECORD_PATH} uses format ${String(value.formatVersion)}; expected ${String(CLIENT_BUILD_RECORD_FORMAT)}`, + ) + } + if (!isObject(value.environment)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment`) + } + const environment: Record = {} + for (const [name, entry] of Object.entries(value.environment).sort(([left], [right]) => left.localeCompare(right))) { + if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || typeof entry !== 'string') { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment entry ${name}`) + } + environment[name] = entry + } + if (!isObject(value.artifacts) || !hasExactKeys(value.artifacts, ['fileCount', 'sha256'])) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact digest`) + } + if (!Number.isSafeInteger(value.artifacts.fileCount) || Number(value.artifacts.fileCount) < 1) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact count`) + } + if (typeof value.artifacts.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.artifacts.sha256)) { + throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid SHA-256 digest`) + } + return { + formatVersion: CLIENT_BUILD_RECORD_FORMAT, + environment, + artifacts: { + fileCount: Number(value.artifacts.fileCount), + sha256: value.artifacts.sha256, + }, + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index dce448b2e0..8de5d1e095 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -344,6 +344,12 @@ describe('Node 24 lane ownership', () => { 'built-bin-smoke', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) + expect(subject.find(item => item.id === 'build')?.env).toEqual({ + DSH_BUILD_CLIENT_PROFILE: 'official', + }) + expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({ + DSH_BUILD_CLIENT_PROFILE: 'official', + }) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of [ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f65fed97e..ee021782f9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' import { COVERAGE_PARTITIONS_ENV, @@ -176,6 +177,14 @@ function pnpmScript(id: string, script: string, options: Partial = {}): Ga } } +/** Build official client artifacts inside a CI aggregate without changing sibling gate environments. */ +function ciBuildGate(id = 'build', options: Partial = {}): Gate { + return pnpmScript(id, 'build', { + ...options, + env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' }, + }) +} + function pnpmExec(id: string, args: string[], options: Partial = {}): Gate { return { id, @@ -216,7 +225,7 @@ export function gatesForMode(selected: Mode): Gate[] { case 'ci-coverage': return coverageGates() case 'ci-snapshot': - return [pnpmScript('build', 'build'), snapshotGate()] + return [ciBuildGate(), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() case 'ci-consumers': @@ -287,7 +296,7 @@ function ciPrimaryGates(): Gate[] { // The prepared typecheck and build both drive Client tsc, while build also // repeats the Host contract pass. Wait for all three consumers so build // neither races tsbuildinfo nor replaces declarations while they are read. - pnpmScript('build', 'build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }), + ciBuildGate('build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -369,7 +378,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ ...ciSharedStaticGates(), - ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], + ...options.ownsBuild ? [ciBuildGate()] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, ...options.ownsBuild @@ -388,7 +397,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { function ciArtifactGates(): Gate[] { return [ - pnpmScript('build', 'build'), + ciBuildGate(), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -403,8 +412,11 @@ function ciConsumerGates(): Gate[] { const builtTree = ['build'] const validatedBuild = ['built-package-invariants'] return [ - pnpmScript('build', 'build'), - pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + ciBuildGate(), + pnpmScript('node-compat', 'check:node-compat', { + label: 'Node compatibility', + env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' }, + }), pnpmScript('publint', 'publint', { needs: builtTree }), builtPackageInvariantsGate(builtTree), pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', { @@ -450,7 +462,7 @@ function webSnapshotGate(needs: string[]): Gate { function ciWindowsBlockingGates(): Gate[] { return [ - pnpmScript('windows-build', 'build', { label: 'build' }), + ciBuildGate('windows-build', { label: 'build' }), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), ] } @@ -470,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] { after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ - pnpmScript('build', 'build'), + ciBuildGate(), pnpmScript('windows-site', 'docs:build', { label: 'production site' }), ...coverage, ...observational, From 738dcced9bcbdf0a3b7e9362c9bacc2aa6510872 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:53:24 +0800 Subject: [PATCH 3/6] feat(release): validate client build artifacts --- scripts/release/families.spec.ts | 45 ++++++++++++++++++++++++++++++-- scripts/release/families.ts | 16 ++++++++++++ scripts/release/pack.ts | 1 + 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 22eb0b064a..46ce6d92d0 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -1,7 +1,10 @@ /** Release family discovery, publish order, tag naming, and the bump judgements. */ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts' import { releaseFamily, type ReleaseMember } from './families.ts' import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts' @@ -16,6 +19,27 @@ function member(directory: string, name: string, manifest: Record): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-')) + roots.push(root) + write(join(root, 'apps/web/dist/index.html'), '
') + write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(root, environment) + return root +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.unstubAllEnvs() +}) + describe('release families', () => { it('excludes private experimental packages from the dsh release', () => { const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..')) @@ -57,6 +81,23 @@ describe('release families', () => { expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/) }) + it('requires a current official client build only for dsh artifacts', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const officialEnvironment = officialClientBuildEnvironment(resolve(import.meta.dirname, '../..')) + vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH) + const official = buildFixture(officialEnvironment) + const defaultBuild = buildFixture({}) + + expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow() + expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/) + expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) + expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow() + + write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') + expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/) + }) + it('publishes a dependency before its consumer, and orders ties by name', () => { const dsh = releaseFamily('dsh') const members = [ diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 7ce5566831..a847371631 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -11,6 +11,10 @@ import { globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { + officialClientBuildEnvironment, + readClientBuildRecord, +} from '../client-build-environment.ts' import { validateTarballPayload } from '../publication-payload.ts' /** @@ -111,6 +115,13 @@ export abstract class ReleaseFamily { /** Git tag prefix this family publishes from. */ abstract readonly tagPrefix: string + /** + * Assert that built artifacts match this release family's required profile. + * Families without environment-selected artifacts accept every build tree. + * @param _root - repository root containing generated artifacts. + */ + verifyBuildArtifacts(_root: string): void {} + /** * Discover this family's members. * @param root - repository root. @@ -311,6 +322,11 @@ class DshFamily extends ReleaseFamily { readonly patterns = ['packages/!(experimental)/*/package.json', 'apps/*/package.json'] as const readonly tagPrefix = 'dsh-v' + /** Require current artifacts from a complete official client build. */ + override verifyBuildArtifacts(root: string): void { + readClientBuildRecord(root, officialClientBuildEnvironment(root)) + } + /** * Require one version across the family, the way a single tag can name it. * @param members - this family's members. diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 5d2b9b4e64..3b68a1e49c 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -46,6 +46,7 @@ function main(): void { const root = process.cwd() const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) const members = family.publishOrder(family.members(root)).order + family.verifyBuildArtifacts(root) family.verifyVersions(members) rmSync(destination, { recursive: true, force: true }) From 319d9a79841eaa3272a1fdfdd69afc9917d7d500 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:53:57 +0800 Subject: [PATCH 4/6] feat(client): compose deployment branding through slots --- apps/web/index.html | 2 +- apps/web/tests/built-boot.snapshot.ts | 2 + apps/web/tests/smoke-real.e2e.ts | 9 +- apps/web/tests/startup-auto-selection.e2e.ts | 4 +- apps/web/vite.config.ts | 19 ++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 6 + docs/module-graph.zh.md | 6 + packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-brand-official/README.i18n.yaml | 6 + packages/client/ui-brand-official/README.md | 20 +++ .../client/ui-brand-official/README.zh.md | 20 +++ .../client/ui-brand-official/package.json | 72 +++++++++++ .../ui-brand-official/src/client/Brand.tsx | 22 ++++ .../ui-brand-official/src/client/index.ts | 23 ++++ .../client/ui-brand-official/src/index.ts | 7 ++ .../client/ui-brand-official/src/invariant.ts | 30 +++++ .../tests/browser-plugin.client.spec.tsx | 79 ++++++++++++ .../tests/invariant.client.spec.ts | 18 +++ .../client/ui-brand-official/tsconfig.json | 30 +++++ .../client/ui-brand-official/tsdown.config.ts | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/slots.ts | 14 +++ .../ui-conversation/src/client/index.ts | 2 +- .../src/client/skeleton/ConversationRoot.tsx | 2 +- .../src/client/skeleton/EmptyHero.tsx | 8 +- .../tests/chat-apply.client.spec.tsx | 1 + .../tests/skeleton.client.spec.tsx | 8 +- .../ui-primitives/src/BrandWordmark.tsx | 16 ++- packages/client/ui-primitives/src/index.ts | 1 + .../ui-primitives/tests/icons.client.spec.tsx | 13 ++ .../ui-renderer/src/client/DocumentTitle.tsx | 14 ++- .../ui-renderer/tests/app.client.spec.tsx | 9 +- .../tests/document-title.client.spec.tsx | 15 ++- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 6 +- packages/client/ui-sidebar/README.zh.md | 6 +- .../src/client/SidebarRoot.module.css | 57 ++++++++- .../ui-sidebar/src/client/SidebarRoot.tsx | 30 ++++- .../ui-sidebar/src/client/contract/slots.ts | 31 ++++- .../client/ui-sidebar/src/client/index.ts | 6 +- .../sidebar-snapshot.client.spec.tsx.snap | 119 ++++++++++++++---- .../ui-sidebar/tests/apply.client.spec.tsx | 4 + .../tests/sidebar-root.client.spec.tsx | 23 ++++ .../tests/sidebar-snapshot.client.spec.tsx | 9 +- .../tests/sidebar-styles.client.spec.ts | 9 ++ .../src/client/slot-catalog.ts | 102 +++++++++++++-- pnpm-lock.yaml | 36 ++++++ .../request-response.expected.json | 4 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 62 files changed, 868 insertions(+), 92 deletions(-) create mode 100644 packages/client/ui-brand-official/README.i18n.yaml create mode 100644 packages/client/ui-brand-official/README.md create mode 100644 packages/client/ui-brand-official/README.zh.md create mode 100644 packages/client/ui-brand-official/package.json create mode 100644 packages/client/ui-brand-official/src/client/Brand.tsx create mode 100644 packages/client/ui-brand-official/src/client/index.ts create mode 100644 packages/client/ui-brand-official/src/index.ts create mode 100644 packages/client/ui-brand-official/src/invariant.ts create mode 100644 packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx create mode 100644 packages/client/ui-brand-official/tests/invariant.client.spec.ts create mode 100644 packages/client/ui-brand-official/tsconfig.json create mode 100644 packages/client/ui-brand-official/tsdown.config.ts 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 2b5cb35886..0e48e08ec0 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -42,6 +42,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // 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/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 5e57eee177..cd27136cb7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,6 +8,23 @@ 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 { @@ -91,7 +108,7 @@ function npmPackageOf(id: string): string | undefined { } export default defineConfig({ - plugins: [rejectStandaloneServe(), react()], + plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()], build: { sourcemap: true, rollupOptions: { 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/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/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/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/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..2db2db153b 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,14 @@ 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') + expect(renderSlot.mock.calls[0]?.[1]).toEqual({ size: 34 }) + 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 (
- {/* Expanded, the wordmark doubles as a New Session shortcut; the + {/* Expanded, the brand doubles as a New Session shortcut; the collapsed rail's logo is the expand toggle below instead. */} {wide && ( )} {/* Rail resting state is the whale mark; hovering swaps in the panel @@ -149,7 +163,11 @@ export function SidebarRoot({ aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')} onClick={() => { toggleSidebar() }} > - {!wide && } + {!wide && ( + + )} {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */} diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index a8c4f2894d..65f0102543 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -15,6 +15,17 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** + * Brand mark rendered in the expanded brand row and collapsed rail. + * Declared by this package's `sidebar` entry; deployments may replace + * the shell's fish fallback without replacing the surrounding controls. + */ + 'sidebar.brand.mark': { kind: 'single'; scope: 'root'; owner: SidebarBrandMarkOwnerProps } + /** + * Brand name rendered beside the expanded mark. Declared by this + * package's `sidebar` entry; the shell supplies a generic text fallback. + */ + 'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps } /** * The workspace/session browsing region: section header, search, the * grouped/flat session list, and every workspace dialog. Declared by this @@ -36,6 +47,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } +/** Geometry supplied to the sidebar brand-mark occupant. */ +export interface SidebarBrandMarkOwnerProps { + /** Requested square edge in pixels. */ + size: number +} + +/** Empty owner share for the sidebar brand-name occupant. */ +export interface SidebarBrandNameOwnerProps { + /** Marker field: the occupant owns its own content and width. */ + children?: never +} + /** * Owner share of the browser hole — the only facts crossing the shell/region * boundary. Business data and actions arrive through the region's own inject. @@ -85,5 +108,11 @@ export type SidebarRootInjected = { */ export type SidebarRootComponentProps = PropsRuntime<'sidebar'> - & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'> + & PropsRenderSlots< + | 'sidebar.brand.mark' + | 'sidebar.brand.name' + | 'sidebar.workspaces' + | 'sidebar.settings' + | 'sidebar.footer.action' + > & SidebarRootInjected & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0bd2c98295..5b8ac288f7 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -7,8 +7,8 @@ import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' export type { - SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarRootInjected, - SidebarSectionOwnerProps, SidebarSettingsOwnerProps, + SidebarBrandMarkOwnerProps, SidebarBrandNameOwnerProps, SidebarFooterActionOwnerProps, + SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, } from './contract/slots.ts' export type { SidebarKey } from './locales.ts' @@ -45,6 +45,8 @@ export function apply(ctx: ClientContext): void { // region (header, search, session list, workspace dialogs), ui-settings // registers the foot trigger + settings panel. children: { + 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, + 'sidebar.brand.name': { kind: 'single', scope: 'root' }, 'sidebar.workspaces': { kind: 'single', scope: 'root' }, 'sidebar.settings': { kind: 'single', scope: 'root' }, 'sidebar.footer.action': { kind: 'list', scope: 'root' }, diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index f82fbc1656..a8ef9f5d4e 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -17,15 +17,24 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad class="iconButton toggle" type="button" > - + class="railMark" + > +
+ +
+ renders the expanded column (wordmark, capsul class="brand wide" type="button" > - + class="brandIdentity" + > + +
+ +
+
+ +
+ + DSH Local Build + + + abc1234 + +
+
+