mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2665 from deepseek-harness/worktree-nologo
feat(client): configure build-time branding
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
|
||||
2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431
|
||||
2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Build-time public environment variables for client business code
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-18-client-build-environment.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type.
|
||||
|
||||
Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published.
|
||||
|
||||
## Decision
|
||||
|
||||
`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`.
|
||||
|
||||
The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability.
|
||||
|
||||
The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it.
|
||||
|
||||
The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`.
|
||||
|
||||
**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable.
|
||||
|
||||
**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices.
|
||||
|
||||
**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption.
|
||||
|
||||
Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Client 业务代码使用构建期公开环境变量
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-18-client-build-environment.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。
|
||||
|
||||
浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。
|
||||
|
||||
## Decision
|
||||
|
||||
`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。
|
||||
|
||||
Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。
|
||||
|
||||
`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。
|
||||
|
||||
根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。
|
||||
|
||||
**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。
|
||||
|
||||
**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。
|
||||
|
||||
**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。
|
||||
|
||||
## Consequences
|
||||
|
||||
Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。
|
||||
|
||||
任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。
|
||||
@@ -219,6 +219,8 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Build single-exe
|
||||
env:
|
||||
DSH_BUILD_CLIENT_PROFILE: official
|
||||
run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
|
||||
|
||||
- name: Resolve platform outputs
|
||||
|
||||
@@ -299,6 +299,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run compatibility smokes
|
||||
env:
|
||||
DSH_BUILD_CLIENT_PROFILE: official
|
||||
run: pnpm run check:node-compat
|
||||
|
||||
python-sdk:
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
# The Loader smoke runs package exports under plain Node in lib mode.
|
||||
- name: Build (lib for the E2B Loader smoke)
|
||||
run: pnpm run build
|
||||
run: pnpm run build:official
|
||||
|
||||
- name: E2B tests (live sandbox)
|
||||
env:
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
# the built artifact under plain Node, resolving plugins through real package
|
||||
# exports — the shape a real consumer runs. That requires a prior build.
|
||||
- name: Build (lib for the e2e example bins)
|
||||
run: pnpm run build
|
||||
run: pnpm run build:official
|
||||
|
||||
# Real-API end-to-end tests only. The keyless gates (lint/typecheck/
|
||||
# coverage/snapshot/etc.) already run in ci.yml on every push/PR.
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
run: pnpm run release:verify --family dsh
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
run: pnpm run build:official
|
||||
|
||||
- name: Pack release tarballs
|
||||
run: pnpm run release:pack --family dsh --out dist/npm
|
||||
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
# together so registry state cannot mask source/package drift.
|
||||
- name: Build packages for the pack rehearsal
|
||||
if: matrix.runner == 'landlock'
|
||||
run: pnpm run build
|
||||
run: pnpm run build:official
|
||||
|
||||
- name: Packed-distribution e2e (pack → install → confine)
|
||||
if: matrix.runner == 'landlock'
|
||||
|
||||
@@ -32,6 +32,7 @@ python/**/__pycache__/
|
||||
python/**/.pytest_cache/
|
||||
apps/web/dist/
|
||||
.artifacts/
|
||||
.dsh-build/
|
||||
.playwright-mcp/
|
||||
.orig
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# DeepSeek Harness 品牌素材使用规范
|
||||
|
||||
为了维护 DeepSeek Harness 生态的长期健康发展,避免用户混淆,方便用户对相关资源进行检索和识别,我们特别制定本规范,希望大家共同遵守:
|
||||
|
||||
- 在项目的描述性文字中,您可以使用“DeepSeek Harness ”真实、准确地说明您的项目与 DeepSeek Harness 的关系,例如“基于 DeepSeek Harness 构建”或“兼容 DeepSeek Harness”等。这类说明符合许可证的要求,也有助于用户理解项目的定位。
|
||||
- 如果您希望项目名称能体现与 DeepSeek Harness 生态的关联,我们建议使用缩写的 **“DSH”** 标识来命名,这样既清晰又便于社区内的交流。
|
||||
- 在项目命名时,请避免直接使用完整的 **“DeepSeek Harness”** 商标。**“DeepSeek Harness”** 是深度求索公司的注册商标,未经授权用于项目名,容易引发用户的误解和混淆,从而影响整个生态的清晰度。同时,也可能涉及商标侵权行为。
|
||||
- 此外,请您避免在宣传或展示时,以容易引起误解的方式使用官方品牌素材,以免让用户产生官方背书、合作或授权等不实印象。
|
||||
|
||||
我们相信,一个清晰、有序的社区环境,能让每一位开发者的努力都更容易被看见和认可。对于少数不符合上述规范的情况,我们可能会联系相关方进行适当的调整,以维护生态整体的秩序。感谢大家的理解与支持,让我们一起构建一个更友好、更可持续发展的开源社区。
|
||||
|
||||
# DeepSeek Harness Brand Asset Usage Guidelines
|
||||
|
||||
|
||||
To maintain the long\-term healthy development of the DeepSeek Harness ecosystem, avoid user confusion, and facilitate the retrieval and identification of related resources, we have established these specifications and hope that everyone will adhere to them:
|
||||
|
||||
- In your project's descriptive text, you may use "DeepSeek Harness" to truthfully and accurately describe your project's relationship with DeepSeek Harness, for example, "built on DeepSeek Harness" or "compatible with DeepSeek Harness\." Such descriptions comply with license requirements and help users understand your project's positioning\.
|
||||
- If you wish your project name to reflect its association with the DeepSeek Harness ecosystem, we recommend using the abbreviated "DSH" designation for naming, which is both clear and facilitates communication within the community\.
|
||||
- When naming your project, please avoid using the full "DeepSeek Harness" trademark directly\. "DeepSeek Harness" is a registered trademark of DeepSeek\. Unauthorized use in project names can easily lead to user misunderstanding and confusion, thereby affecting the clarity of the entire ecosystem\. It may also involve trademark infringement\.
|
||||
- Additionally, please avoid using official brand materials in your promotions or presentations in a way that could cause misunderstanding, so as not to give users the false impression of official endorsement, cooperation, or authorization\.
|
||||
|
||||
We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>DeepSeek Harness</title>
|
||||
<title>DSH Local Build</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,17 +10,40 @@
|
||||
// benches over src). This smoke additionally pins the resident interaction
|
||||
// fixture's cross-plugin projection because only the built connection/runtime/
|
||||
// workspace graph can prove that transport-to-row path end to end.
|
||||
import { resolve } from 'node:path'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
|
||||
|
||||
installAssembledBootEnv()
|
||||
|
||||
const buildEnvironmentModulePath = '../../../scripts/client-build-environment.ts'
|
||||
const buildEnvironmentModule: unknown = await import(buildEnvironmentModulePath)
|
||||
if (typeof buildEnvironmentModule !== 'object' || buildEnvironmentModule === null) {
|
||||
throw new TypeError('client build environment module must be an object')
|
||||
}
|
||||
const readClientBuildRecord: unknown = Reflect.get(buildEnvironmentModule, 'readClientBuildRecord')
|
||||
if (!isBuildRecordReader(readClientBuildRecord)) {
|
||||
throw new TypeError('client build environment module must export readClientBuildRecord')
|
||||
}
|
||||
const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..'))
|
||||
if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object')
|
||||
const clientBuildEnvironment: unknown = Reflect.get(record, 'environment')
|
||||
if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) {
|
||||
throw new TypeError('client build record environment must be an object')
|
||||
}
|
||||
|
||||
function isBuildRecordReader(value: unknown): value is (root: string) => unknown {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
it('boots the built plugin graph and renders a fixture session end to end', async () => {
|
||||
mountAssembledApp()
|
||||
|
||||
// The sidebar renders from the boot graph: every inject layer activated.
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull()
|
||||
expect(screen.queryByText('DSH Local Build')).toBeNull()
|
||||
// The compact layout dropped group session counts; the fixture workspace
|
||||
// group row renders immediately with its sessions beneath it.
|
||||
const fixtureGroup = (await within(tree).findAllByText('fixture'))
|
||||
|
||||
@@ -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<string, string>): SubprocessSpawnSpec {
|
||||
@@ -70,11 +71,13 @@ async function stopTree(child: SubprocessHandle): Promise<void> {
|
||||
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))
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
|
||||
+20
-1
@@ -2,11 +2,29 @@ import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
|
||||
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
|
||||
const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
|
||||
|
||||
/** Escape build-time text before placing it in the HTML title element. */
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').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('<title>DSH Local Build</title>', `<title>${title}</title>`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
@@ -90,7 +108,7 @@ function npmPackageOf(id: string): string | undefined {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [rejectStandaloneServe(), react()],
|
||||
plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
@@ -146,6 +164,7 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
define: {
|
||||
...clientBuildEnvironmentDefines(process.env),
|
||||
// vendored loader internal.ts: fromInternal() probes the Node major —
|
||||
// "0.0.0" takes neither branch, returning undefined (exactly the empty
|
||||
// internal slot the shell boot fills with the client module loader).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -66,6 +66,10 @@ Npm sections describe installation and development relationships; each build fac
|
||||
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
|
||||
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
|
||||
|
||||
## Build-time browser environment
|
||||
|
||||
Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
|
||||
|
||||
## Shared modules and the module graph
|
||||
|
||||
A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) | 编排工具调用树和按工具键控的视图。 |
|
||||
|
||||
Vendored
-5
@@ -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 } }
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 <FishLogo size={size} className={className} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the official name artwork without its independently slotted mark.
|
||||
* @returns the official name wordmark.
|
||||
*/
|
||||
export function OfficialBrandName() {
|
||||
return <BrandWordmark includeMark={false} />
|
||||
}
|
||||
@@ -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)
|
||||
})))
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 */
|
||||
@@ -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(<OfficialBrandName />)
|
||||
expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24')
|
||||
name.unmount()
|
||||
|
||||
const mark = render(<OfficialBrandMark size={34} className="hero-mark" />)
|
||||
expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34')
|
||||
expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark')
|
||||
mark.rerender(<OfficialBrandMark size={24} />)
|
||||
expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'])
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 是更靠前的前提。
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ConversationRoot({
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell t={t} />}
|
||||
{hero && <HeroShell t={t} renderSlot={renderSlot} />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<span className={css.fishHitbox}>
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
|
||||
fallback: <FishLogo size={34} className={css.fish} />,
|
||||
})}
|
||||
</span>
|
||||
<span className={css.headlineText}>{t('hero.headline')}</span>
|
||||
<span className={css.previewBadge}>{t('hero.preview')}</span>
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -22,6 +22,7 @@ import { en, zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
|
||||
import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type {
|
||||
@@ -259,9 +260,19 @@ function mount(
|
||||
|
||||
describe('Hero chrome', () => {
|
||||
it('renders the English preview badge through the hero locale seat', () => {
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
|
||||
const renderSlot = vi.fn<HeroShellProps['renderSlot']>(() => null)
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} renderSlot={renderSlot} />)
|
||||
expect(view.getByText('Into the Unknown')).toBeTruthy()
|
||||
expect(view.getByText('Preview')).toBeTruthy()
|
||||
expect(renderSlot).toHaveBeenCalledOnce()
|
||||
expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark')
|
||||
const brandMarkOwner = renderSlot.mock.calls[0]?.[1]
|
||||
if (brandMarkOwner === undefined || !('size' in brandMarkOwner) || !('className' in brandMarkOwner)) {
|
||||
throw new Error('hero brand-mark owner must provide size and className')
|
||||
}
|
||||
expect(brandMarkOwner.size).toBe(34)
|
||||
expect(brandMarkOwner.className).toBeTypeOf('string')
|
||||
expect(renderSlot.mock.calls[0]?.[2]?.fallback).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={(size * 182) / 24}
|
||||
width={(size * width) / 24}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 182 24"
|
||||
viewBox={includeMark ? '0 0 182 24' : '26 0 156 24'}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -24,6 +24,7 @@ export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export type { BrandWordmarkProps } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { Toast } from './Toast.tsx'
|
||||
|
||||
@@ -66,3 +66,16 @@ describe('FishLogo', () => {
|
||||
expect(container.innerHTML).not.toContain('M0 0L23.16')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BrandWordmark', () => {
|
||||
it('can render the name artwork with or without its leading mark', () => {
|
||||
const view = render(<primitives.BrandWordmark />)
|
||||
const svg = view.container.querySelector('svg')!
|
||||
expect(svg.getAttribute('width')).toBe('182')
|
||||
expect(svg.getAttribute('viewBox')).toBe('0 0 182 24')
|
||||
|
||||
view.rerender(<primitives.BrandWordmark includeMark={false} />)
|
||||
expect(svg.getAttribute('width')).toBe('156')
|
||||
expect(svg.getAttribute('viewBox')).toBe('26 0 156 24')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
|
||||
|
||||
/** Props for the browser title projection. */
|
||||
export interface DocumentTitleProps {
|
||||
@@ -8,15 +10,15 @@ export interface DocumentTitleProps {
|
||||
|
||||
/**
|
||||
* Project the selected durable session title into the browser title and
|
||||
* restore the original product title when unmounted.
|
||||
* restore the build-selected product title when unmounted.
|
||||
* @param props - Selected session title projection.
|
||||
* @returns No rendered content.
|
||||
*/
|
||||
export function DocumentTitle({ title }: DocumentTitleProps): null {
|
||||
const original = useRef(document.title)
|
||||
const productTitle = process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE
|
||||
useEffect(() => {
|
||||
document.title = title === undefined ? original.current : `${title} — ${original.current}`
|
||||
return () => { document.title = original.current }
|
||||
}, [title])
|
||||
document.title = title === undefined ? productTitle : `${title} — ${productTitle}`
|
||||
return () => { document.title = productTitle }
|
||||
}, [productTitle, title])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -13,6 +13,7 @@ afterEach(async () => {
|
||||
await runtime?.dispose()
|
||||
runtime = undefined
|
||||
document.title = ''
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function bench() {
|
||||
@@ -33,7 +34,8 @@ describe('buildRenderApp', () => {
|
||||
})
|
||||
|
||||
it('projects the selected durable session title', async () => {
|
||||
document.title = 'Product'
|
||||
vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
|
||||
document.title = 'stale title'
|
||||
const b = await bench()
|
||||
render(<>{b.renderApp()}</>)
|
||||
expect(document.title).toBe('Product')
|
||||
@@ -46,7 +48,8 @@ describe('buildRenderApp', () => {
|
||||
})
|
||||
|
||||
it('falls back when the selected id has no list row', async () => {
|
||||
document.title = 'Product'
|
||||
vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
|
||||
document.title = 'stale title'
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
|
||||
render(<>{b.renderApp()}</>)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { DocumentTitle } from '../src/client/DocumentTitle.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.title = ''
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('DocumentTitle', () => {
|
||||
it('projects a durable title and restores the product title', () => {
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness')
|
||||
document.title = 'stale title'
|
||||
const mounted = render(<DocumentTitle />)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
mounted.rerender(<DocumentTitle title="First title" />)
|
||||
@@ -22,4 +24,13 @@ describe('DocumentTitle', () => {
|
||||
mounted.unmount()
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('uses the generic title when the build provides no title', () => {
|
||||
vi.stubEnv('DSH_CLIENT_TITLE', '')
|
||||
delete process.env.DSH_CLIENT_TITLE
|
||||
const mounted = render(<DocumentTitle title="First title" />)
|
||||
expect(document.title).toBe('First title — DSH Local Build')
|
||||
mounted.unmount()
|
||||
expect(document.title).toBe('DSH Local Build')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-sidebar/README.md
|
||||
README.md: 7cc4fe0a722fe5f8cf0e983a0e3fdb30cd31bb64
|
||||
README.zh.md: 96b6f60f871758c126e6c387c6ce3f0993039af0
|
||||
README.md: b924c2e6d18217d9689b7c21137321856e14da2e
|
||||
README.zh.md: 10416e598e8d40c2771d6046e93f60acee097968
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a `DSH Local Build` label carrying the build's 7-character `DSH_CLIENT_COMMIT_HASH` badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar.
|
||||
|
||||
New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace.
|
||||
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, `sidebar.workspaces`, and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
|
||||
|
||||
During a live collapse, the shell holds the expanded content at its current width while it fades out for 150ms. The four upper controls—the shell toggle and New Session plus add and search rendered through `sidebar.workspaces`—then share one 150ms fade and 49px leftward translation into the 56px rail, ending with the layout's 300ms column slide; every 36px control box follows the same path to the rail's 10px left inset. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
|
||||
侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
|
||||
|
||||
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记,以及带有构建期 7 位 `DSH_CLIENT_COMMIT_HASH` 徽标的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。
|
||||
|
||||
New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。
|
||||
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
|
||||
|
||||
实时收起时,外壳会把展开内容固定在当前宽度,并用 150ms 将其淡出。随后,上方四个控件——外壳的侧栏切换与新建会话,以及通过 `sidebar.workspaces` 渲染的添加和搜索——共用一次 150ms 的淡入和 49px 左移,在布局的 300ms 栏滑动结束时一起进入 56px 轨道;每个 36px 控件盒都会沿同一条路径到达轨道左侧 10px 的内边距。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。
|
||||
|
||||
|
||||
@@ -124,6 +124,41 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Mark and name are independent slots; the shell owns their shared baseline,
|
||||
spacing, and height so either occupant can vary in width. */
|
||||
.brandIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fallbackBrandName {
|
||||
font-size: 17px;
|
||||
letter-spacing: 0px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
@@ -159,16 +194,36 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.collapsed .toggle:hover .railFish {
|
||||
.collapsed .toggle:hover .railMark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.railMark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
|
||||
secondary icon-button ink. */
|
||||
.collapsed .iconButton {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.buildRevision {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
background: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
|
||||
corners); collapsed it renders as the rail's plain icon control. */
|
||||
.newSession {
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
BrandWordmark, FishLogo,
|
||||
IconNewChatOutline16, IconPanelLeftOutline16,
|
||||
Tooltip,
|
||||
FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
||||
import css from './SidebarRoot.module.css'
|
||||
@@ -128,7 +126,7 @@ export function SidebarRoot({
|
||||
onPointerLeave={() => { armLinger() }}
|
||||
>
|
||||
<div className={css.logoRow}>
|
||||
{/* 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 && (
|
||||
<button
|
||||
@@ -137,7 +135,23 @@ export function SidebarRoot({
|
||||
aria-label={t('session.new.label')}
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<BrandWordmark />
|
||||
<span className={css.brandIdentity} aria-hidden="true">
|
||||
<span className={css.brandMark}>
|
||||
{renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })}
|
||||
</span>
|
||||
<span className={css.brandName}>
|
||||
{renderSlot('sidebar.brand.name', {}, {
|
||||
fallback: (
|
||||
<>
|
||||
<span className={css.fallbackBrandName}>DSH Local Build</span>
|
||||
{process.env.DSH_CLIENT_COMMIT_HASH
|
||||
? <span className={css.buildRevision}>{process.env.DSH_CLIENT_COMMIT_HASH}</span>
|
||||
: null}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 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 && <FishLogo className={css.railFish} size={24} />}
|
||||
{!wide && (
|
||||
<span className={css.railMark} aria-hidden="true">
|
||||
{renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })}
|
||||
</span>
|
||||
)}
|
||||
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
|
||||
<IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
|
||||
</button>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+97
-22
@@ -17,15 +17,24 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
|
||||
class="iconButton toggle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="railFish"
|
||||
data-content="965fe321"
|
||||
fill="none"
|
||||
height="17.6580310880829"
|
||||
viewBox="0 0 23.16 17.04"
|
||||
width="24"
|
||||
/>
|
||||
class="railMark"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar.brand.mark"
|
||||
style="display: contents;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-content="965fe321"
|
||||
fill="none"
|
||||
height="17.6580310880829"
|
||||
viewBox="0 0 23.16 17.04"
|
||||
width="24"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<svg
|
||||
class="panelIcon"
|
||||
data-content="35f95b0c"
|
||||
@@ -100,14 +109,47 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
|
||||
class="brand wide"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-content="951274b9"
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 182 24"
|
||||
width="182"
|
||||
/>
|
||||
class="brandIdentity"
|
||||
>
|
||||
<span
|
||||
class="brandMark"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar.brand.mark"
|
||||
style="display: contents;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-content="965fe321"
|
||||
fill="none"
|
||||
height="17.6580310880829"
|
||||
viewBox="0 0 23.16 17.04"
|
||||
width="24"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="brandName"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar.brand.name"
|
||||
style="display: contents;"
|
||||
>
|
||||
<span
|
||||
class="fallbackBrandName"
|
||||
>
|
||||
DSH Local Build
|
||||
</span>
|
||||
<span
|
||||
class="buildRevision"
|
||||
>
|
||||
abc1234
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Collapse sidebar"
|
||||
@@ -193,14 +235,47 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
|
||||
class="brand wide"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-content="951274b9"
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 182 24"
|
||||
width="182"
|
||||
/>
|
||||
class="brandIdentity"
|
||||
>
|
||||
<span
|
||||
class="brandMark"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar.brand.mark"
|
||||
style="display: contents;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-content="965fe321"
|
||||
fill="none"
|
||||
height="17.6580310880829"
|
||||
viewBox="0 0 23.16 17.04"
|
||||
width="24"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="brandName"
|
||||
>
|
||||
<div
|
||||
data-slot="sidebar.brand.name"
|
||||
style="display: contents;"
|
||||
>
|
||||
<span
|
||||
class="fallbackBrandName"
|
||||
>
|
||||
DSH Local Build
|
||||
</span>
|
||||
<span
|
||||
class="buildRevision"
|
||||
>
|
||||
abc1234
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="收起侧边栏"
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('ui-sidebar apply', () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.brand.mark')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.brand.name')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' })
|
||||
@@ -62,6 +64,8 @@ describe('ui-sidebar apply', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.brand.mark')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.brand.name')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
|
||||
SidebarSettingsOwnerProps,
|
||||
@@ -14,6 +15,7 @@ const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -27,6 +29,8 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||
let settingsOwner: SidebarSettingsOwnerProps | undefined
|
||||
let footerActionOwner: SidebarFooterActionOwnerProps | undefined
|
||||
const brandMark = <span data-testid="custom-brand-mark">M</span>
|
||||
const brandName = <span data-testid="custom-brand-name">Custom Brand</span>
|
||||
let current = { collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
@@ -37,6 +41,8 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
key: string,
|
||||
owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
|
||||
) => {
|
||||
if (key === 'sidebar.brand.mark') return brandMark
|
||||
if (key === 'sidebar.brand.name') return brandName
|
||||
if (key === 'sidebar.settings') {
|
||||
settingsOwner = owner
|
||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||
@@ -76,6 +82,8 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session (capsule + wordmark) and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
expect(screen.getByTestId('custom-brand-mark')).toBeTruthy()
|
||||
expect(screen.getByTestId('custom-brand-name')).toBeTruthy()
|
||||
// Expanded, both the wordmark and the capsule start a session.
|
||||
const starters = screen.getAllByRole('button', { name: 'New session' })
|
||||
expect(starters).toHaveLength(2)
|
||||
@@ -85,6 +93,21 @@ describe('SidebarRoot shell', () => {
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders generic brand fallbacks when no package fills the slots', () => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
|
||||
const { container } = render(<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
|
||||
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
|
||||
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
|
||||
/>)
|
||||
|
||||
expect(screen.getByText('DSH Local Build')).toBeTruthy()
|
||||
expect(screen.getByText('0123456')).toBeTruthy()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* holes (sidebar.workspaces / sidebar.settings) have no registrant here, so
|
||||
* the snapshots pin the shell chrome itself.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, waitFor } from '@testing-library/react'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -18,7 +18,12 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') })
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
/**
|
||||
* Boot the package over the slot test runtime. The default bench stays on
|
||||
|
||||
@@ -63,4 +63,13 @@ describe('SidebarRoot.module.css', () => {
|
||||
expect(declarations('.collapsed .newSession')?.get('align-self')).toBe('flex-start')
|
||||
expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px')
|
||||
})
|
||||
|
||||
it('keeps the slotted brand row at the full artwork height', () => {
|
||||
expect(declarations('.brandIdentity')?.get('height')).toBe('24px')
|
||||
expect(declarations('.brandName')?.get('height')).toBe('24px')
|
||||
expect(declarations('.brandName')?.get('line-height')).toBe('24px')
|
||||
expect(declarations('.brandName')?.get('font-size')).toBe('18px')
|
||||
expect(declarations('.fallbackBrandName')?.get('font-size')).toBe('17px')
|
||||
expect(declarations('.fallbackBrandName')?.get('white-space')).toBe('nowrap')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -371,7 +371,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:230',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:235',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer.dock',
|
||||
@@ -424,7 +424,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
|
||||
},
|
||||
{
|
||||
key: 'conversation.details.tool',
|
||||
@@ -481,7 +481,33 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.brand.mark',
|
||||
kind: 'single',
|
||||
scope: 'root',
|
||||
summary: 'Brand mark leading the blank-session headline.',
|
||||
doc: 'Brand mark leading the blank-session headline. Declared by this\npackage\'s `conversation` entry; the shell supplies a fish fallback.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Presentation props supplied to the blank-session brand-mark occupant. */\nexport interface HeroBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Host CSS class for preserving the default hero mark color and hover motion. */\n className?: string | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useSessions: SnapshotSelectorHook<SessionListState>',
|
||||
'useWorkspaces: SnapshotSelectorHook<import(\'./workspaces/service.ts\').WorkspaceListState>',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-brand-official OfficialBrandMark',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.workspace',
|
||||
@@ -570,7 +596,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:232',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.dock',
|
||||
@@ -625,7 +651,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.left',
|
||||
@@ -676,7 +702,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:208',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.model',
|
||||
@@ -707,7 +733,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:256',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.overlay',
|
||||
@@ -787,7 +813,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:246',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:251',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.right',
|
||||
@@ -838,7 +864,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:216',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:221',
|
||||
},
|
||||
{
|
||||
key: 'conversation.message.images',
|
||||
@@ -1556,6 +1582,58 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-layout/src/client/index.ts:49',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.brand.mark',
|
||||
kind: 'single',
|
||||
scope: 'root',
|
||||
summary: 'Brand mark rendered in the expanded brand row and collapsed rail.',
|
||||
doc: 'Brand mark rendered in the expanded brand row and collapsed rail.\nDeclared by this package\'s `sidebar` entry; deployments may replace\nthe shell\'s fish fallback without replacing the surrounding controls.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Geometry supplied to the sidebar brand-mark occupant. */\nexport interface SidebarBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useSessions: SnapshotSelectorHook<SessionListState>',
|
||||
'useWorkspaces: SnapshotSelectorHook<import(\'./workspaces/service.ts\').WorkspaceListState>',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-brand-official OfficialBrandMark',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:23',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.brand.name',
|
||||
kind: 'single',
|
||||
scope: 'root',
|
||||
summary: 'Brand name rendered beside the expanded mark.',
|
||||
doc: 'Brand name rendered beside the expanded mark. Declared by this\npackage\'s `sidebar` entry; the shell supplies a generic text fallback.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Empty owner share for the sidebar brand-name occupant. */\nexport interface SidebarBrandNameOwnerProps {\n /** Marker field: the occupant owns its own content and width. */\n children?: never\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useSessions: SnapshotSelectorHook<SessionListState>',
|
||||
'useWorkspaces: SnapshotSelectorHook<import(\'./workspaces/service.ts\').WorkspaceListState>',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-brand-official OfficialBrandName',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:28',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.footer.action',
|
||||
kind: 'list',
|
||||
@@ -1599,7 +1677,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:46',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.settings',
|
||||
@@ -1625,7 +1703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:30',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:41',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.workspaces',
|
||||
@@ -1651,7 +1729,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:24',
|
||||
source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.workspaces.directoryFlow',
|
||||
|
||||
Generated
+36
@@ -1305,6 +1305,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-attachment
|
||||
'@deepseek-ai/dsh-client-ui-brand-official':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-brand-official
|
||||
'@deepseek-ai/dsh-client-ui-commands':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-commands
|
||||
@@ -1768,6 +1771,39 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-brand-official:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-sidebar
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@testing-library/react':
|
||||
specifier: ^16.1.0
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-commands:
|
||||
dependencies:
|
||||
clsx:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 {
|
||||
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',
|
||||
'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()
|
||||
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, string>): string {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-'))
|
||||
roots.push(fixtureRoot)
|
||||
write(join(fixtureRoot, 'apps/web/dist/index.html'), '<main></main>')
|
||||
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',
|
||||
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('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'), '<main>changed</main>')
|
||||
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`)
|
||||
}
|
||||
expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
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<Record<string, string>>
|
||||
|
||||
/**
|
||||
* 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<Record<`DSH_CLIENT_${string}`, string>> {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Record<string, string | undefined>>,
|
||||
expected: Readonly<Record<`DSH_CLIENT_${string}`, string>>,
|
||||
): 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.
|
||||
*
|
||||
* 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<string, string> {
|
||||
const defines: Record<string, string> = { 'process.env': '{}' }
|
||||
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<Record<`DSH_CLIENT_${string}`, string>>,
|
||||
): 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<string, string> = {}
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort()
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index])
|
||||
}
|
||||
@@ -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, unknow
|
||||
return { directory, name, version: '0.0.1', manifest }
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
function write(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
}
|
||||
|
||||
function buildFixture(environment: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'apps/web/dist/index.html'), '<main></main>')
|
||||
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 = [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 [
|
||||
|
||||
+20
-8
@@ -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<Gate> = {}): Ga
|
||||
}
|
||||
}
|
||||
|
||||
/** Build official client artifacts inside a CI aggregate without changing sibling gate environments. */
|
||||
function ciBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
|
||||
return pnpmScript(id, 'build', {
|
||||
...options,
|
||||
env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
|
||||
})
|
||||
}
|
||||
|
||||
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): 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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' },
|
||||
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
"typeRoots": ["./scripts/types", "./node_modules/@types"],
|
||||
"types": ["client-build-environment"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime/invariant": ["./packages/test-support/client-runtime/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
||||
"@deepseek-ai/dsh-client-ui-brand-official": ["./packages/client/ui-brand-official/src"],
|
||||
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
||||
"@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"],
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"],
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
@@ -62,6 +64,7 @@
|
||||
{ "path": "./packages/test-support/client-runtime" },
|
||||
{ "path": "./packages/client/ui-layout" },
|
||||
{ "path": "./packages/client/ui-sidebar" },
|
||||
{ "path": "./packages/client/ui-brand-official" },
|
||||
{ "path": "./packages/client/ui-conversation" },
|
||||
{ "path": "./packages/client/ui-tool" },
|
||||
{ "path": "./packages/client/ui-deliverables" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user