feat(client): compose deployment branding through slots

This commit is contained in:
imccyu
2026-08-19 18:21:35 +08:00
parent 738dcced9b
commit 319d9a7984
62 changed files with 868 additions and 92 deletions
+1 -1
View File
@@ -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>
+2
View File
@@ -42,6 +42,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull()
expect(screen.queryByText('DSH Local Build')).toBeNull()
// The compact layout dropped group session counts; the fixture workspace
// group row renders immediately with its sessions beneath it.
const fixtureGroup = (await within(tree).findAllByText('fixture'))
+5 -4
View File
@@ -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' })
+2 -2
View File
@@ -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()
+18 -1
View File
@@ -8,6 +8,23 @@ const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
/** Escape build-time text before placing it in the HTML title element. */
function escapeHtmlText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 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 {
@@ -91,7 +108,7 @@ function npmPackageOf(id: string): string | undefined {
}
export default defineConfig({
plugins: [rejectStandaloneServe(), react()],
plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
build: {
sourcemap: true,
rollupOptions: {
+2 -2
View File
@@ -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
+1
View File
@@ -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))
+1
View File
@@ -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 -2
View File
@@ -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
+6
View File
@@ -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) |
+6
View File
@@ -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) |
+4
View File
@@ -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'
+1
View File
@@ -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:^",
+2 -2
View File
@@ -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
+1
View File
@@ -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. |
+1
View File
@@ -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) | 编排工具调用树和按工具键控的视图。 |
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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,14 @@ 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')
expect(renderSlot.mock.calls[0]?.[1]).toEqual({ size: 34 })
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 -2
View File
@@ -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
+4 -2
View File
@@ -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.
+4 -2
View File
@@ -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' },
@@ -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: [
'/** Geometry supplied to the blank-session brand-mark occupant. */\nexport interface HeroBrandMarkOwnerProps {\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 \'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',
+36
View File
@@ -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:
File diff suppressed because one or more lines are too long
@@ -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.' },
+1
View File
@@ -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"],
+1
View File
@@ -64,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" },