Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Resolve additive conflicts in the api-remotes client assembly by keeping
both the message-feedback remote mount and master's forwarded-event
allowlist, and regenerate the module graph.
This commit is contained in:
Chinesezjc
2026-08-11 21:37:55 +08:00
733 changed files with 22553 additions and 3440 deletions

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: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca

View File

@@ -18,11 +18,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. |
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
| [`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. |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
@@ -33,6 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |

View File

@@ -18,11 +18,13 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 |
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 |
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent智能体活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
@@ -33,6 +35,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |

View File

@@ -2835,8 +2835,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return Promise.resolve({ accepted: true })
},
// Satisfies the ApiProxy contract type only: the browser export button
// fetches GET /api/session.export directly (window.fetch), so this stub is
// never reached through the fixture's dispatch.
// hands GET /api/session.export to the native download manager, so this
// stub is never reached through the fixture's dispatch.
downloads: {
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
},

View File

@@ -152,14 +152,14 @@ describe('connection client apply', () => {
sockets[1]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'host-browser',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
}))
expect(await muxFrame).toMatchObject({
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
})
expect(await hostFrame).toMatchObject({
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
})
expect(errors).toHaveBeenCalledTimes(2)
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })

View File

@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
},
async function * (signal) {
try {
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
await untilAbort(signal)
} finally {
hostAborted = true
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
expect(await hostFrame).toEqual({
type: 'server-request',
rpcId: 'host-1',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
const muxClosed = once(mux, 'close')

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/hmr/README.md
README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。

View File

@@ -4,7 +4,9 @@
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
* The web bundle mounts this row unconditionally: without a rebuild
* watcher rewriting client bundles, the poll observes no changes and the
* chain stays idle.
*/
import { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http'

View File

@@ -33,7 +33,9 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web",
"immediately": true
@@ -41,21 +43,25 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dependencies": {

View File

@@ -7,7 +7,7 @@
import { useState } from 'react'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'

View File

@@ -13,9 +13,11 @@ import type { Context } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
bindSettingsScope, type ClientContext, type SettingsScope,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge and the settings slot types.
// Cross-plugin collaboration goes through the service, never a value import
// (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
@@ -29,7 +31,6 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
@@ -343,7 +344,7 @@ function detectBrowserLocale(): LocaleId | undefined {
}
/** Required services: slot registration plus the settings transport. */
export const inject = ['slots', 'connection']
export const inject = ['slots', 'connection', 'remote', 'settingsScope']
/**
* Client plugin body: provide the locale service with base dictionaries and
@@ -352,7 +353,7 @@ export const inject = ['slots', 'connection']
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
const host = ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE })
const locale = new LocaleService(ctx, host)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })

View File

@@ -1,26 +0,0 @@
/**
* The `settings.general.item` slot type — one preference row inside the
* settings General section, contributed by the feature plugin that owns the
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
* key), `order` (row position). Rows draw their own internals (row layout,
* separators via CSS); the section column only stacks them.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -4,6 +4,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, SETTINGS_NS,
} from '@deepseek-ai/dsh-client-locale/client'
@@ -43,6 +45,9 @@ async function bench() {
}
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
return {
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
@@ -79,7 +84,7 @@ describe('locale apply', () => {
})
it('declares the slot service', () => {
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
@@ -134,10 +139,10 @@ describe('locale apply', () => {
const locale = b.ctx.get('locale') as LocaleService
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
b.setHostPreference(undefined)
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
b.setHostPreference('en')
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
expect(b.describe).toHaveBeenCalledTimes(3)
})

View File

@@ -6,6 +6,7 @@ import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepsee
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
@@ -20,10 +21,13 @@ describe('invariant companion', () => {
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
const ctx = new Context()
new SlotsService(ctx)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The settings row's transport and the forwarded-event port.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)

View File

@@ -25,6 +25,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../ui-settings"
}
]
}

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/runtime/README.md
README.md: 7c835deb58db149710495f97a2553c3de58d99da
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
README.md: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d
README.zh.md: ce8117fc4c95071a6db8592302030a8a63b5478b

View File

@@ -2,9 +2,12 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
@@ -33,6 +36,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.

View File

@@ -2,9 +2,12 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set``unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。字段是否被覆盖取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
@@ -33,6 +36,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`

View File

@@ -33,7 +33,8 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
@@ -44,7 +45,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
@@ -59,19 +59,20 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,81 @@
/**
* The settings-namespace scope contract. The type lives here, in the common
* dependency of every feature that owns a preference, while the implementation
* and its Host transport live with the Settings surface
* (`dsh-client-ui-settings`): a feature service accepts a scope through
* `attachSettings` without depending on the surface that binds it, which would
* otherwise close a reference cycle.
*/
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/**
* Composition layer the Host resolved {@link value} over, when the owning
* plugin declared one. What a field reverts to once cleared.
*/
base: unknown
/**
* Raw user layer as stored, when one exists. A field's PRESENCE here is what
* marks it overridden — an override whose value equals the composition
* default is still an override, and comparing values could not see it.
*/
user: unknown
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
/**
* Queue one field clear, so the field re-inherits the composition layer.
* Shares {@link set}'s ordering, revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @returns settlement after the clear and any latest-write recovery read.
*/
unset(field: string): Promise<void>
}

View File

@@ -1,6 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
// Contract only: the scope implementation and its Host transport belong to
// dsh-client-ui-settings (see that package's settings-scope.ts).
export type {
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
} from './contract/settings-scope.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
@@ -150,47 +157,6 @@ declare module '@deepseek-ai/cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -213,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
export const inject = ['connection', 'typert', 'remote']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -241,17 +207,12 @@ export function apply(ctx: Context): void {
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model services) subscribe on ctx.
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin owns the frame sink, so it hands the
// decoded frame straight to the Remote service, which fans it out to
// `ctx.remote.$on` subscribers; no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
},
onConnected: () => {
sessions.handleConnected()

View File

@@ -330,8 +330,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
* - `active`: the session is non-blank beyond its pending first prompt,
* contains visible non-command Chat content, is running, or owns a pending
* interaction — the ordinary conversation view.
*
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; returning to the hero would discard the error context).

View File

@@ -800,14 +800,6 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)

View File

@@ -23,6 +23,7 @@ import { PendingWait } from './pending.ts'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
@@ -194,7 +195,12 @@ export class Session implements SessionFace {
let result: RpcResult<{ accepted: true }>
try {
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
result = (await this.api.sessions.prompt({
sessionId: this.sessionId,
mode,
content,
clientTimeZone: resolvedClientTimeZone(),
})).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
@@ -220,6 +226,7 @@ export class Session implements SessionFace {
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
@@ -741,7 +748,8 @@ export class Session implements SessionFace {
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
(!this.blankBit && !this.firstPromptPendingTurn)
hasVisibleConversationContent(chat)
|| (!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
@@ -774,13 +782,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -0,0 +1,14 @@
/** Browser-owned time-zone sampling for prompt RPC provenance. */
/**
* Resolve the current browser IANA zone for one outbound operation.
* @returns The browser-provided canonical zone.
* @throws when the runtime cannot provide a non-empty zone.
*/
export function resolvedClientTimeZone(): string {
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
if (typeof timeZone !== 'string' || timeZone.length === 0) {
throw new Error('browser time zone is unavailable')
}
return timeZone
}

View File

@@ -334,6 +334,7 @@ describe('subagent catalogs', () => {
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
content: [{ type: 'text', text: 'continue' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('session.history')).toEqual([])

View File

@@ -8,6 +8,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import type {
@@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
if (context.state === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'runtime-test-event',
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
? 'command-input'
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
? 'command'
: 'runtime-test-event',
id: context.id,
target: 'chat',
anchorSeq: context.start.event.seq,
@@ -272,6 +277,24 @@ describe('live event path', () => {
expect(snapshot.composerPhase).toBe('blank')
})
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
const { session } = await opened([])
session.handleBlank(true)
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
expect(session.getSnapshot()).toMatchObject({
blank: true,
composerPhase: 'active',
})
expect(session.getSnapshot().chat.order.map(
key => session.getSnapshot().chat.nodes.get(key)?.kind,
)).toContain('command-input')
})
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
@@ -442,6 +465,7 @@ describe('prompt and cancel errors', () => {
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
@@ -507,7 +531,12 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
expect(api.callsOf('session.prompt')).toMatchObject([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: '要发的' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
}])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')

View File

@@ -87,23 +87,6 @@ describe('list store projection', () => {
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
// Every connected client gets this frame; only the switching tab gets the
// RPC echo. A client that ignored the payload would keep labelling the
// session with the composition it replaced.
b.svc.handleHostEnvelope({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])

View File

@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
afterEach(() => {
vi.restoreAllMocks()
})
describe('browser time zone', () => {
it('returns the runtime-resolved zone', () => {
expect(resolvedClientTimeZone()).toBe(
new Intl.DateTimeFormat().resolvedOptions().timeZone,
)
})
it.each([undefined, ''])('fails loud when the runtime exposes no zone %#', (timeZone) => {
const options = new Intl.DateTimeFormat().resolvedOptions()
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
...options,
timeZone: timeZone as string,
})
expect(() => resolvedClientTimeZone()).toThrow('browser time zone is unavailable')
})
})

View File

@@ -1,26 +1,63 @@
/**
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; host/session-preset-changed →
* ctx 'session/preset-changed'; each established connection generation
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
* api-gateway's own coverage); each established connection generation emits
* `connection/reset` for generation-scoped cache invalidation.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
// Type-only: the api-remotes facade carries both the allowlist's selection seat
// and the owner packages' `./types` declarations, which together give `$on` its
// key face and per-event listener signatures.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
/**
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
* rather than by running it: the allowlist narrows the key set, and each
* listener's parameters come from the owner package's own cordis `Events`
* declaration (so a brand cannot be flattened on the way to a consumer).
* @param ctx - any client Context carrying the Remote service.
*/
function forwardedEventContracts(ctx: Context): void {
ctx.remote.$on('settings/document-updated', (namespace, source) => {
// @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
const bare: typeof namespace = 'plain-string'
void bare; void namespace; void source
})
ctx.remote.$on('credentials/updated', () => {})
ctx.remote.$on('commands/change', () => {})
ctx.remote.$on('llm/adapters-updated', () => {})
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
void sessionId; void agentPreset
})
// @ts-expect-error -- client-local event outside the allowlist
ctx.remote.$on('slots/changed', () => {})
// @ts-expect-error -- declared host event the allowlist does not select
ctx.remote.$on('skills/change', () => {})
}
void forwardedEventContracts
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
dispatched: unknown[][]
}
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
// Stands in for api-gateway's Remote service: this spec owns the carrier's
// handoff, not the fan-out behind it.
ctx.reflect.provide('remote', {
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
})
const handle: ConnectionHandle = {
api,
isLoopback: true,
@@ -33,50 +70,52 @@ async function mount(): Promise<Bench> {
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('wire event bridge', () => {
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
const bench = await mount()
let changed = 0
bench.ctx.on('commands/changed', () => { changed++ })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
expect(changed).toBe(1)
const seen = bench.dispatched
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
expect(seen).toEqual([['commands/change']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r2' as never,
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
})
expect(changed).toBe(1)
expect(seen).toEqual([['commands/change']])
})
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
bench.ctx.on('models/changed', () => seen.push(['models']))
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
expect(seen).toEqual([
['settings', 'llm-pi-ai'],
['credentials', 'OPENAI_API_KEY'],
['models'],
])
})
const seen = bench.dispatched
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
const bench = await mount()
const seen: Array<[string, string]> = []
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
rpcId: 'r3' as never,
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
})
expect(seen).toEqual([['s1', 'minimal']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r4' as never,
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
})
// The carrier does not second-guess the name: selecting what a consumer can
// receive is the allowlist's job, and dropping an unsubscribed name is the
// Remote service's. This plugin republishes whatever the frame carried.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r5' as never,
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
})
expect(seen).toEqual([
['settings/document-updated', 'llm-pi-ai', 7],
['credentials/updated', 'OPENAI_API_KEY'],
['nobody/listening', 'ignored'],
])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {

View File

@@ -23,9 +23,6 @@
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../host/apiproxy"
},
@@ -61,6 +58,9 @@
},
{
"path": "../../typert/registry"
},
{
"path": "../../api/gateway"
}
],
"exclude": [

View File

@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { TestWorkspaces } from './workspaces.ts'
export { TestRemote } from './remote.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'

View File

@@ -0,0 +1,66 @@
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
import type { Context } from '@deepseek-ai/cordis'
/**
* Remote service test double for the forwarded-event path. Feature specs need
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
* host events to reach those subscribers, but not the generated namespaces or
* the wire — so this double implements subscription and dispatch only.
*
* Dispatch is driven the same way production drives it: `client/runtime` owns the
* host frame sink and hands each decoded `host/remote-event` frame to
* `$dispatch`. A spec therefore exercises its refresh chains by calling
* `$dispatch(name, args)` on this double.
*
* `$mount` rejects: a spec that reaches a generated namespace through this
* double has outgrown it and needs the real Client Remote service.
*
* One deliberate asymmetry with production: a throwing listener propagates out
* of the emit instead of being contained and logged, so a spec cannot lean on
* this double for the containment guarantee `$on` documents — assert that
* against the real service.
*/
export class TestRemote {
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
/**
* Register the double as `ctx.remote`.
* @param ctx - the spec's root Context.
*/
constructor(ctx: Context) {
ctx.provide('remote', this)
}
/**
* Deliver one forwarded host event to its subscribers, standing in for the
* carrier that owns the frame sink.
* @param event - forwarded host event name.
* @param args - the Host argument list, verbatim.
*/
$dispatch(event: string, args: readonly unknown[]): void {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
for (const listener of [...listeners]) listener(...args as never[])
}
/**
* Subscribe to one forwarded host event.
* @param event - forwarded host event name.
* @param listener - receives the Host argument list verbatim.
* @returns disposer removing this subscription.
*/
$on(event: string, listener: (...args: never[]) => void): () => void {
const listeners = this.subscriptions.get(event) ?? new Set()
this.subscriptions.set(event, listeners)
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* Generated-namespace mount, unsupported by this double.
* @returns never; always rejects.
*/
$mount(): Promise<() => Promise<void>> {
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
}
}

View File

@@ -8,6 +8,8 @@ export interface StubSettingsScope<T> {
scope: SettingsScope<T>
/** Spy behind `scope.set`; resolves immediately. */
set: ReturnType<typeof vi.fn>
/** Spy behind `scope.unset`; resolves immediately. */
unset: ReturnType<typeof vi.fn>
/** @returns how many listeners are currently subscribed (disposal assertions). */
listenerCount(): number
/**
@@ -25,10 +27,12 @@ export interface StubSettingsScope<T> {
*/
export function stubSettingsScope<T>(): StubSettingsScope<T> {
let snapshot: SettingsScopeSnapshot<T> = {
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
status: 'loading', value: undefined, base: undefined, user: undefined,
revision: undefined, writable: false, mode: 'host',
}
const listeners = new Set<() => void>()
const set = vi.fn(() => Promise.resolve())
const unset = vi.fn(() => Promise.resolve())
return {
scope: {
getSnapshot: () => snapshot,
@@ -37,8 +41,10 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
return () => { listeners.delete(listener) }
},
set,
unset,
},
set,
unset,
listenerCount: () => listeners.size,
publish: (next) => {
snapshot = { ...snapshot, ...next }

View File

@@ -0,0 +1,43 @@
/**
* TestRemote's own contract: subscription and disposal, dispatch driven by the
* internal plumbing event, the silent drop for an unsubscribed name, and the
* `$mount` refusal that sends a spec to the real Client Remote service.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { TestRemote } from '../src/remote.ts'
describe('TestRemote', () => {
it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
const ctx = new Context()
const remote = new TestRemote(ctx)
const seen: string[] = []
const off = remote.$on('settings/document-updated', (ns: string) => {
seen.push(ns)
})
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1])
expect(seen).toEqual(['ui-theme'])
off()
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2])
expect(seen).toEqual(['ui-theme'])
await ctx.fiber.dispose()
})
it('drops a forwarded event nobody subscribed to', async () => {
const ctx = new Context()
new TestRemote(ctx)
// No subscriber for this name: the emit must be inert rather than throwing,
// because the wire carries whatever the Host allowlist selected.
expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
await ctx.fiber.dispose()
})
it('refuses $mount, which needs the real Client Remote service', async () => {
const ctx = new Context()
const remote = new TestRemote(ctx)
await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service')
await ctx.fiber.dispose()
})
})

View File

@@ -7,6 +7,7 @@
* stack — this suite is the fixture the migrated feature specs rely on.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stubSettingsScope } from '../src/settings-scope.ts'
import { cleanup } from '@testing-library/react'
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -635,3 +636,32 @@ describe('single-slot mounting edge arms', () => {
await runtime.dispose()
})
})
describe('stubbed settings scope', () => {
it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
const host = stubSettingsScope<{ preference: string }>()
let notified = 0
const stop = host.scope.subscribe(() => { notified += 1 })
expect(host.listenerCount()).toBe(1)
expect(host.scope.getSnapshot()).toMatchObject({
status: 'loading', base: undefined, user: undefined,
})
await host.scope.set('preference', 'dark')
await host.scope.unset('preference')
host.publish({
status: 'ready',
value: { preference: 'system' },
base: { preference: 'system' },
revision: 2,
writable: true,
})
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
expect(host.unset).toHaveBeenCalledWith('preference')
expect(notified).toBe(1)
expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true })
stop()
expect(host.listenerCount()).toBe(0)
})
})

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-agent-preset/README.md
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
README.md: f0fdff4b1f2453be9e8b4c1d20f7ca0fee506d55
README.zh.md: c391a4548f2bd2fc4bfe168e9d494bc0073a8cb2

View File

@@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen
## The session-header label
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads.
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary and resolves the display name against the same roster the General row reads. Forwarded `agent-preset/selected` owner events fold committed blank-session switches into that shared summary in every tab; the initiating tab may already have applied the RPC echo, and the merge is idempotent.
## What it reads and writes

View File

@@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
## 会话标题旁的标签
第三个表层,位于会话标题旁:**本会话**所运行的 preset作为静态装饰呈现。在那里放一个控件等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称
第三个表层,位于会话标题旁:**本会话**所运行的 preset作为静态装饰呈现。在那里放一个控件等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset,并在 General 行所读的同一份名单上解析显示名称。转发的 owner 事件 `agent-preset/selected` 会在每个标签页中把已经提交的空会话切换折进这份共享摘要;发起方标签页可能已经采用 RPC 回执,而合并是幂等的
## 它读什么、写什么

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-settings"
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -47,6 +48,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -56,10 +59,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -14,6 +14,9 @@
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Mount the General-settings row.
@@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
// The roster is a live directory and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const refresh = (): void => {
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
@@ -132,10 +137,15 @@ export function apply(ctx: ClientContext): void {
// the next session keeps offering the previous default until a reload,
// which is exactly the session the setting claims to govern. A staged
// pick survives: `load()` prefers it over the refreshed fallback.
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Every tab folds the committed preset into the shared session row; the
// initiating tab may already have applied the RPC echo, which is idempotent.
const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
scope.sessions.noteAgentPreset(sessionId, agentPreset)
})
// Authoring writes a FILE, not a setting, so nothing on the wire
// announces it — without this the screen that starts the next session
// keeps offering the roster as it stood when the chip first loaded, and
@@ -168,6 +178,7 @@ export function apply(ctx: ClientContext): void {
return () => {
stop()
settingsMoved()
presetSelected()
rosterReaders.delete(readRoster)
creatorDraft = undefined
chip()

View File

@@ -137,9 +137,10 @@ export class AgentPresetSectionController {
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
* already announced by the host through `settings/changed`; a directory
* copied or deleted here is not, and the new-session chip has no other
* way to learn a preset it should offer now exists.
* already announced by the host through the forwarded
* `settings/document-updated`; a directory copied or deleted here is not,
* and the new-session chip has no other way to learn a preset it should
* offer now exists.
*/
private readonly rosterChanged: () => void = () => {},
) {}

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
@@ -78,6 +78,9 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const calls: string[] = []
ctx.provide('connection', {
api: {
@@ -162,6 +165,12 @@ function sessionsDouble(state: {
return () => listeners.delete(fn)
},
},
noteAgentPreset: (sessionId: string, agentPreset: string) => {
const summary = state.byId[sessionId]
if (summary === undefined || summary.agentPreset === agentPreset) return
summary.agentPreset = agentPreset
for (const fn of listeners) fn()
},
/** Push a list change the way the runtime's store does. */
notify: () => { for (const fn of listeners) fn() },
}
@@ -169,7 +178,7 @@ function sessionsDouble(state: {
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the General row and the settings section', async () => {
@@ -250,11 +259,11 @@ describe('ui-agent-preset apply', () => {
await section.load()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
const afterRelevant = calls.length
ctx.emit('settings/changed', 'llm-deepseek')
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
// Both surfaces re-read on their own namespace; an unrelated one moves
@@ -282,7 +291,7 @@ describe('ui-agent-preset apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
// Only the General row reloads: a section nobody opened has nothing to
@@ -333,17 +342,35 @@ describe('ui-agent-preset apply', () => {
// An unrelated namespace moves nothing: the chip re-reads on its own
// setting, not on every settings write in the process.
moveDefault()
ctx.emit('settings/changed', 'llm-deepseek')
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
})
conversation()
})
it('folds a remote preset commit into the shared session row', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state = {
current: 's1',
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
}
ctx.provide('sessions', sessionsDouble(state) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal'])
expect(state.byId.s1.agentPreset).toBe('minimal')
})
it('offers a just-authored preset on the new-session chip', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)

View File

@@ -40,6 +40,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -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-attachment/README.md
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5

View File

@@ -0,0 +1,27 @@
# @deepseek-ai/dsh-client-ui-attachment
English | [中文](README.zh.md)
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
## Attachment rail
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
## Message images and the lightbox
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
## Model Experience
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).

View File

@@ -0,0 +1,27 @@
# @deepseek-ai/dsh-client-ui-attachment
[English](README.md) | 中文
纯 React 附件原子组件(零 cordis输入框草稿图片栏`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
## 附件栏
`AttachmentRail` 将待发送草稿图片渲染为固定 64px16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px并平滑滚动`prefers-reduced-motion: reduce` 下瞬时完成箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算rail 元素上的 ResizeObserver因此侧栏、面板的宽度变化也计入不只是窗口尺寸变化。附件栏只允许横向滚动非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px经持有方的 `ImageLoader` 加载会话授权 URL加载失败渲染显式重试按钮加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭卸载时将焦点还给打开者。
## 模型体验
无。该包package在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-client-ui-attachment",
"description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-attachment"
},
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -0,0 +1,118 @@
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
16px radius, remove control fully inside the card, arrows overlaid at the
edges instead of a scrollbar. */
.root {
position: relative;
min-width: 0;
}
.rail {
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
/* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */
scrollbar-width: none;
/* The rail scrolls on the composer's elevated input surface: bind the l2
pair (ui-theme styles/scrollbar.css rebinding contract) so anything that
does draw a thumb here matches the surface. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.rail::-webkit-scrollbar {
display: none;
}
.item {
position: relative;
flex: 0 0 64px;
width: 64px;
height: 64px;
}
.thumbnail {
width: 64px;
height: 64px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 16px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.thumbnail img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.remove {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-contrast-fill);
color: var(--dsw-alias-label-primary-inverted);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.item:hover .remove,
.remove:focus-visible {
opacity: 1;
}
/* Touch surfaces have no hover to reveal the control. */
@media (pointer: coarse) {
.remove {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.remove {
transition: none;
}
}
.arrow {
position: absolute;
top: 50%;
z-index: 2;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-secondary);
box-shadow: var(--dsw-shadow-lv2);
cursor: pointer;
transform: translateY(-50%);
}
.arrow:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.arrowLeft {
left: 4px;
}
.arrowRight {
right: 4px;
}

View File

@@ -0,0 +1,200 @@
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
* by edge arrows, hover-revealed per-item remove, single-click open. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './AttachmentRail.module.css'
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
export interface AttachmentRailItem {
/** Stable identity for the React key. */
id: string
/** Object or data URL rendered as the thumbnail. */
previewUrl: string
/** Image alt text (display name with the owner's fallback applied). */
alt: string
/** Accessible label of the item's remove control. */
removeLabel: string
}
/** Rail-level strings the owner resolves from its own locale namespace. */
export interface AttachmentRailLabels {
/** Accessible name of the rail group. */
group: string
/** Thumbnail tooltip inviting the original-image preview. */
open: string
/** Accessible label of the left paging arrow. */
scrollLeft: string
/** Accessible label of the right paging arrow. */
scrollRight: string
}
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
* notch wheels report lines, not pixels). */
const WHEEL_LINE_PX = 16
/** Smooth paging unless the user asked for reduced motion. */
function pageBehavior(): ScrollBehavior {
// jsdom (the unit lane) implements no matchMedia despite lib.dom's
// non-optional typing; the optional call keeps that lane on the default.
// oxlint-disable-next-line typescript/no-unnecessary-condition
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
}
/**
* Horizontal thumbnail rail over the caller's draft attachments.
*
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
* arrows recomputed from scroll geometry on scroll, item-count changes, and
* rail size changes (a ResizeObserver on the rail element, so sidebar or
* panel resizes count, not only window resizes). A vertical wheel pans the
* rail horizontally and is consumed exclusively (non-passive listener), a
* newly added item is revealed at the rail's end while a rail that mounts
* over an existing draft keeps its start position, and each thumbnail opens
* on a single click while its remove control sits inside the card and
* reveals on hover or focus. The owner decides mounting; it renders the rail
* only while items exist.
*
* @param props.items - resolved thumbnails in draft order.
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
* @param props.onOpen - single-click open of one item's original image.
* @param props.onRemove - remove one item from the draft.
* @returns the rail group with its paging arrows.
*/
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
items: readonly T[]
labels: AttachmentRailLabels
onOpen: (item: T) => void
onRemove: (item: T) => void
}) {
const railRef = useRef<HTMLDivElement | null>(null)
// null marks the first layout pass: a rail that MOUNTS over an existing
// draft (session switch back to held images) is initial display, not
// growth, and must not jump to the end.
const countRef = useRef<number | null>(null)
const [edges, setEdges] = useState({ left: false, right: false })
const updateEdges = useCallback(() => {
const el = railRef.current
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
if (el === null) return
// 1px slack: engines report fractional scroll positions at the edges.
const left = el.scrollLeft > 1
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
}, [])
useLayoutEffect(() => {
const grew = countRef.current !== null && items.length > countRef.current
countRef.current = items.length
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
if (el === null) return
// A newly added attachment lands at the rail's end: reveal it.
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
updateEdges()
}, [items.length, updateEdges])
useEffect(() => {
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
if (el === null) return
// The rail's width follows the composer, which resizes with sidebars and
// panels, not only the window — observe the element itself. jsdom (the
// unit lane) implements no ResizeObserver; every browser gets the
// subscription.
let disconnect = (): void => {}
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateEdges)
observer.observe(el)
disconnect = () => { observer.disconnect() }
}
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
// component is consumed — without preventDefault it would also scroll the
// conversation behind the composer, and React's root wheel listener is
// passive, so the exclusion needs this manually attached non-passive
// listener. A diagonal trackpad pan keeps its horizontal intent; a pure
// vertical wheel converts to a horizontal step, with LINE and PAGE deltas
// (Firefox notch wheels) normalized to pixels before the per-tick clamp
// that keeps a fast wheel followable. A purely horizontal pan stays
// native.
const onWheel = (event: globalThis.WheelEvent): void => {
if (event.deltaY === 0) return
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? WHEEL_LINE_PX
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
event.preventDefault()
el.scrollBy({
left: event.deltaX !== 0
? event.deltaX * scale
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
behavior: 'auto',
})
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => {
disconnect()
el.removeEventListener('wheel', onWheel)
}
}, [updateEdges])
const page = (direction: -1 | 1): void => {
const el = railRef.current
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
if (el === null) return
// One viewport minus a card keeps the last visible thumbnail as context;
// the floor keeps narrow rails paging a useful distance.
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
}
return (
<div className={css.root}>
{edges.left && (
<button
type="button"
className={clsx(css.arrow, css.arrowLeft)}
aria-label={labels.scrollLeft}
onClick={() => { page(-1) }}
>
<IconChevronLeftOutline14 />
</button>
)}
<div
ref={railRef}
className={css.rail}
role="group"
aria-label={labels.group}
onScroll={updateEdges}
>
{items.map(item => (
<div key={item.id} className={css.item}>
<button
type="button"
className={css.thumbnail}
title={labels.open}
onClick={() => { onOpen(item) }}
>
<img src={item.previewUrl} alt={item.alt} />
</button>
<button
type="button"
className={css.remove}
aria-label={item.removeLabel}
onClick={() => { onRemove(item) }}
>
<IconCloseFill14 size={12} />
</button>
</div>
))}
</div>
{edges.right && (
<button
type="button"
className={clsx(css.arrow, css.arrowRight)}
aria-label={labels.scrollRight}
onClick={() => { page(1) }}
>
<IconChevronRightOutline14 />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
export interface ImageLightboxLabels {
/** Accessible name of the preview dialog. */
dialog: string
/** Accessible label of the close control. */
close: string
}
/**
* Document-level original-image preview opened by clicking a thumbnail.
* Closes on Escape, backdrop press, or the close control, and restores focus
* to the opener on unmount. Rendered through a body portal: an opener inside
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
* in that ancestor's box instead of covering the viewport.
*
* @param props.src - the original image URL.
* @param props.alt - the image's alt text.
* @param props.labels - dialog and close-control strings.
* @param props.onClose - dismiss callback owned by the opener.
* @returns the modal preview dialog.
*/
export function ImageLightbox({ src, alt, labels, onClose }: {
src: string
alt: string
labels: ImageLightboxLabels
onClose: () => void
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return createPortal(
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
</div>,
document.body,
)
}

View File

@@ -24,7 +24,7 @@
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
border-radius: 16px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { ImageLightbox } from './ImageLightbox.tsx'
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Message-image strings the owner resolves from its own locale namespace. */
export interface MessageImageLabels {
/** Fallback display name for an unnamed image. */
image: string
/** Thumbnail tooltip inviting the original-image preview. */
open: string
/** Accessible thumbnail label; receives the image's display name. */
openNamed: (label: string) => string
/** Loading placeholder shown until bytes resolve. */
loading: string
/** Retry-control label shown when the load fails. */
loadFailed: string
/** Lightbox strings forwarded to the opened preview. */
lightbox: ImageLightboxLabels
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
* @returns the bounded thumbnail button, or the retry control on failure.
*/
export function MessageImage({ attachment, load, labels }: {
attachment: ImageAttachmentRef
load: ImageLoader
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
// retry — runs under the same liveness guard and the same reset.
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
useEffect(() => {
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title={labels.open}
aria-label={labels.openNamed(label)}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align, labels }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
labels: MessageImageLabels
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
))}
</div>
)
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,14 @@
/**
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
* the chat-history image gallery, and the original-image lightbox. Owners
* resolve every string through their own locale namespace and pass it down;
* nothing here reads application state.
* @module @deepseek-ai/dsh-client-ui-attachment
*/
export { AttachmentRail } from './AttachmentRail.tsx'
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
export { ImageLightbox } from './ImageLightbox.tsx'
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
export { ImageGallery, MessageImage } from './MessageImage.tsx'
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`.
* @module @deepseek-ai/dsh-client-ui-attachment/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-attachment'
/** Cordis companion plugin name. */
export const name = 'client-ui-attachment-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: pure props-in React atoms with no Cordis API —
* no events, no services, no mutable cross-plugin state; rendering contracts
* are asserted directly by this package's component specs.
*/
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 */

View File

@@ -0,0 +1,173 @@
// @vitest-environment jsdom
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
// exclusive vertical-wheel pan, and the new-item end reveal.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentRail } from '../src/AttachmentRail.tsx'
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
afterEach(cleanup)
// jsdom implements no ResizeObserver; the stub records instances so a test
// can drive the size-change recompute path.
const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = []
beforeEach(() => {
observers.length = 0
vi.stubGlobal('ResizeObserver', class {
observed: Element[] = []
constructor(callback: ResizeObserverCallback) {
observers.push({ callback, observed: this.observed })
}
observe(el: Element) { this.observed.push(el) }
disconnect() { this.observed.length = 0 }
})
})
afterEach(() => { vi.unstubAllGlobals() })
const labels: AttachmentRailLabels = {
group: '待发送图片',
open: '查看原图',
scrollLeft: '向左滚动图片',
scrollRight: '向右滚动图片',
}
function item(id: string): AttachmentRailItem {
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
}
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
let scrollLeft = 0
Object.defineProperty(rail, 'scrollLeft', {
configurable: true,
get: () => scrollLeft,
set: (value: number) => { scrollLeft = value },
})
const scrollBy = vi.fn((options: { left: number }) => {
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
})
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
}
describe('AttachmentRail', () => {
it('renders thumbnails in order and routes open and remove clicks', () => {
const onOpen = vi.fn()
const onRemove = vi.fn()
const items = [item('a'), item('b')]
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
const rail = view.getByRole('group', { name: '待发送图片' })
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
expect(onOpen).toHaveBeenCalledWith(items[0])
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
expect(onRemove).toHaveBeenCalledWith(items[1])
})
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.scroll(rail)
// Same-edges scroll takes the memoized-state path.
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
const right = view.getByLabelText('向右滚动图片')
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
fireEvent.click(right)
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
fireEvent.scroll(rail)
// Scrolled to the far edge: only the left arrow remains.
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.click(view.getByLabelText('向左滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
setScrollLeft(100)
// The component observes the rail element, not the window: a sidebar or
// panel resize reaches it through the ResizeObserver callback.
expect(observers.at(-1)?.observed).toContain(rail)
act(() => { observers.at(-1)!.callback([], undefined as never) })
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
// Converted ticks are consumed (preventDefault): fireEvent returns false.
expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false)
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: 500 })
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -500 })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// Firefox notch wheels report lines; a page-mode wheel reports viewports.
fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE })
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// A diagonal pan is consumed too — nothing vertical may escape the rail —
// and keeps its horizontal intent.
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false)
expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' })
// A purely horizontal pan and a zero-delta wheel keep native behavior.
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true)
fireEvent.wheel(rail, { deltaY: 0 })
expect(scrollBy).toHaveBeenCalledTimes(6)
})
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
fireEvent.scroll(rail)
fireEvent.click(view.getByLabelText('向右滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior })
view.unmount()
}
})
it('reveals the rail end when an item is added, not when one is removed', () => {
const first = [item('a'), item('b')]
const view = render(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
view.rerender(
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
expect(rail.scrollLeft).toBe(200)
view.rerender(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
// Removal keeps the position; only growth jumps to the end.
expect(rail.scrollLeft).toBe(200)
})
})

View File

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { ImageLightbox } from '../src/ImageLightbox.tsx'
afterEach(cleanup)
const labels = { dialog: '原图预览', close: '关闭原图预览' }
describe('ImageLightbox', () => {
it('focuses its close control, closes by button and Escape, and restores focus', () => {
const opener = document.createElement('button')
document.body.appendChild(opener)
opener.focus()
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
const close = view.getByRole('button', { name: '关闭原图预览' })
expect(document.activeElement).toBe(close)
fireEvent.keyDown(window, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.click(close)
expect(onClose).toHaveBeenCalledTimes(2)
view.unmount()
expect(document.activeElement).toBe(opener)
opener.remove()
})
it('tolerates a focus owner it cannot restore (no active element at mount)', () => {
// jsdom always reports body as the fallback active element; stub the
// element-less state a detached focus can leave.
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
try {
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
view.unmount()
} finally {
delete (document as { activeElement?: unknown }).activeElement
}
})
it('closes on a backdrop press but not on a press over the image', () => {
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
fireEvent.mouseDown(view.getByRole('img'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
import type { MessageImageLabels } from '../src/MessageImage.tsx'
afterEach(cleanup)
const labels: MessageImageLabels = {
image: '图片',
open: '查看原图',
openNamed: label => `${label},点击查看原图`,
loading: '图片加载中…',
loadFailed: '图片加载失败,点击重试',
lightbox: { dialog: '原图预览', close: '关闭原图预览' },
}
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
expect(frame.getAttribute('title')).toBe('查看原图')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('ignores a click while the thumbnail is still loading', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
expect(view.queryByRole('dialog')).toBeNull()
})
it('falls back to the image label for an unnamed attachment', async () => {
const { name: _named, ...unnamed } = attachment
const load = vi.fn().mockResolvedValue('blob:unnamed')
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retryAgain)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(3)
})
it('ignores a load settling after unmount', async () => {
let resolve: ((url: string) => void) | undefined
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
view.unmount()
resolve?.('blob:late')
await Promise.resolve()
let reject: ((error: Error) => void) | undefined
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
})
})
describe('ImageGallery', () => {
it('renders nothing without images and an aligned wrapping group with them', async () => {
const load = vi.fn().mockResolvedValue('blob:gallery')
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
expect(empty.container.firstChild).toBeNull()
const view = render(
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../support/invariants"
},
{
"path": "../ui-primitives"
}
]
}

View File

@@ -0,0 +1,35 @@
import { clientOnly } from '../tsdown.client.ts'
// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only
// the package differs). On a third atoms package, extract a shared css-stub
// client-library preset in packages/client/tsdown.client.ts instead of a
// fourth copy.
/**
* ui-attachment is browser-only, but its lib bundle IS imported under plain
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
* this package). CSS imports are therefore stubbed to empty modules instead
* of externalized — the hashed class maps only matter in bundler contexts
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
plugins: [{
name: 'dsh-css-stub',
resolveId(source: string) {
if (!source.endsWith('.css')) return null
return `\0dsh-css-stub:${source}.mjs`
},
load(id: string) {
if (!id.startsWith('\0dsh-css-stub:')) return null
return 'export default {};'
},
}],
}])

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-command/README.md
README.md: e49ce89804886a11f102fcaf60316e8044965c10
README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
README.zh.md: 639b7f997e967527232bb88116558c5457b4d497

View File

@@ -6,7 +6,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).

View File

@@ -6,7 +6,7 @@
`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claimspace / 带参 Enter与生命周期记账被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -49,6 +50,8 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -57,10 +60,10 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale']
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
/**
* Client plugin body: mount the service, then register the popupSelect shell

View File

@@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
@@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
static inject = ['slash', 'sessions', 'connection', 'remote']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
@@ -123,12 +126,12 @@ export class CommandService extends Service implements CommandServiceContract {
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
warm: (session) => { this.directory.warm(session.sessionId) },
}), 'command: slash source')
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
// A preset switch changes which commands one session's agent resolves and
// registers nothing globally, so the registry-wide signal above never
// fires for it: repull that key alone, soft, so the old snapshot serves
// the menu until the new one lands.
ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
ctx.on('connection/reset', () => { this.directory.resetConnected() })
}

View File

@@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -38,6 +39,8 @@ async function bench() {
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
// CommandService injects `remote` for the forwarded directory invalidation.
new TestRemote(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -50,7 +53,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -10,6 +10,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
@@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) {
: undefined,
})
ctx.provide('connection', { api })
// CommandService injects `remote`; the directory invalidation arrives on the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -598,7 +602,7 @@ describe('popupFor', () => {
})
describe('directory invalidation events', () => {
it('commands/changed repulls in the background while the old snapshot serves', async () => {
it('commands/change repulls in the background while the old snapshot serves', async () => {
let round = 0
const { ctx, source, warm } = await bench({
commands: () => {
@@ -611,13 +615,13 @@ describe('directory invalidation events', () => {
},
})
await warm(proj('s1'))
ctx.emit('commands/changed')
ctx.remote.$dispatch('commands/change', [])
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
})
it('session/preset-changed repulls the recomposed session and leaves the others served', async () => {
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
const rounds = new Map<SessionId, number>()
const { ctx, source, warm } = await bench({
commands: (payload) => {
@@ -634,7 +638,7 @@ describe('directory invalidation events', () => {
await warm(proj('s2'))
// A preset switch changes which commands one session's agent resolves;
// every other session keeps the catalog its own composition serves.
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,6 +35,8 @@
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
@@ -51,49 +53,55 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -2,8 +2,11 @@
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
// goes through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -46,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the conversation plugin. */
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
'conversationEvents', 'conversationViews',
]
@@ -128,7 +131,7 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy(
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({

View File

@@ -13,8 +13,9 @@ import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import { messageImageLabels } from '../image-labels.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (

View File

@@ -1,72 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, load, t }: {
attachment: ImageAttachmentRef
load: ImageLoader
t: ChatViewSlotProps['t']
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
const label = attachment.name ?? t('image.label')
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title={t('image.openOriginal')}
aria-label={t('image.openOriginalLabel', { label })}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align, t }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
t: ChatViewSlotProps['t']
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
))}
</div>
)
}

View File

@@ -10,10 +10,11 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
import { messageImageLabels } from '../image-labels.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './MessageItem.module.css'
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
@@ -177,7 +178,7 @@ function UserStyleBubble({
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
<ImageGallery images={images} load={imageLoader} align="end" labels={messageImageLabels(t)} />
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}

View File

@@ -0,0 +1,48 @@
/** Bridges the `conversation` locale namespace to the zero-cordis attachment
* atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
* @returns the lightbox dialog and close-control labels.
*/
export function lightboxLabels(t: Translate<ConversationKey>): ImageLightboxLabels {
return { dialog: t('image.preview'), close: t('image.closePreview') }
}
/**
* Resolve the chat-history image strings.
* @param t - the conversation-namespace translate.
* @returns the message-image labels including the forwarded lightbox strings.
*/
export function messageImageLabels(t: Translate<ConversationKey>): MessageImageLabels {
return {
image: t('image.label'),
open: t('image.openOriginal'),
openNamed: label => t('image.openOriginalLabel', { label }),
loading: t('image.loading'),
loadFailed: t('image.loadFailed'),
lightbox: lightboxLabels(t),
}
}
/**
* Resolve the composer draft-image rail strings.
* @param t - the conversation-namespace translate.
* @returns the rail group, open-tooltip, and paging-arrow labels.
*/
export function attachmentRailLabels(t: Translate<ConversationKey>): AttachmentRailLabels {
return {
group: t('image.pending'),
open: t('image.openOriginal'),
scrollLeft: t('image.scrollLeft'),
scrollRight: t('image.scrollRight'),
}
}

View File

@@ -27,9 +27,11 @@ export const zh = {
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '双击查看原图',
'image.openOriginalLabel': '{label}击查看原图',
'image.openOriginal': '查看原图',
'image.openOriginalLabel': '{label}击查看原图',
'image.remove': '移除图片 {name}',
'image.scrollLeft': '向左滚动图片',
'image.scrollRight': '向右滚动图片',
'image.original': '原图',
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
@@ -184,9 +186,11 @@ export const en = {
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.pending': 'Pending images',
'image.openOriginal': 'Double-click to view original',
'image.openOriginalLabel': '{label}, double-click to view original',
'image.openOriginal': 'View original',
'image.openOriginalLabel': '{label}, click to view original',
'image.remove': 'Remove image {name}',
'image.scrollLeft': 'Scroll images left',
'image.scrollRight': 'Scroll images right',
'image.original': 'Original image',
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',

View File

@@ -1,40 +0,0 @@
import { useEffect, useRef } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose, t }: {
src: string
alt: string
onClose: () => void
t: ChatViewSlotProps['t']
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return (
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label={t('image.preview')}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
</div>
)
}

View File

@@ -35,22 +35,6 @@
padding: 0 var(--dsh-composer-side-clearance);
}
.error,
.status {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
line-height: 18px;
}
.status {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.notice {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
@@ -68,11 +52,6 @@
color: var(--dsw-alias-state-error-primary);
}
.error {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.card {
box-sizing: border-box;
position: relative; /* overlay anchor positioning context */
@@ -162,55 +141,13 @@
padding: 10px 12px 0;
}
/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek
Chat's spacing above the thumbnails; the card's 12px flex gap owns the space
below. The rail itself (arrows, hidden scrollbar, card geometry) is the
ui-attachment atom's. */
.attachments {
display: flex;
gap: 8px;
min-width: 0;
padding: 12px 12px 0;
overflow-x: auto;
overflow-y: hidden;
}
.attachment {
position: relative;
flex: 0 0 72px;
width: 72px;
height: 72px;
}
.thumbnail {
width: 72px;
height: 72px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove {
position: absolute;
top: -6px;
right: -6px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--dsw-specific-input-major);
border-radius: 999px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-specific-input-major);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 4px 12px 0;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position

View File

@@ -9,7 +9,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
@@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { ImageLightbox } from './ImageLightbox.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
message: string
/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */
interface ComposerRailItem extends AttachmentRailItem {
attachment: ComposerAttachment
}
export type InputBarProps = ComposerBarProps
@@ -56,12 +59,6 @@ export function InputBar({
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
const hasGoal = useProjection('goal', goal => goal != null)
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
@@ -73,8 +70,26 @@ export function InputBar({
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const [dropError, setDropError] = useState<string | null>(null)
// Transient error banner (image-intake rejections and prompt failures): the
// seq keys the Toast so an identical repeated message restarts the
// hold-then-fade cycle instead of silently reusing the faded one.
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
const toastSeq = useRef(0)
const showToast = useCallback((text: string) => {
toastSeq.current += 1
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
@@ -369,7 +384,10 @@ export function InputBar({
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) setDropError(addImages(files))
if (files.length > 0 && addImages !== undefined) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -393,7 +411,6 @@ export function InputBar({
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
}
@@ -416,11 +433,24 @@ export function InputBar({
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) setDropError(addImages(dropped))
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
}
}
const closePreview = useCallback(() => { setPreview(null) }, [])
// Rail thumbnails with their strings resolved here: the attachment atoms are
// zero-cordis and read no locale.
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
id: attachment.id,
previewUrl: attachment.previewUrl,
alt: attachment.file.name || t('image.pending'),
removeLabel: t('image.remove', { name: attachment.file.name }),
attachment,
})), [attachments, t])
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
@@ -543,10 +573,14 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error} role="alert">
{error.message}
</div>
{toast !== null && (
<Toast
key={toast.seq}
text={toast.text}
icon={<IconWarningOutline16 />}
anchor={cardRef.current}
onDone={dismissToast}
/>
)}
{notice !== null && (
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
@@ -558,8 +592,8 @@ export function InputBar({
their pointer events), so the WHOLE capsule is the pick target.
pointerdown stops here so the Menu's outside-close cannot race the
click's reopen (close-then-open flickers the chip's open echo). */}
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
@@ -572,29 +606,14 @@ export function InputBar({
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title={t('image.openOriginal')}
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
<button
type="button"
className={css.remove}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => {
setDropError(null)
removeImage?.(attachment.id)
}}
>×</button>
</div>
))}
{railItems.length > 0 && (
<div className={css.attachments}>
<AttachmentRail
items={railItems}
labels={attachmentRailLabels(t)}
onOpen={(item) => { setPreview(item.attachment) }}
onRemove={(item) => { removeImage?.(item.attachment.id) }}
/>
</div>
)}
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
@@ -628,10 +647,7 @@ export function InputBar({
? t('placeholder.steerQueue')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={(event) => {
setDropError(null)
onChange(event)
}}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
@@ -712,8 +728,8 @@ export function InputBar({
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
labels={lightboxLabels(t)}
onClose={closePreview}
t={t}
/>
)}
{footer}

View File

@@ -15,7 +15,7 @@
// chat-toolview-slot.spec.tsx.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -48,6 +48,9 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,

View File

@@ -6,7 +6,7 @@ import { useState } from 'react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
usePinnedBrowserLanguages('zh-CN')
@@ -51,6 +51,9 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -76,6 +79,9 @@ describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -103,6 +109,9 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -169,6 +178,9 @@ describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)

View File

@@ -6,7 +6,7 @@
// entries. Tool composition belongs to ui-tool and its machinery spec.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -22,6 +22,9 @@ const CHILD = 'child-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
// The conversation-side bridge to the ui-attachment atoms: dictionary strings
// flow through image-labels into the gallery, and assistant images keep their
// block position between text blocks.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('title')).toBe('查看原图')
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('resolves the active English dictionary', async () => {
const view = render(
<AssistantMarkdown
t={enT}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png, click to view original' })
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -237,15 +237,49 @@ describe('image draft rail', () => {
expect(removeImage).toHaveBeenCalledWith('draft-1')
})
it('opens the original image on double-click and closes it with Escape', () => {
it('opens the original image on a single click and closes it with Escape', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view } = bench({ attachments: [attachment] })
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
fireEvent.click(view.getByTitle('查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.keyDown(window, { key: 'Escape' })
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
vi.useFakeTimers()
try {
const addImages = vi.fn(() => '不支持的图片格式text/plain')
const { view, textarea } = bench({ addImages })
const paste = () => {
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
getData: () => '',
},
})
}
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式text/plain')
act(() => { vi.advanceTimersByTime(4000) })
expect(view.queryByRole('alert')).toBeNull()
// The identical rejection re-announces: the toast is keyed per show.
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式text/plain')
} finally {
vi.useRealTimers()
}
})
it('announces a rejected drop through the same toast', () => {
const addImages = vi.fn(() => '图片读取服务不可用')
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
fireEvent.drop(card, { dataTransfer })
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
})
})
describe('Enter semantics', () => {
@@ -941,10 +975,19 @@ describe('insertText (scoped event body)', () => {
})
describe('strips and variants', () => {
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
vi.useFakeTimers()
try {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
// The toast body-portals (transformed ancestors must not trap it), so
// queries go through the view's document-bound helpers.
expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
act(() => { vi.advanceTimersByTime(4000) })
expect(send.view.queryByRole('alert')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('renders the notice strip from the machine notice store', () => {

View File

@@ -1,81 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('surfaces a retry control when durable bytes cannot be read', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../ui-slots"
},
{
"path": "../ui-attachment"
},
{
"path": "../ui-primitives"
},
@@ -76,6 +79,9 @@
},
{
"path": "../../interaction/permission"
},
{
"path": "../ui-settings"
}
],
"exclude": [

View File

@@ -16,9 +16,9 @@ import type {
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, ToolResultNode, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
@@ -341,7 +341,10 @@ describe('plugin registration', () => {
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
} as never, () => null)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()

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-goal/README.md
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd
README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn.
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
## Model Experience

View File

@@ -4,6 +4,8 @@
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`ctx.remote.goals` 调用——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node并为该 Node 注册 keyed rendererrenderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input``命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat重新加载时会根据 run 重建该 Node而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## 模型体验

View File

@@ -52,6 +52,7 @@
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
@@ -65,6 +66,7 @@
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",

View File

@@ -0,0 +1,25 @@
.row {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.stack {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 0;
max-width: min(525px, 82%);
}
.bubble {
max-width: 100%;
padding: 10px 16px;
overflow-wrap: anywhere;
border-radius: 22px;
background: var(--dsw-specific-bubble);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code);
white-space: pre-wrap;
}

View File

@@ -0,0 +1,30 @@
import { memo } from 'react'
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { GoalCommandInputData } from './goal-command-input.ts'
import css from './GoalCommandInputView.module.css'
type GoalCommandInputViewProps =
PropsRuntime<'conversation.chat.node', 'command-input'>
& PropsLocale<'goal'>
/** Right-aligned `/goal` input bubble without ordinary message actions. */
export const GoalCommandInputView = memo(function GoalCommandInputView({
node, t,
}: GoalCommandInputViewProps) {
const data: GoalCommandInputData = node.data
return (
<div
className={css.row}
data-command-input=""
role="group"
aria-label={t('commandInput.aria')}
>
<div className={css.stack}>
<div className={css.bubble}>
<MessageText text={data.text} />
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,71 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {} from '@deepseek-ai/dsh-commands/types'
import type {
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Goal-owned human command input projected independently of model messages. */
export interface GoalCommandInputData {
readonly commandId: CommandId
readonly text: string
readonly time: number
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Human-entered `/goal` command input. */
'command-input': GoalCommandInputData
}
}
interface GoalCommandInputState extends GoalCommandInputData {
readonly seq: number
}
/**
* Derive the visible command line from its structured durable run.
* @param event - `/goal` command run.
* @returns command text with trailing parser whitespace removed.
*/
export function goalCommandText(event: SessionEvent<'command/run'>): string {
return `/${event.data.name}${(event.data.args ?? '').trimEnd()}`
}
/** Goal-owned command input projection; the generic command Definition retains the result row. */
export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
kind: 'goal-command-input',
target: 'chat',
match: event => event.type === 'command/run' && event.data.name === 'goal'
? { id: String(event.data.commandId), role: 'start' }
: null,
start: (_context, match) => {
if (match.event.type !== 'command/run') {
throw new Error('goal-command-input start requires command/run')
}
return {
commandId: match.event.data.commandId,
seq: match.event.seq,
time: match.event.time,
text: goalCommandText(match.event),
}
},
update: context => context.state,
buildViewNode: (context) => {
if (context.state === undefined) return null
return {
key: context.key,
kind: 'command-input',
id: context.id,
target: 'chat',
anchorSeq: context.state.seq - 0.1,
location: context.start?.location ?? { kind: 'unresolved' },
visibility: 'visible',
data: {
commandId: context.state.commandId,
text: context.state.text,
time: context.state.time,
},
}
},
}

View File

@@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import { GoalDock } from './GoalBar.tsx'
import { GoalCommandInputView } from './GoalCommandInputView.tsx'
import { goalCommandInputDefinition } from './goal-command-input.ts'
import { en, zh, type GoalKey } from './locales.ts'
export { GoalBar, GoalDock } from './GoalBar.tsx'
@@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
@@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(goalCommandInputDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'command-input',
locale: NS,
}, GoalCommandInputView))
const sessions = ctx.sessions
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */

Some files were not shown because too many files have changed in this diff Show More