Merge remote-tracking branch 'origin/master' into fix/stats

This commit is contained in:
07akioni
2026-08-12 21:22:05 +08:00
465 changed files with 11350 additions and 1149 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca
README.md: 236531281c17ef982982e97caad99491584bd0b5
README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64
+1
View File
@@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
+1
View File
@@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
README.zh.md: d4c19db8e74e3562ec55b5ef60675aef79b82008
README.md: d3727df981ecbc022345def48b38fbc879e29cb2
README.zh.md: 69af4cdba553399c0db0e0587e540c2a7f5375d2
+1
View File
@@ -23,3 +23,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits.
+1
View File
@@ -23,3 +23,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## 已知限制与暂缓事项
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
- **`/api` 桥把每个请求体整体缓冲在内存里**`maxRequestBodyBytes`(默认 160 MiB,按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。
@@ -353,7 +353,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
}
/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
/** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record<string, unknown>[] = []
@@ -489,7 +489,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 74: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -548,20 +548,35 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
// Turn 72: user and assistant images share one durable fixture object.
// The todo turn remains last so its standing projection stays visible.
// Turn 72: max-tokens sample — the provider ends the turn at its output cap
// mid-sentence, so the chat flow must render the turn-max-tokens notice
// instead of ending silently. Ordered before the todo turn for the same
// standing-plan reason the bash turn is.
push({ type: 'turn/start', data: { turn: 72 } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text('问题 72:请完整列出全部一百条条目。')) })
push({ type: 'step/start', data: { turn: 72, step: 0 } })
push({
type: 'assistant/message',
surfaceOp: 'append',
data: { turn: 72, step: 0, message: assistantMessage(text('条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被')) },
})
push({ type: 'step/end', data: { turn: 72, step: 0 } })
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'max-tokens' } } })
// Turn 73: user and assistant images share one durable fixture object.
// The todo turn remains last so its standing projection stays visible.
push({ type: 'turn/start', data: { turn: 73 } })
push({
type: 'user/message',
surfaceOp: 'append',
data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
})
push({ type: 'step/start', data: { turn: 72, step: 0 } })
push({ type: 'step/start', data: { turn: 73, step: 0 } })
push({
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: 72,
turn: 73,
step: 0,
message: assistantMessage(
[...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
@@ -569,11 +584,11 @@ function buildAlphaLog(): SessionEvent[] {
),
},
})
push({ type: 'step/end', data: { turn: 72, step: 0 } })
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
push({ type: 'step/end', data: { turn: 73, step: 0 } })
push({ type: 'turn/end', data: { turn: 73, reason: { kind: 'completed' } } })
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
toolTurn(74, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -1052,6 +1067,18 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['contextBreakdown'] = contextBreakdownOf(log)
// Always present (session-stats unit composed): whole-log turn/step counts.
values['sessionStats'] = sessionStatsOf(log)
// Always present (attachment service composed): the deployment image
// limits, constant per boot (mirrors the attachment-local defaults).
// Deliberate host divergence: the real gateway never pushes an imageLimits
// change frame (constant unit), but the fixture's uniform baseline replay
// frames every key here, incidentally exercising higher-seq-wins.
values['imageLimits'] = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
return values
}
@@ -1516,7 +1543,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
])
let fixtureDefaultPreset = 'standard'
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 74]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 75]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
@@ -5,6 +5,12 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Default carrier cap for all HTTP RPC bodies: sized for the default
* aggregate image limit (100 MiB) after base64 expansion plus envelope
* headroom (~134.3 MiB required), rounded up for slack. The bridge buffers
* each body in memory, so this cap is also the per-request resident bound. */
export const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024
/** Transport-independent request handler consumed by the Host HTTP bridge. */
export interface FetchHandler {
/**
@@ -27,7 +33,7 @@ export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: FetchHandler,
maxRequestBodyBytes = 32 * 1024 * 1024,
maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
+1 -3
View File
@@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-attachment'
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { HostConnectionService } from './rpc-host.ts'
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
@@ -42,8 +42,6 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
)
}
}
/** Default carrier cap for all HTTP RPC bodies. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
@@ -168,6 +168,13 @@ describe('createFixtureApi', () => {
sessionStats: {
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
},
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},
} },
})
})
@@ -357,7 +364,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 12) abort.abort()
if (envelopes.length >= 13) abort.abort()
}
return envelopes
}
@@ -380,10 +387,14 @@ describe('createFixtureApi', () => {
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' })
expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0)
expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[11]?.rpcId).toBe(first[11]?.rpcId)
expect(first[10]?.payload).toMatchObject({
type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits',
value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 },
})
expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[12]?.rpcId).toBe(first[12]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294
README.md: 441f5462d98c8a9e92ebc9f08cdd7568e89fd427
README.zh.md: 9e31d51410639cdd314ddd37c2c6287f26f8c484
+2
View File
@@ -68,6 +68,8 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start
The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.
A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
+2
View File
@@ -68,6 +68,8 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别
Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry``llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
reason 为 `max-tokens``turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
+2 -1
View File
@@ -77,7 +77,8 @@ export type {
CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,
@@ -96,6 +96,12 @@ export interface AssistantTiming {
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
/**
* Stable identity of the finalized model output, carried from the
* `assistant/message` event. Absent on interruption-frozen partials: those
* were never finalized, so they address no durable message.
*/
messageId?: MessageId
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
time: number
turn: number
@@ -163,6 +169,17 @@ export interface TurnErrorNode {
code?: string
}
/** Durable notice for a turn ended by the per-request output-token cap. */
export interface TurnMaxTokensNode {
kind: 'turn-max-tokens'
/** Seq of the owning turn/end event. */
seq: number
/** Unix epoch ms from the turn/end event. */
time: number
turn: number
step: number
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -268,6 +285,7 @@ export type ConversationNode =
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
| TurnMaxTokensNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode
@@ -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-attachment/README.md
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
README.md: 65db3f03b3ef174d12de75786e197da4712c4de1
README.zh.md: 2b714e880912d6dc097ce73aca4286a674bdade5
+6 -2
View File
@@ -2,7 +2,7 @@
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.
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). 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
@@ -10,7 +10,11 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
## 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.
`MessageImage` renders one durable history image, 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). Sizing follows DeepSeek Chat: a message's lone image (`variant="single"`) renders at 240px on its longer edge with the displayed aspect ratio clamped to [0.25, 4] — the overflow is cropped by `object-fit: cover`, anchored to the top of very tall images and the left of very wide ones — and never upscales past its natural size; an image among several (`variant="tile"`) is a fixed 64px square. `ImageGallery` wraps a message's images in one aligned wrapping flex group (`end` for user messages, `start` for assistant messages), picks the variant from the image count, and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview over the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, painted on its own layer so the blur never touches the previewed image) that closes on Escape, a mask press, or its close control, and restores focus to its opener on unmount.
## Drop overlay
`DropOverlay` is the full-viewport invitation shown while a file drag is over the page: illustration, title, and a limits line while drops are accepted (`disabled` swaps the blocked illustration and hides the limits line). The layer is pointer-inert — the owner's document-level drag listeners keep the enter/leave count and decide accept/reject; the overlay only shows state. It portals to the body like the lightbox.
## Model Experience
+6 -2
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`原图灯箱(`ImageLightbox`与整页拖放遮罩(`DropOverlay`。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
## 附件栏
@@ -10,7 +10,11 @@
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
`MessageImage` 渲染一张持久化历史图片,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。尺寸规则对齐 DeepSeek Chat:一条消息仅有的一张图(`variant="single"`)长边 240px、展示宽高比钳制在 [0.25, 4] 之间——超出部分由 `object-fit: cover` 裁切,特别高的图锚定顶部、特别宽的图锚定左侧——且从不放大超过原始尺寸;多图中的一张(`variant="tile"`)为固定 64px 方块。`ImageGallery` 将一条消息的图片包为一个对齐的可换行弹性分组(用户消息 `end`,助手消息 `start`),按图片数量选择 variant空列表不渲染。`ImageLightbox` 是文档级模态预览,铺在共享的对话框遮罩上(`--dsw-alias-bg-mask-1``--dsw-mask-blur`,画在独立图层上,模糊不会波及预览图本身),按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
## 拖放遮罩
`DropOverlay` 是文件拖拽悬停页面时的全视口邀请层:插画、标题,接受拖放时再加一行上限说明(`disabled` 换为禁用插画并隐藏上限行)。该层不接收指针事件——持有方的 document 级拖拽监听器负责 enter/leave 计数和接受与否的判定;遮罩只呈现状态。与灯箱一样经 body portal 渲染。
## 模型体验
@@ -0,0 +1,54 @@
/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events:
none — the layer is decoration; drag events must keep hitting the page so
the owner's enter/leave count stays balanced. The frosted sheet color is
the theme's drop-mask alias (dark override lives with the theme owner). */
.mask {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background-color: var(--dsw-alias-bg-mask-drop);
backdrop-filter: blur(10px);
animation: fade-in 160ms ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.mask {
animation: none;
}
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
margin-top: -3%;
padding: 0 40px;
color: var(--dsw-alias-label-primary);
text-align: center;
}
.illustration {
width: 115px;
height: 84px;
}
.title {
margin-top: 16px;
font: var(--dsw-font-l-20);
}
.desc {
margin-top: 16px;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
}
@@ -0,0 +1,77 @@
import { createPortal } from 'react-dom'
import css from './DropOverlay.module.css'
/** Drop-overlay strings the owner resolves from its own locale namespace. */
export interface DropOverlayLabels {
/** Headline inviting the drop, or naming why it is unavailable. */
title: string
/** Limits line under the title; shown only while drops are accepted. */
desc?: string | undefined
}
/**
* Full-viewport invitation shown while a file drag is over the page
* (DeepSeek Chat's DragMask). Decoration only: `pointer-events: none` keeps
* drag targeting on the page below, so the owner's document-level listeners
* keep an accurate enter/leave count and own accept/reject. Rendered through
* a body portal for the same transformed-ancestor reason as the lightbox.
*
* @param props.disabled - drops are currently refused; renders the blocked
* illustration and drops the desc line.
* @param props.labels - resolved title and limits strings.
* @returns the overlay layer.
*/
export function DropOverlay({ disabled, labels }: {
disabled: boolean
labels: DropOverlayLabels
}) {
return createPortal(
<div className={css.mask} role="status">
<div className={css.wrap}>
<div className={css.illustration} aria-hidden="true">
{disabled ? <UploadDisabledIllustration /> : <UploadIllustration />}
</div>
<div className={css.title}>{labels.title}</div>
{!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
</div>
</div>,
document.body,
)
}
/** Tilted photo-and-note cards (DeepSeek Chat upload illustration). */
const UploadIllustration = () => (
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#dshDropOverlayClip)">
<rect y="17.0742" width="44.1832" height="43.6431" rx="12" transform="rotate(-22.7338 0 17.0742)" fill="#9CE5ED" />
<rect x="73.4043" y="8.54297" width="43.7267" height="50.5284" rx="8" transform="rotate(17.403 73.4043 8.54297)" fill="#679EFE" />
<path d="M30.4917 28.1369L40.8865 33.4564L37.2232 34.9524L29.5302 31.0159L26.7919 39.2122L23.1285 40.7082L26.8287 29.6338L16.8967 24.5516L20.5601 23.0556L27.7902 26.7549L30.3639 19.052L34.0273 17.556L30.4917 28.1369Z" fill="white" />
<path d="M77.5088 26.3047L101.057 33.7966" stroke="white" strokeWidth="3" />
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
<rect x="31.583" y="38.6641" width="44.9157" height="44.3666" rx="12" transform="rotate(-0.134233 31.583 38.6641)" fill="#3964FE" />
<path d="M38.9521 73.0337C39.6129 71.7086 41.7113 66.0937 43.5113 61.1663C44.1607 59.3885 46.7484 59.3923 47.4591 61.1465C48.9728 64.8828 50.7969 68.6922 51.9988 69.1925C54.2946 70.1482 57.9854 59.3573 68.0064 70.1801" stroke="white" strokeWidth="3" />
<circle cx="60.6157" cy="52.247" r="4.38794" transform="rotate(22.5996 60.6157 52.247)" fill="white" />
</g>
<defs>
<clipPath id="dshDropOverlayClip">
<rect width="115" height="84" fill="white" />
</clipPath>
</defs>
</svg>
)
/** Greyed cards with a blocked badge (DeepSeek Chat disabled illustration). */
const UploadDisabledIllustration = () => (
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.6829 4.63701L11.0677 12.4368C4.95519 14.998 2.07624 22.0294 4.6374 28.1419L12.2285 46.259C14.7896 52.3715 21.8211 55.2505 27.9336 52.6893L46.5488 44.8895C52.6613 42.3283 55.5403 35.2969 52.9791 29.1844L45.388 11.0673C42.8269 4.9548 35.7954 2.07585 29.6829 4.63701Z" fill="#979DA6" />
<path d="M30.4915 28.1375L40.8863 33.4569L37.223 34.9529L29.53 31.0165L26.7917 39.2128L23.1283 40.7088L26.8285 29.6344L16.8965 24.5522L20.5599 23.0562L27.79 26.7555L30.3637 19.0526L34.0271 17.5566L30.4915 28.1375Z" fill="white" />
<path d="M107.496 19.2285L81.0381 10.9357C76.8221 9.61423 72.333 11.9607 71.0116 16.1768L60.6844 49.1246C59.363 53.3406 61.7095 57.8297 65.9255 59.1511L92.383 67.4439C96.599 68.7654 101.088 66.4189 102.41 62.2029L112.737 29.255C114.058 25.039 111.712 20.55 107.496 19.2285Z" fill="#979DA6" />
<path d="M77.5088 26.3047L101.057 33.7967" stroke="white" strokeWidth="3" />
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
<path d="M66.5798 30.1418L41.481 30.2006C33.5281 30.2193 27.0962 36.6815 27.1148 44.6343L27.172 69.0742C27.1907 77.0271 33.6529 83.459 41.6057 83.4404L66.7045 83.3816C74.6574 83.363 81.0894 76.9008 81.0707 68.9479L81.0135 44.5081C80.9949 36.5552 74.5327 30.1232 66.5798 30.1418Z" fill="#F59E0B" />
<path d="M54 70.7969C61.732 70.7969 68 64.5289 68 56.7969C68 49.0649 61.732 42.7969 54 42.7969C46.268 42.7969 40 49.0649 40 56.7969C40 64.5289 46.268 70.7969 54 70.7969Z" stroke="white" strokeWidth="3.5" />
<path d="M44 46.7969L64 66.7969" stroke="white" strokeWidth="3.5" strokeLinecap="round" />
</svg>
)
@@ -5,10 +5,20 @@
display: grid;
place-items: center;
padding: 40px;
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
}
/* Same mask recipe as the Modal primitive and the settings dialog. A separate
layer, not a background on .backdrop: backdrop-filter there would blur the
previewed image and the close control along with the page. */
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
.image {
position: relative;
max-width: min(100%, 1600px);
max-height: calc(100vh - 80px);
object-fit: contain;
@@ -21,6 +31,7 @@
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
display: grid;
place-items: center;
width: 36px;
@@ -29,6 +40,5 @@
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
font-size: 24px;
cursor: pointer;
}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
@@ -51,10 +52,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
<IconCloseOutline16 size={16} />
</button>
</div>,
document.body,
)
@@ -1,8 +1,8 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: min(240px, 100%);
gap: 10px;
max-width: 100%;
}
.gallery[data-align='end'] {
@@ -29,11 +29,18 @@
cursor: zoom-in;
}
.frame[data-variant='tile'] {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
}
.frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
.loading,
@@ -51,3 +58,12 @@
background: var(--dsw-alias-interactive-bg-hover-danger);
cursor: pointer;
}
/* A failed tile keeps the 64px grid cell instead of growing to its copy. */
.error[data-variant='tile'] {
width: 64px;
height: 64px;
padding: 4px;
overflow: hidden;
border-radius: 16px;
}
@@ -23,18 +23,38 @@ export interface MessageImageLabels {
lightbox: ImageLightboxLabels
}
/** Display box for a lone image (DeepSeek Chat rule): long edge 240px with
* the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
* `object-fit: cover` — and never upscaled past the image's natural size. The
* crop anchor keeps the top of very tall images and the left of very wide
* ones, where the informative content usually starts. */
function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } {
const natural = attachment.width / attachment.height
const ratio = Math.min(4, Math.max(0.25, natural))
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height)
return {
width: Math.max(1, Math.round(box.width * scale)),
height: Math.max(1, Math.round(box.height * scale)),
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
}
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview.
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.variant - `single` for a message's lone image, `tile` otherwise.
* @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 }: {
export function MessageImage({ attachment, load, variant, labels }: {
attachment: ImageAttachmentRef
load: ImageLoader
variant: 'single' | 'tile'
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
@@ -45,10 +65,10 @@ export function MessageImage({ attachment, load, labels }: {
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])
const fit = useMemo(
() => (variant === 'single' ? singleFit(attachment) : undefined),
[attachment, variant],
)
useEffect(() => {
let live = true
@@ -59,25 +79,29 @@ export function MessageImage({ attachment, load, labels }: {
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
data-variant={variant}
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
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} />}
{src === null
? <span className={css.loading}>{labels.loading}</span>
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
/** Wrapping image group shared by user and assistant history: a lone image
* renders large, several render as 64px square tiles (DeepSeek Chat rule). */
export function ImageGallery({ images, load, align, labels }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
@@ -85,10 +109,11 @@ export function ImageGallery({ images, load, align, labels }: {
labels: MessageImageLabels
}) {
if (images.length === 0) return null
const variant = images.length === 1 ? 'single' : 'tile'
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
))}
</div>
)
+5 -3
View File
@@ -1,13 +1,15 @@
/**
* 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.
* the chat-history image gallery, the original-image lightbox, and the
* full-page drop overlay. 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 { DropOverlay } from './DropOverlay.tsx'
export type { DropOverlayLabels } from './DropOverlay.tsx'
export { ImageLightbox } from './ImageLightbox.tsx'
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
export { ImageGallery, MessageImage } from './MessageImage.tsx'
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { DropOverlay } from '../src/DropOverlay.tsx'
afterEach(cleanup)
describe('DropOverlay', () => {
it('portals the invitation with its title and limits desc to the body', () => {
const view = render(
<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加', desc: '最多 20 张,每张 5MB' }} />,
)
const overlay = view.getByRole('status')
expect(overlay.parentElement).toBe(document.body)
expect(overlay.textContent).toContain('图片拖动到此处即可添加')
expect(overlay.textContent).toContain('最多 20 张,每张 5MB')
})
it('omits the desc line when none is resolved', () => {
const view = render(<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加' }} />)
expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加')
})
it('drops the desc and switches the illustration while disabled', () => {
const enabled = render(
<DropOverlay disabled={false} labels={{ title: '拖入', desc: '限制' }} />,
)
const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML
enabled.unmount()
const disabled = render(
<DropOverlay disabled labels={{ title: '当前无法添加图片', desc: '限制' }} />,
)
const overlay = disabled.getByRole('status')
expect(overlay.textContent).toBe('当前无法添加图片')
expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg)
})
})
@@ -39,12 +39,13 @@ describe('ImageLightbox', () => {
}
})
it('closes on a backdrop press but not on a press over the image', () => {
it('closes on a mask 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: '原图预览' }))
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
fireEvent.mouseDown(mask)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -29,7 +29,7 @@ const attachment = {
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 view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -44,7 +44,7 @@ describe('MessageImage', () => {
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 view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
@@ -54,7 +54,7 @@ describe('MessageImage', () => {
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} />)
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
@@ -73,16 +73,59 @@ describe('MessageImage', () => {
expect(load).toHaveBeenCalledTimes(3)
})
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
const load = vi.fn().mockResolvedValue('blob:ratio')
const tall = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
)
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
tall.unmount()
const wide = render(
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
)
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
wide.unmount()
const small = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
)
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
})
it('renders a tile at the fixed square without inline sizing', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('data-variant')).toBe('tile')
expect(frame.getAttribute('style')).toBeNull()
})
it('keeps the tile variant on the failed-load retry control', async () => {
const load = vi.fn().mockRejectedValue(new Error('offline'))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
expect(retry.getAttribute('data-variant')).toBe('tile')
})
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} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" 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} />)
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
@@ -100,4 +143,15 @@ describe('ImageGallery', () => {
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
})
it('renders a lone image large and several images as square tiles', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
lone.unmount()
const several = render(
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
README.zh.md: 639b7f997e967527232bb88116558c5457b4d497
README.md: 0281df76fe601eaad86cefc0dcaecc6d8999df60
README.zh.md: ace51230d6d250f4a3b09a5212182ccf434f9249
+2
View File
@@ -8,6 +8,8 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
`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.
After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running.
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).
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
+2
View File
@@ -8,6 +8,8 @@
`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 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
@@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis'
// 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 { CommandResult } from '@deepseek-ai/dsh-commands/types'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
@@ -23,6 +24,28 @@ import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
import type { TokenSegment } from './popup.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* This browser client completed one admitted Host command execution.
* Other clients receive the durable command nodes but never this local
* submission acknowledgment.
* @param sessionId - Session addressed by the local submission.
* @param name - Executed command name without the leading slash.
* @param result - Host command result returned to this browser.
* @mode emit
*/
'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void
}
}
/** Recover the command name from a line the Host confirmed as executed. */
function submittedCommandName(line: string): string {
const trimmed = line.trim()
const separator = trimmed.search(/\s/u)
return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1)
}
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
readonly contributions: Map<string, CommandContribution>
@@ -351,9 +374,33 @@ export class CommandService extends Service implements CommandServiceContract {
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result)
return { kind: 'success' }
}
/** Publish the local acknowledgment without letting an observer change command admission. */
private notifyExecuted(sessionId: SessionId, name: string, result: CommandResult): void {
const args = ['command/executed', sessionId, name, result]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(sessionId, name, result)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnExecutedListenerFailure(name, error)
})
}
} catch (error) {
this.warnExecutedListenerFailure(name, error)
}
}
}
/** Log one contained `command/executed` observer failure. */
private warnExecutedListenerFailure(name: string, error: unknown): void {
this.ctx.logger.warn('client command: a command/executed listener for "%s" failed', name)
this.ctx.logger.warn(error)
}
/**
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
* NOT surfaced here: the host executor durably logs the command lifecycle
@@ -9,6 +9,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -120,6 +121,10 @@ async function bench(opts: BenchOptions = {}) {
},
})
ctx.provide('remote.commands', commandsRemote)
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
ctx.on('command/executed', (sessionId, name, result) => {
executions.push({ sessionId, name, result })
})
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -145,7 +150,7 @@ async function bench(opts: BenchOptions = {}) {
const warm = async (session: ClientSessionContext) => {
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
}
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
}
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
@@ -367,7 +372,7 @@ describe('dispatch (menu column)', () => {
})
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
const { source, mint, warm, executeCalls } = await bench()
const { source, mint, warm, executeCalls, executions } = await bench()
const scope = mint('s1')
const consumes: ConsumeTokenRequest[] = []
scope.ctx.on('slash/input-consume-token', (r) => {
@@ -377,8 +382,14 @@ describe('dispatch (menu column)', () => {
await warm(proj('s1'))
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
await Promise.resolve()
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
await vi.waitFor(() => {
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
expect(executions).toEqual([{
sessionId: sid('s1'),
name: 'plan',
result: { kind: 'success' },
}])
})
})
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
@@ -496,7 +507,7 @@ describe('matchEnter (enter column)', () => {
describe('execute payload', () => {
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
const { source, warm, executeCalls } = await bench({
const { source, warm, executeCalls, executions } = await bench({
execute: () => Promise.resolve({ matched: true }),
})
await warm(proj('s1'))
@@ -507,6 +518,34 @@ describe('execute payload', () => {
// Pure admission: no outcome text ever rides the submit result — the
// durable command lifecycle events render the outcome in the flow.
expect(settled).toEqual({ kind: 'success' })
expect(executions).toEqual([{
sessionId: sid('s1'),
name: 'goal',
result: { kind: 'success' },
}])
})
it('contains local acknowledgment listeners without changing an admitted result', async () => {
const b = await bench({ execute: () => Promise.resolve({ matched: true }) })
await b.warm(proj('s1'))
const outcome = b.source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
const syncFailure = new Error('sync observer failed')
const asyncFailure = new Error('async observer failed')
const after = vi.fn()
const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined)
b.ctx.on('command/executed', () => { throw syncFailure })
const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void
b.ctx.on('command/executed', rejectingListener)
b.ctx.on('command/executed', after)
await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' })
expect(after).toHaveBeenCalledOnce()
await Promise.resolve()
await Promise.resolve()
expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal')
expect(warn).toHaveBeenCalledWith(syncFailure)
expect(warn).toHaveBeenCalledWith(asyncFailure)
})
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 6d0f3d0a088ad580d55d524699a7bf8d2806b724
README.zh.md: 70f828d29512eb5e25a873a8fce2b4ffad6380fc
README.md: 131f76fc8bc7b63449e022ad91fd8453c10d5f01
README.zh.md: 55f49f01c6cddd6b02382df742f923c8e159c1ac
+3 -1
View File
@@ -16,7 +16,7 @@ Chat business rows are independent registry contributions rather than a closed b
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
@@ -36,6 +36,8 @@ Keyboard message submission resolves delivery from the addressed session's runni
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
+3 -1
View File
@@ -14,7 +14,7 @@
Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
会话页头会在标题旁渲染 Session scope 的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。Session 上下文和谱系控件保留在 `actions` 中,可选的 Session 工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
@@ -36,6 +36,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts``attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({
@@ -313,9 +314,9 @@ export function apply(ctx: Context): void {
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
// Positive copy: the supported list is fixed in imageMediaType,
// and naming it beats echoing the rejected MIME type back.
return t('image.unsupportedType')
}
return error instanceof Error ? error.message : String(error)
}
@@ -10,6 +10,7 @@
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import type { ReactNode } 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'
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
const rendered: ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block === undefined) continue
switch (block.kind) {
case 'text':
rendered.push(
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>,
)
break
case 'reasoning':
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
break
case 'image': {
// Consecutive image blocks share one gallery so several images tile
// into rows instead of each opening a one-image group of its own.
// Keyed by the group's FIRST block index: a streaming append that
// extends the group then only grows `images` instead of remounting
// the gallery under a shifted key.
const start = i
const group = [block]
while (i + 1 < blocks.length) {
const next = blocks[i + 1]
if (next === undefined || next.kind !== 'image') break
group.push(next)
i += 1
}
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
break
}
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call':
break
default:
rendered.push(
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>,
)
}
}
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
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" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{rendered}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
</div>
@@ -1,7 +1,7 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -29,6 +29,11 @@ export interface MessageIconActionsProps {
branchUnavailable?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/**
* Slot-rendered actions owned by independent plugins, placed between the
* built-in copy and branch controls.
*/
extraActions?: ReactNode
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -39,7 +44,8 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
extraActions, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -109,6 +115,7 @@ export function MessageIconActions({
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{extraActions}
{onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
@@ -244,6 +244,12 @@
font: var(--dsw-font-markdown-code-block-small);
}
.maxTokensTitle {
margin-right: 6px;
color: var(--dsw-alias-state-warn-primary);
font-weight: 600;
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;
@@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: {
)
}
/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */
function TurnMaxTokensItem({ t }: {
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="warning" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.maxTokensTitle}>{t('message.maxTokens')}</span>
<span className={css.turnErrorMessage}>{t('message.maxTokens.hint')}</span>
</div>
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch
return <TurnErrorItem node={node.data} t={t} />
})
/** Max-tokens turn-end notice keyed Chat renderer. */
export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) {
return <TurnMaxTokensItem t={t} />
})
/** Explicit unknown-surface keyed Chat renderer. */
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
const data = node.data
@@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlotChain, t, useSession,
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
}: TurnTailNodeViewProps) {
const data = node.data
const hasLaterChatNode = useSession(snapshot =>
@@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
// Interruption-frozen partials carry no messageId, so they address no
// durable message and contribute no per-message actions.
const messageId = closing.finalNode.messageId
const assistantActions = messageId === undefined
? null
: renderSlot('conversation.chat.assistant-actions', { messageId })
return (
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
{tail}
@@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
t={t}
/>
</div>
@@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
@@ -35,11 +35,16 @@ export function registerChatNodeRenderers(ctx: Context): void {
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'turn-tail',
locale: NS,
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
children: {
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
},
}, TurnTailNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
@@ -11,6 +11,7 @@ import type {
TurnLocation, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type {
@@ -44,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* context that precedes interactive actions.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* Right-aligned Session utilities kept outside the title-adjacent action
* group, so an optional utility cannot reorder session context or lineage.
*/
'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
@@ -77,6 +83,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* only to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* Action strip attached to one finalized assistant message, rendered
* inside that message's IconActions row. The chat entry owns the render
* site and passes the addressed message identity; contributors add
* per-message actions without importing the conversation implementation.
* Entries render by ascending `order`.
*/
'conversation.chat.assistant-actions': {
kind: 'list'
scope: 'session'
owner: AssistantActionOwnerProps
}
/** Selected Tool call output inside the details panel. */
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
/**
@@ -253,6 +271,16 @@ export interface TurnTailOwnerProps {
openFile: (path: string) => void
}
/**
* Owner currency of the assistant-message action strip: the durable identity
* of the one finalized message the contributed actions address. Only finalized
* messages reach this slot, so the id is always present.
*/
export interface AssistantActionOwnerProps {
/** Stable identity carried from the `assistant/message` event. */
messageId: MessageId
}
/** Hook constrained to business data published on the current Chat Node's Turn. */
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
@@ -502,7 +530,7 @@ export type ConversationSessionSlotProps =
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>
@@ -152,6 +152,7 @@ function finalNode(
return {
kind: 'assistant',
seq: event.seq,
messageId: event.data.message.id,
time: event.time,
turn: state.turn,
step: state.step,
@@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
case 'command':
case 'compaction':
case 'turn-error':
case 'turn-max-tokens':
case 'unknown':
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
case 'assistant-step': {
@@ -7,11 +7,14 @@ import type {
/**
* Relative positions in one durable event's seq neighborhood: interrupted
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The
* max-tokens notice sits between a closing Assistant and the turn-tail so the
* tail stays the turn's last node and keeps its branch action enabled.
*/
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
interruptedAssistant: -0.9,
interruptedFollowup: -0.8,
maxTokensNotice: 0.05,
finalizedFollowup: 0.1,
} as const
@@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts'
import { registerRetryConversationNode } from './retry.ts'
import { registerToolConversationNode } from './tool.ts'
import { registerTurnErrorConversationNode } from './turn-error.ts'
import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
import { registerTurnTailConversationNode } from './turn-tail.ts'
/**
@@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void {
registerCompactionConversationNode(ctx)
registerRetryConversationNode(ctx)
registerTurnErrorConversationNode(ctx)
registerTurnMaxTokensConversationNode(ctx)
registerTurnTailConversationNode(ctx)
registerUnknownConversationFallback(ctx)
registerChatConversationView(ctx)
@@ -0,0 +1,82 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Turn ended by the per-request output-token cap. */
'turn-max-tokens': TurnMaxTokensNode
}
}
interface TurnMaxTokensState {
readonly turn: number
readonly seq: number
readonly time: number
}
function lastStep(context: ConversationNodeContext<TurnMaxTokensState>): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
return location.turn.steps.at(-1)?.step ?? 0
}
/**
* Anchor the notice between the closing Assistant and the turn-tail so the
* tail stays the turn's last Chat node and keeps its branch action enabled.
* Without a closing text Assistant there is no branch action to protect, and
* the turn/end seq keeps the notice at the truncation point.
*/
function noticeAnchor(context: ConversationNodeContext<TurnMaxTokensState>, seq: number): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return seq
const closing = location.turn.data.get('turn-tail')?.closing
return closing === null || closing === undefined
? seq
: closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice
}
function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined
return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time }
}
/** Notice Definition for a turn the provider ended at its output-token cap. */
export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensState> = {
kind: 'turn-max-tokens',
target: 'chat',
match: (event) => {
if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') {
return { id: String(event.data.turn), role: 'start' }
}
return null
},
start: (_context, match) => {
const state = stateFrom(match)
if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end')
return state
},
update: context => context.state,
buildViewNode: (context) => {
const state = context.state
if (state === undefined) return null
const node: TurnMaxTokensNode = {
kind: 'turn-max-tokens',
seq: state.seq,
time: state.time,
turn: state.turn,
step: lastStep(context),
}
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
},
}
/**
* Register the max-tokens turn-end notice contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnMaxTokensConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnMaxTokensDefinition)
}
@@ -3,11 +3,60 @@
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
/**
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
* @param bytes - the byte count.
* @returns the rounded megabyte text.
*/
export function imageSizeText(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
}
/**
* Product copy for a host attachment rejection (the `attachment-error`
* `details.reason`). User-solvable reasons name the limit and the way out;
* reasons the user cannot act on fold into one send-failed line carrying the
* reason code for a bug report.
* @param t - the conversation-namespace translate.
* @param reason - the wire `details.reason` code.
* @param limits - projected limits interpolated into count/size copy, when known.
* @returns the banner text.
*/
export function attachmentErrorText(
t: Translate<ConversationKey>,
reason: string,
limits?: ImageAttachmentLimits,
): string {
switch (reason) {
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
// Undecodable bytes or a declared type its bytes contradict: solvable by
// replacing or re-exporting the file, so it reads as a format problem.
case 'INVALID_IMAGE':
case 'IMAGE_TYPE_MISMATCH':
return t('image.unsupportedType')
case 'TOO_MANY_IMAGES':
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
break
case 'IMAGE_TOO_LARGE':
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
break
case 'IMAGES_TOO_LARGE':
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
break
default: break
}
return t('image.sendFailed', { reason })
}
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
}
}
/**
* Resolve the full-page drop overlay strings.
* @param t - the conversation-namespace translate.
* @param accepting - whether drops are currently accepted.
* @param limits - per-message limits for the desc line, when known.
* @returns the overlay title, with the limits desc while accepting.
*/
export function dropOverlayLabels(
t: Translate<ConversationKey>,
accepting: boolean,
limits?: { count: number; size: string },
): DropOverlayLabels {
if (!accepting) return { title: t('image.dropBlocked') }
return {
title: t('image.dropTitle'),
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
}
}
/**
* Resolve the composer draft-image rail strings.
* @param t - the conversation-namespace translate.
@@ -11,6 +11,7 @@ export type {} from './conversation-nodes/message.ts'
export type {} from './conversation-nodes/retry.ts'
export type {} from './conversation-nodes/tool.ts'
export type {} from './conversation-nodes/turn-error.ts'
export type {} from './conversation-nodes/turn-max-tokens.ts'
export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'
@@ -25,7 +25,9 @@ export const zh = {
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.dropTitle': '图片拖动到此处即可添加',
'image.dropDesc': '最多 {count} 张,每张 {size}',
'image.dropBlocked': '当前无法添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '查看原图',
'image.openOriginalLabel': '{label},点击查看原图',
@@ -39,8 +41,14 @@ export const zh = {
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '支持的图片格式:{type}',
'image.unknownType': '未知格式',
'image.unsupportedType': '支持 PNG、JPG、WebP、GIF 格式的图片',
'image.tooMany': '一条消息最多添加 {count} 张图片',
'image.fileTooLarge': '单张图片不能超过 {size}',
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
'image.subagentUnsupported': '子智能体会话暂不支持图片',
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
@@ -114,6 +122,8 @@ export const zh = {
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.maxTokens': '已达到输出 token 上限',
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
@@ -184,7 +194,9 @@ export const en = {
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.dropTitle': 'Drag images here to add them',
'image.dropDesc': 'Up to {count} images, {size} each',
'image.dropBlocked': 'Images cannot be added right now',
'image.pending': 'Pending images',
'image.openOriginal': 'View original',
'image.openOriginalLabel': '{label}, click to view original',
@@ -198,8 +210,14 @@ export const en = {
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
'image.tooMany': 'A message can include up to {count} images',
'image.fileTooLarge': 'Each image must be smaller than {size}',
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
@@ -273,6 +291,8 @@ export const en = {
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.maxTokens': 'Output token limit reached',
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',
@@ -53,9 +53,18 @@
.titleRow {
display: flex;
align-items: center;
gap: 10px;
gap: 0;
min-height: 32px;
}
.titleCluster {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.crumbs {
display: flex;
align-items: center;
@@ -111,6 +120,18 @@
gap: 8px;
}
.headerUtilities {
display: flex;
flex: none;
align-items: center;
gap: 8px;
margin-left: 20px;
}
.headerUtilities:empty {
display: none;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
position: relative;
@@ -69,27 +69,32 @@ export function ConversationSessionHeader({
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
<div className={css.titleCluster}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
<div className={css.headerUtilities}>
{renderSlot('conversation.session.header.utilities', {})}
</div>
</div>
{tabs.length > 1 && (
@@ -115,25 +115,6 @@
background: var(--dsw-alias-state-business-primary);
}
.dragActive {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
}
.dropHint {
position: absolute;
z-index: 2;
inset: 4px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
color: var(--dsw-alias-state-business-primary);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
.accessory {
display: flex;
align-items: center;
@@ -7,23 +7,28 @@
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import { AttachmentRail, DropOverlay, 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'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
// The `imageLimits` projection key merge (intake pre-check) arrives with the
// wire types: apiproxy's sessions contract declares it, and client-runtime's
// api-remotes import already places it in every client program.
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 {
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
} from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -80,14 +85,22 @@ export function InputBar({
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// The deployment's image-intake limits (absent while no attachment service
// is composed — the pre-check below then defers entirely to the host).
const imageLimits = useProjection('imageLimits')
// 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.
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason; other codes are
// developer-facing and keep the raw message plus code.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
if (promptError === null) return
showToast(promptError.error.code === 'attachment-error'
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
: `${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast, t, imageLimits])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
@@ -384,10 +397,7 @@ 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) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
if (files.length > 0) intakeImages(files)
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -406,38 +416,94 @@ export function InputBar({
keyboard.track(keyboard.snapshot.draft, caret)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDragActive(true)
}
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
// a projected limit is refused as a whole batch, announced immediately, and
// never enters the rail — no more submit-time failure rolling the rail
// back. The host enforces the same limits at submit for callers that bypass
// this composer.
const intakeImages = useCallback((files: readonly File[]): void => {
if (addImages === undefined || files.length === 0) return
const rejected = ((): string | null => {
if (imageLimits !== undefined) {
// Format precedes limits (DeepSeek Chat's filter order): a batch with
// a non-image must announce the format problem, not a count or size
// it could never pass anyway — addImages rejects it authoritatively.
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
return addImages(files)
}
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
}
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
}
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
+ files.reduce((sum, file) => sum + file.size, 0)
if (total > imageLimits.maxMessageImageBytes) {
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
}
}
return addImages(files)
})()
if (rejected !== null) showToast(rejected)
}, [addImages, attachments, imageLimits, showToast, t])
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
// on the document so a drop anywhere over the window adds images, not only
// over the composer card. Safe as document-level state: the composer-bar
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
// Text drags carry no 'Files' type and pass through untouched, keeping the
// native drop-text-into-textarea path. The overlay layer itself is
// pointer-inert, so it never disturbs the enter/leave count.
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
useEffect(() => {
const hasFiles = (event: globalThis.DragEvent): boolean =>
event.dataTransfer?.types.includes('Files') ?? false
const reset = (): void => {
dragDepthRef.current = 0
setDragActive(false)
}
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
dragDepthRef.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
if (!hasFiles(event) || event.dataTransfer === null) return
event.preventDefault()
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
// Leaving through the viewport edge does not balance the count on every
// engine; a page-root leave at the border means the drag left the window.
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
reset()
if (!canAcceptDrop) return
intakeImages([...(event.dataTransfer?.files ?? [])])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}, [canAcceptDrop, intakeImages])
const closePreview = useCallback(() => { setPreview(null) }, [])
@@ -573,6 +639,15 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
})}
/>
)}
{toast !== null && (
<Toast
key={toast.seq}
@@ -594,16 +669,11 @@ export function InputBar({
click's reopen (close-then-open flickers the chip's open echo). */}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{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>}
{railItems.length > 0 && (
@@ -9,7 +9,7 @@ import { useEffect } from 'react'
import type {
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
TurnMaxTokensNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import {
@@ -28,7 +28,7 @@ import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
@@ -108,6 +108,9 @@ const turnError = (seq: number, code?: string): TurnErrorNode => ({
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
...(code === undefined ? {} : { code }),
})
const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0,
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -217,6 +220,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return <RetryNodeView {...nodeProps<'model-retry'>()} />
case 'turn-error':
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
case 'turn-max-tokens':
return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
case 'turn-tail':
return (
<TurnTailNodeView
@@ -585,6 +590,16 @@ describe('ChatView', () => {
])
})
it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => {
const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] })
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
])
expect(view.queryByText('本轮运行失败')).toBeNull()
})
it('hands the trajectory callback to the Tool seat', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
@@ -14,6 +14,7 @@ import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
import type {
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
@@ -29,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
compactionDefinition,
retryDefinition,
turnErrorDefinition,
turnMaxTokensDefinition,
turnTailDefinition,
]
@@ -812,6 +814,75 @@ describe('built-in conversation node Definitions', () => {
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
})
it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
}, { surfaceOp: 'append' }),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
])
const notice = node(snapshot(value), 'turn-max-tokens')
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
// The tail stays the turn's last node so its branch action survives; the
// notice slots between the truncated closing Assistant and the tail.
const tail = node(snapshot(value), 'turn-tail')
expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
expect(notice?.anchorSeq).toBeGreaterThan(3)
const completed = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
const failed = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
}),
])
expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
expect(node(snapshot(failed), 'turn-error')).toBeDefined()
})
it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
const value = assembler([
at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
], true)
const notice = node(snapshot(value), 'turn-max-tokens')
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
})
it('pins the max-tokens Definition edges the engine cannot reach', () => {
// The engine only hands start the single matched turn/end and never emits
// update Matches for this kind; these direct calls pin the declared
// behavior of both required Definition members anyway.
const match = (seq: number, type: string, data: unknown) => ({
event: { seq, time: seq * 1_000, type, data },
view: undefined,
role: 'start',
location: undefined,
}) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
const context = (state: unknown, matches: unknown[] = []) => ({
key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
}) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
const reader = { previous: () => undefined }
expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
.toThrow('turn-max-tokens start requires a max-tokens turn/end')
const state = { turn: 1, seq: 5, time: 5_000 }
expect(turnMaxTokensDefinition.update(
context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
)).toBe(state)
expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
})
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
const value = assembler([
at(12, 'tool/code-dispatch-start', {
@@ -9,6 +9,7 @@ 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 { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -25,6 +26,40 @@ const attachment = {
name: 'history.png',
}
describe('attachment rejection copy', () => {
const limits = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
it('renders megabytes without a trailing fraction unless one exists', () => {
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
})
it('maps user-solvable reasons to limit-naming copy', () => {
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
})
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
})
})
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
@@ -60,6 +95,27 @@ describe('assistant images through the label bridge', () => {
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('merges consecutive image blocks into one tiled gallery, split by text', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'image', attachment },
{ kind: 'image', attachment },
{ kind: 'text', text: 'between' },
{ kind: 'image', attachment },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:grouped')}
/>,
)
await view.findAllByAltText('history.png')
const galleries = view.container.querySelectorAll('[data-align="start"]')
expect(galleries).toHaveLength(2)
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
@@ -56,6 +56,14 @@ interface BenchOptions {
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
/** The `imageLimits` projection value (absent = no attachment service). */
imageLimits?: {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
}
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
@@ -146,7 +154,9 @@ function bench(over?: BenchOptions) {
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
(selector ?? (v => v))(key === 'permissions'
? over?.permissions
: key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -212,18 +222,147 @@ describe('image draft rail', () => {
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
})
it('accepts file drops and prevents browser navigation', () => {
it('accepts a drop anywhere on the page under the full-page overlay', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
// The drag never touches the composer card: the listeners are page-wide.
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
expect(addImages).toHaveBeenCalledWith([image])
expect(view.queryByRole('status')).toBeNull()
})
it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
// A text drag carries no Files type: no overlay, native behavior stays.
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
expect(view.queryByRole('status')).toBeNull()
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragLeave(document.body, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
// An aborted drag (Escape) fires dragend without a balancing leave.
fireEvent.dragEnter(document.body, { dataTransfer })
fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragEnd(window, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
expect(addImages).not.toHaveBeenCalled()
})
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
const limits = {
maxImageBytes: 1024 * 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
const drop = (files: File[]) => {
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
}
// Count: three at once over a two-image limit → the whole batch refused.
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
expect(overCount.props.addImages).not.toHaveBeenCalled()
cleanup()
// Per-file bytes.
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(1024 * 1024 + 1, 'big.png')])
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
expect(overFile.props.addImages).not.toHaveBeenCalled()
cleanup()
// Aggregate bytes across the existing rail plus the new batch.
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
drop([png(1024 * 1024, 'more.png')])
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
expect(overTotal.props.addImages).not.toHaveBeenCalled()
cleanup()
// Within every limit: the batch passes through to addImages.
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
const fits = png(16, 'fits.png')
drop([fits])
expect(within.props.addImages).toHaveBeenCalledWith([fits])
expect(within.view.queryByRole('alert')).toBeNull()
})
it('announces the format problem before any limit when the batch holds a non-image', () => {
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
const { view } = bench({
addImages,
imageLimits: {
maxImageBytes: 8,
maxImagesPerMessage: 1,
maxMessageImageBytes: 8,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
},
})
// Oversized AND over-count AND wrong type: the format rejection wins.
const files = [
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
]
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
expect(addImages).toHaveBeenCalledWith(files)
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
})
it('shows the projected limits in the drop overlay desc line', () => {
const { view } = bench({
addImages: vi.fn(() => null),
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
},
})
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
})
it('announces server attachment rejections as product copy, other codes as developer text', () => {
const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({
op: 'send',
error: { code: 'attachment-error', message: 'raw wire text', details: { reason } },
})
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
cleanup()
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED')
cleanup()
const other = bench({
promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
})
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
})
it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages, inert: true })
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
fireEvent.dragOver(document.body, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(document.body, { dataTransfer })
expect(addImages).not.toHaveBeenCalled()
expect(view.queryByRole('status')).toBeNull()
})
it('sends an image-only draft and removes its thumbnail', () => {
@@ -250,7 +389,7 @@ describe('image draft rail', () => {
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 addImages = vi.fn(() => '支持 PNG、JPG、WebP、GIF 格式的图片')
const { view, textarea } = bench({ addImages })
const paste = () => {
fireEvent.paste(textarea, {
@@ -261,12 +400,12 @@ describe('image draft rail', () => {
})
}
paste()
expect(view.getByRole('alert').textContent).toContain('支持的图片格式:text/plain')
expect(view.getByRole('alert').textContent).toContain('支持 PNG、JPG、WebP、GIF 格式的图片')
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')
expect(view.getByRole('alert').textContent).toContain('支持 PNG、JPG、WebP、GIF 格式的图片')
} finally {
vi.useRealTimers()
}
@@ -337,6 +337,8 @@ describe('ConversationRoot resident composer', () => {
expect(host?.contains(header)).toBe(false)
expect(host?.contains(seat)).toBe(true)
expect(seat?.contains(textarea)).toBe(true)
expect(b.slotCalls).toContain('conversation.session.header.actions')
expect(b.slotCalls).toContain('conversation.session.header.utilities')
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
@@ -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-feedback/README.md
README.md: 0187fee61863292ee4e51975efe414a4e41ba066
README.zh.md: 9d225f4632e15a2b54437cbe30d376acb509a156
+25
View File
@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-client-ui-feedback
English | [中文](README.zh.md)
Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
One `FeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history.
Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` carries the `version` this controller last observed, and a `version-conflict` reply carries the authoritative item, so a lost race reconciles from the reply itself instead of refetching the Session. Mutations serialize per Session, so a queued operation always compares against the committed version. Re-clicking the recorded rating retracts the feedback; switching sides carries the existing note forward.
The `/client` exports are the plugin body (`apply`/`inject`), the `FeedbackActions` component, the `FeedbackController` class, and the injected face types.
## Model Experience
None, as feedback is a sidecar that never enters the append-only Session log, the model context, or telemetry; no rating or note is ever visible to the model.
#### KV Cache effect
None; no feedback mutation touches the history tail.
## Known Limitations and Deferred Work
- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The editor does not pre-check the limit, so an oversized note fails on save rather than while typing.
- **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the sidecar publishes no live frames.
- **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes now carry the same `messageId`.
+25
View File
@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-client-ui-feedback
[English](README.md) | 中文
单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。
变更通过 `ctx.remote.messageFeedback` 提交,按条目的 compare-and-set 由 Host 负责。每次 `put``delete` 都携带本 controller 最后观察到的 `version``version-conflict` 响应会带回权威条目,因此竞争失败时直接用该响应对账,无需重新拉取整个 Session。变更按 Session 串行,排队中的操作总是与已提交的版本比较。再次点击已记录的评分会撤回反馈;切换到另一侧会保留已有备注。
`/client` 导出插件本体(`apply`/`inject`)、`FeedbackActions` 组件、`FeedbackController` 类以及注入面类型。
## 模型体验
无。反馈是 sidecar,不进入 append-only 的 Session 日志、模型上下文或遥测;任何评分与备注对模型都不可见。
#### KV Cache 影响
无;任何反馈变更都不触碰历史尾部。
## 已知限制与暂缓事项
- **备注大小是 Host 策略** —— 部署方配置 `maxNoteBytes`Web bundle 中为 8192),超长备注由 Host 以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。
- **无跨标签页推送** —— 另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;该 sidecar 不发布实时帧。
- **仅限对话视图** —— trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点现在也带有相同的 `messageId`
+86
View File
@@ -0,0 +1,86 @@
{
"name": "@deepseek-ai/dsh-client-ui-feedback",
"description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-feedback"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "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:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,108 @@
/* Per-message feedback controls. The rating buttons mirror the shared message
IconActions chrome so the strip reads as one row; the note editor is an
inline expansion anchored to the same row. */
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.action:disabled {
cursor: default;
opacity: 0.4;
}
/* A recorded rating stays legible without hover, so the signal survives a
pointer leaving the row. */
.action[data-active] {
color: var(--dsw-alias-label-primary);
}
.noteOpen {
max-width: 220px;
overflow: hidden;
padding: 0 8px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 28px;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
.noteOpen:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.noteEditor {
display: inline-flex;
align-items: flex-start;
gap: 6px;
}
.noteInput {
width: 260px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-secondary);
border-radius: 8px;
background: var(--dsw-alias-bg-primary);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
resize: vertical;
}
.noteSave,
.noteCancel {
height: 28px;
padding: 0 10px;
border: none;
border-radius: 14px;
font-size: 13px;
cursor: pointer;
}
.noteSave {
background: var(--dsw-alias-interactive-bg-primary);
color: var(--dsw-alias-label-inverse);
}
.noteSave:disabled {
cursor: default;
opacity: 0.4;
}
.noteCancel {
background: transparent;
color: var(--dsw-alias-label-tertiary);
}
.noteCancel:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.failure {
padding-left: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 28px;
}
@@ -0,0 +1,153 @@
/**
* Per-message feedback controls: a Like/Dislike pair plus an optional note.
* Rendered inside the assistant message's IconActions row, so the buttons
* reuse that row's chrome and sit between copy and branch.
* @module @deepseek-ai/dsh-client-ui-feedback/client/FeedbackActions
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import {
IconDislikeOutline16, IconLikeOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
import type { FeedbackActionProps } from './slots.ts'
import css from './FeedbackActions.module.css'
/**
* One message's feedback controls.
* @param props - the owner's message identity, the injected verbs, and the
* shared feedback hook.
* @returns the rating buttons, plus the note editor while it is open.
*/
export function FeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: FeedbackActionProps) {
const item = useFeedback(view => view.items.get(messageId))
const loadFailed = useFeedback(view => view.status === 'error')
const rating = item?.rating
const [noteOpen, setNoteOpen] = useState(false)
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
// The controls mount for every settled message in the transcript, so the
// Session's feedback is read once on first hover/focus rather than on mount.
const seeded = useRef(false)
const seed = useCallback(() => {
if (seeded.current) return
seeded.current = true
void ensure()
}, [ensure])
const alive = useRef(true)
useEffect(() => () => { alive.current = false }, [])
const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => {
if (!alive.current) return
setPending(false)
if (result.ok) {
setFailure(null)
return
}
setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic'))
}, [t])
const onRate = useCallback((next: MessageFeedbackRating) => {
setPending(true)
setFailure(null)
// The controller decides retract-vs-replace from the committed item, so a
// click that lands before the first list read still toggles the stored
// value instead of this render's empty view.
setNoteOpen(false)
void toggle(messageId, next).then(settle)
}, [messageId, settle, toggle])
// The rating is a parameter because only the note editor's render site can
// prove one is recorded; that removes an unreachable undefined guard here.
const onSaveNote = useCallback((current: MessageFeedbackRating) => {
const trimmed = draft.trim()
setPending(true)
setFailure(null)
// An emptied editor removes the note explicitly; `rate` alone preserves a
// stored note, so it cannot express deletion.
const settled = trimmed.length === 0
? clearNote(messageId)
: rate(messageId, current, trimmed)
void settled.then((result) => {
settle(result)
if (result.ok && alive.current) setNoteOpen(false)
})
}, [clearNote, draft, messageId, rate, settle])
const openNote = useCallback(() => {
setDraft(item?.note ?? '')
setNoteOpen(true)
}, [item?.note])
const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')
return (
<>
<Tooltip label={likeLabel} side="bottom">
<button
type="button"
className={css.action}
aria-label={likeLabel}
aria-pressed={rating === 'positive'}
data-active={rating === 'positive' || undefined}
disabled={pending}
onFocus={seed}
onPointerEnter={seed}
onClick={() => { onRate('positive') }}
>
<IconLikeOutline16 />
</button>
</Tooltip>
<Tooltip label={dislikeLabel} side="bottom">
<button
type="button"
className={css.action}
aria-label={dislikeLabel}
aria-pressed={rating === 'negative'}
data-active={rating === 'negative' || undefined}
disabled={pending}
onFocus={seed}
onPointerEnter={seed}
onClick={() => { onRate('negative') }}
>
<IconDislikeOutline16 />
</button>
</Tooltip>
{rating !== undefined && !noteOpen && (
<button type="button" className={css.noteOpen} onClick={openNote}>
{item?.note === undefined ? t('note.open') : item.note}
</button>
)}
{rating !== undefined && noteOpen && (
<span className={css.noteEditor}>
<textarea
className={css.noteInput}
aria-label={t('note.aria')}
placeholder={t('note.placeholder')}
value={draft}
rows={2}
onChange={(event) => { setDraft(event.target.value) }}
/>
<button
type="button"
className={css.noteSave}
disabled={pending}
onClick={() => { onSaveNote(rating) }}
>
{t('note.save')}
</button>
<button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
{t('note.cancel')}
</button>
</span>
)}
{failure === null && loadFailed && (
<span className={css.failure} role="status">{t('error.load')}</span>
)}
{failure !== null && <span className={css.failure} role="status">{failure}</span>}
</>
)
}
@@ -0,0 +1,377 @@
/**
* Browser-local object layer over one Session's durable message-feedback
* sidecar. The Host owns per-item compare-and-set: every mutation carries the
* version this controller last observed, and a `version-conflict` reply carries
* the authoritative item, so a lost race reconciles from the reply itself
* instead of refetching the whole Session.
* @module @deepseek-ai/dsh-client-ui-feedback/client/controller
*/
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
MessageFeedbackDeleteResult,
MessageFeedbackItem,
MessageFeedbackListResult,
MessageFeedbackPutResult,
MessageFeedbackRating,
} from '@deepseek-ai/dsh-message-feedback/types'
/**
* The three Remote calls this controller needs. The generated face wraps every
* business result in {@link RemoteResult}: a carrier failure arrives as the
* `ok: false` branch rather than a rejection, so this controller reads one
* envelope and never wraps a call to recover a transport error.
*/
export interface MessageFeedbackRemote {
list: (request: { sessionId: SessionId }) => Promise<RemoteResult<MessageFeedbackListResult>>
put: (request: {
sessionId: SessionId
messageId: MessageId
rating: MessageFeedbackRating
note?: string
ifVersion: MessageFeedbackItem['version'] | null
}) => Promise<RemoteResult<MessageFeedbackPutResult>>
delete: (request: {
sessionId: SessionId
messageId: MessageId
ifVersion: MessageFeedbackItem['version']
}) => Promise<RemoteResult<MessageFeedbackDeleteResult>>
}
/** Load state of the one list read that seeds every per-message control. */
export type FeedbackStatus = 'cold' | 'loading' | 'ready' | 'error'
/** Immutable view published to every per-message control in one Session. */
export interface FeedbackView {
status: FeedbackStatus
/** Current item per message, keyed by the addressed message id. */
items: ReadonlyMap<MessageId, MessageFeedbackItem>
/** Reason the last load failed, cleared by the next successful load. */
error: string | null
}
/** Settled action shape rendered by the message-level controls. */
export type FeedbackActionResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } }
// `Object.freeze` does not protect a Map: `set`/`delete` write internal slots,
// not properties. Immutability here is by discipline instead — the view type is
// ReadonlyMap and every publish hands over a freshly built Map that this class
// keeps no mutable reference to.
const EMPTY_ITEMS: ReadonlyMap<MessageId, MessageFeedbackItem> = new Map()
const INITIAL_VIEW: FeedbackView = Object.freeze({
status: 'cold',
items: EMPTY_ITEMS,
error: null,
})
const OK: FeedbackActionResult = Object.freeze({ ok: true })
const DISPOSED: FeedbackActionResult = Object.freeze({
ok: false,
error: Object.freeze({ code: 'disposed', message: 'feedback controller is disposed' }),
})
/** Human-readable text for one business failure code. */
function describe(code: string): string {
switch (code) {
case 'session-not-found': return 'this session is no longer persisted'
case 'target-not-found': return 'this message is not a persisted assistant message'
case 'version-conflict': return 'feedback changed elsewhere'
case 'note-blank': return 'a note must contain a non-whitespace character'
case 'note-too-large': return 'the note is too long'
default: return code
}
}
/** Build the rejected branch for one business failure code. */
function fail(code: string): FeedbackActionResult {
return { ok: false, error: { code, message: describe(code) } }
}
/** Carrier failure rendered with the Host-supplied code and message. */
function carrierFailure(error: { code: string; message: string }): FeedbackActionResult {
return { ok: false, error: { code: error.code, message: error.message } }
}
/**
* Per-session feedback object layer. One instance backs every per-message
* control in that Session, so a single list read seeds them all.
*/
export class FeedbackController implements HostObservable<FeedbackView> {
private view = INITIAL_VIEW
private readonly listeners = new Set<() => void>()
private loadPromise: Promise<FeedbackActionResult> | null = null
private operationTail: Promise<void> = Promise.resolve()
private disposed = false
/**
* @param remote - the messageFeedback Remote namespace.
* @param sessionId - Session owning every addressed assistant message.
*/
constructor(
private readonly remote: MessageFeedbackRemote,
private readonly sessionId: SessionId,
) {}
/** Return the cached immutable view. */
getSnapshot = (): FeedbackView => this.view
/** Subscribe to view replacement. */
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Load once; a failed load stays retryable.
* @returns the settled load result, shared by concurrent callers.
*/
ensure(): Promise<FeedbackActionResult> {
if (this.view.status === 'ready') return Promise.resolve(OK)
return this.refresh()
}
/**
* Re-read the authoritative list, collapsing concurrent callers onto one
* in-flight read.
*
* This is the unserialized read used to seed a cold controller, where no
* mutation can be in flight yet. A reconnect must use {@link resync} instead:
* an unserialized list response can otherwise arrive after a newer mutation's
* reply and overwrite the version that mutation just committed.
* @returns the settled reload result.
*/
refresh(): Promise<FeedbackActionResult> {
if (this.loadPromise !== null) return this.loadPromise
this.publish({ status: 'loading', items: this.view.items, error: null })
const pending = this.load()
this.loadPromise = pending
return pending.finally(() => { this.loadPromise = null })
}
/**
* Re-read the list behind this Session's queued mutations, so a reconnect
* cannot resurrect a version an in-flight mutation already replaced.
* @returns the settled reload result.
*/
resync(): Promise<FeedbackActionResult> {
// seed: false — this operation *is* the read, so pre-seeding would either
// short-circuit it (status already ready) or run it twice.
return this.mutate(() => this.refresh(), { seed: false })
}
/**
* Create or replace feedback for one message, comparing against the version
* this controller last observed.
*
* The note is resolved here rather than by the caller: `mutate` awaits the
* one list read first, so this body always sees the committed item, while a
* control that rendered before that read completed would still be holding
* `undefined`. Omitting `note` therefore keeps whatever is stored; only
* {@link clearNote} removes one.
* @param messageId - target assistant message.
* @param rating - desired judgment.
* @param note - replacement explanation; omitted keeps the stored note.
* @returns the settled mutation result.
*/
rate(
messageId: MessageId,
rating: MessageFeedbackRating,
note?: string,
): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
return await this.putCommitted(messageId, rating, note ?? observed?.note, observed)
})
}
/**
* Replace one message's rating with the opposite judgment, or retract it when
* the committed rating already matches. The decision reads the committed item
* inside the serialized mutation, so a click that lands before the first list
* read still toggles against the stored value rather than the empty view a
* cold control rendered.
* @param messageId - target assistant message.
* @param rating - the judgment the human asked for.
* @returns the settled mutation result.
*/
toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed?.rating === rating) return await this.deleteCommitted(messageId, observed)
return await this.putCommitted(messageId, rating, observed?.note, observed)
})
}
/**
* Drop the note while keeping the rating. Absent feedback needs no call.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clearNote(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined || observed.note === undefined) return OK
return await this.putCommitted(messageId, observed.rating, undefined, observed)
})
}
/**
* Remove feedback for one message. A message with no known item is already
* in the requested state, so no call is made.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clear(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined) return OK
return await this.deleteCommitted(messageId, observed)
})
}
/** Commit one put against the observed version and reconcile a conflict. */
private async putCommitted(
messageId: MessageId,
rating: MessageFeedbackRating,
note: string | undefined,
observed: MessageFeedbackItem | undefined,
): Promise<FeedbackActionResult> {
const carried = await this.remote.put({
sessionId: this.sessionId,
messageId,
rating,
...(note === undefined ? {} : { note }),
ifVersion: observed?.version ?? null,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, result.value)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Commit one delete against the observed version and reconcile a conflict. */
private async deleteCommitted(
messageId: MessageId,
observed: MessageFeedbackItem,
): Promise<FeedbackActionResult> {
const carried = await this.remote.delete({
sessionId: this.sessionId,
messageId,
ifVersion: observed.version,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, null)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Drop subscribers and refuse further work when the owning fiber unloads. */
dispose(): void {
this.disposed = true
this.listeners.clear()
}
/** Fetch the whole sidecar and publish it as the seeded view. */
private async load(): Promise<FeedbackActionResult> {
try {
const carried = await this.remote.list({ sessionId: this.sessionId })
if (this.disposed) return OK
if (!carried.ok) {
this.publish({ status: 'error', items: this.view.items, error: carried.error.message })
return carrierFailure(carried.error)
}
const result = carried.value
if (!result.ok) {
this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) })
return fail(result.error.code)
}
const items = new Map<MessageId, MessageFeedbackItem>()
for (const item of result.value.items) items.set(item.messageId, item)
this.publish({ status: 'ready', items, error: null })
return OK
} catch (error) {
if (this.disposed) return OK
const message = error instanceof Error ? error.message : 'message feedback list failed'
this.publish({ status: 'error', items: this.view.items, error: message })
return { ok: false, error: { code: 'transport', message } }
}
}
/**
* Serialize one mutation behind this Session's prior mutation so queued
* operations always compare against the committed version, and translate a
* transport throw into the same settled shape the controls already render.
*/
private mutate(
operation: () => Promise<FeedbackActionResult>,
options: { readonly seed?: boolean } = {},
): Promise<FeedbackActionResult> {
const guarded = async (): Promise<FeedbackActionResult> => {
if (this.disposed) return DISPOSED
if (options.seed !== false) {
const loaded = await this.ensure()
if (!loaded.ok) return loaded
// Disposal can land while the seeding read is in flight; without this
// second check the fiber would still reach the wire after unloading.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
if (this.disposed) return DISPOSED
}
try {
return await operation()
} catch (error) {
return {
ok: false,
error: {
code: 'transport',
message: error instanceof Error ? error.message : 'message feedback mutation failed',
},
}
}
}
const result = this.operationTail.then(guarded, guarded)
// `guarded` settles every carrier and business failure as a
// FeedbackActionResult and never rethrows, so this tail cannot reject and
// needs no rejection handler.
this.operationTail = result.then(() => undefined)
return result
}
/**
* Replace one message's entry, keeping every other entry's identity. Only a
* `mutate` operation reaches this, and `mutate` refuses admission once the
* controller is disposed, so no disposal guard belongs here; `publish` is
* the single place that stops notifying after listeners are dropped.
*/
private commit(messageId: MessageId, item: MessageFeedbackItem | null): void {
const items = new Map(this.view.items)
if (item === null) items.delete(messageId)
else items.set(messageId, item)
this.publish({ status: 'ready', items, error: null })
}
/** Replace the view and contain subscriber failures at the observable boundary. */
private publish(view: FeedbackView): void {
this.view = Object.freeze(view)
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.error('[ui-feedback] subscriber threw:', error)
}
}
}
}
@@ -0,0 +1,84 @@
/**
* Message feedback plugin, browser half: the Like/Dislike entry in the
* conversation.chat.assistant-actions strip. One FeedbackController per
* Session backs every message control in that Session, so a single list read
* seeds the whole transcript. Mutations go through the generated
* messageFeedback Remote; the Host owns per-item compare-and-set.
* @module @deepseek-ai/dsh-client-ui-feedback/client
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { FeedbackController } from './controller.ts'
import { FeedbackActions } from './FeedbackActions.tsx'
import type { FeedbackInjected } from './slots.ts'
import { en, zh } from './locales.ts'
export type {
FeedbackActionResult, FeedbackStatus, FeedbackView, MessageFeedbackRemote,
} from './controller.ts'
export type { FeedbackActionProps, FeedbackInjected } from './slots.ts'
export type { FeedbackKey } from './locales.ts'
/** Dictionary namespace owned by this plugin. */
const NS = 'feedback'
/** Required services: the slot registry, the Remote namespace, and the copy. */
export const inject = ['slots', 'remote', 'remote.messageFeedback', 'locale']
/**
* Client plugin body: the per-message feedback entry and its per-session
* object layer.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-feedback: dictionaries')
const controllers = new Map<SessionId, FeedbackController>()
const controllerFor = (sessionId: SessionId): FeedbackController => {
let controller = controllers.get(sessionId)
if (controller === undefined) {
controller = new FeedbackController(ctx.remote.messageFeedback, sessionId)
controllers.set(sessionId, controller)
}
return controller
}
// A reconnect can only invalidate what was already read; a cold Session
// stays cold until something asks for it.
ctx.on('connection/reset', () => {
for (const controller of controllers.values()) {
if (controller.getSnapshot().status !== 'cold') void controller.resync()
}
})
ctx.slots.inject('conversation.chat.assistant-actions', () => {
const dispose = ctx.slots.register({
name: 'conversation.chat.assistant-actions',
id: 'feedback',
order: 10,
locale: NS,
inject: (sessionId): FeedbackInjected => {
const controller = controllerFor(sessionId)
return {
hooks: { feedback: controller },
ensure: () => controller.ensure(),
rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
toggle: (messageId, rating) => controller.toggle(messageId, rating),
clearNote: messageId => controller.clearNote(messageId),
clear: messageId => controller.clear(messageId),
}
},
}, FeedbackActions)
return () => {
dispose()
for (const controller of controllers.values()) controller.dispose()
controllers.clear()
}
})
}
@@ -0,0 +1,43 @@
/** `feedback` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'action.like': '好的回答',
'action.likeActive': '取消标记',
'action.dislike': '有问题的回答',
'action.dislikeActive': '取消标记',
'note.open': '补充说明',
'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
'note.save': '保存',
'note.cancel': '取消',
'note.aria': '反馈说明',
'error.conflict': '这条反馈已在别处改动,已显示最新状态',
'error.load': '反馈状态加载失败',
'error.generic': '反馈保存失败',
} satisfies Record<string, string>
/** The feedback namespace key union. */
export type FeedbackKey = keyof typeof zh
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The per-message feedback controls' copy. */
feedback: FeedbackKey
}
}
/** English dictionary, checked complete against the zh key set. */
export const en = {
'action.like': 'Good response',
'action.likeActive': 'Remove rating',
'action.dislike': 'Bad response',
'action.dislikeActive': 'Remove rating',
'note.open': 'Add a note',
'note.placeholder': 'What was good, or what went wrong? (optional)',
'note.save': 'Save',
'note.cancel': 'Cancel',
'note.aria': 'Feedback note',
'error.conflict': 'This feedback changed elsewhere; the latest state is shown',
'error.load': 'Could not load feedback',
'error.generic': 'Could not save feedback',
} satisfies Record<FeedbackKey, string>
@@ -0,0 +1,64 @@
/**
* The feedback entry's injected face. The target
* 'conversation.chat.assistant-actions' slot is declared and typed by
* ui-conversation; this package only contributes the entry, so no SlotMap
* merge lives here. Live per-message state arrives through the `feedback`
* hook (the framework standard kit binds it into `useFeedback`); inject
* carries the two mutation verbs plus the lazy loader.
* @module @deepseek-ai/dsh-client-ui-feedback/client/slots
*/
import type {
HostObservable, InjectFace, PropsLocale, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
// Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat).
import type {} from './locales.ts'
import type { FeedbackActionResult, FeedbackView } from './controller.ts'
/** Injected business face of one assistant-message feedback entry. */
export interface FeedbackInjected {
hooks: {
/** The owning Session's feedback view, shared by every message control. */
feedback: HostObservable<FeedbackView>
}
/** Load the Session's feedback once, on first interaction. */
ensure: () => Promise<FeedbackActionResult>
/**
* Create or replace this Session's feedback for one message.
* @param messageId - target assistant message.
* @param rating - desired judgment.
* @param note - optional explanation.
*/
rate: (
messageId: MessageId,
rating: MessageFeedbackRating,
note?: string,
) => Promise<FeedbackActionResult>
/**
* Apply the requested judgment, retracting instead when the committed rating
* already matches. The controller decides from the committed item, so a click
* before the first list read still toggles the stored value.
* @param messageId - target assistant message.
* @param rating - the judgment the human asked for.
*/
toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<FeedbackActionResult>
/**
* Drop the note while keeping the rating.
* @param messageId - target assistant message.
*/
clearNote: (messageId: MessageId) => Promise<FeedbackActionResult>
/**
* Remove this Session's feedback for one message.
* @param messageId - target assistant message.
*/
clear: (messageId: MessageId) => Promise<FeedbackActionResult>
}
/** Full props of one assistant-message feedback entry. */
export type FeedbackActionProps =
PropsRuntime<'conversation.chat.assistant-actions'>
& InjectFace<FeedbackInjected>
& PropsLocale<'feedback'>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+9
View File
@@ -0,0 +1,9 @@
/**
* Message feedback surface plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}
@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-feedback`.
* @module @deepseek-ai/dsh-client-ui-feedback/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-feedback'
/** Cordis companion plugin name. */
export const name = 'client-ui-feedback-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the plugin owns one slot registration and one
* per-session controller map, both released by the same effect disposer. The
* lifecycle spec proves the registration is withdrawn and every controller is
* dropped when the owning fiber is disposed, so no second authority exists to
* check at runtime.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,214 @@
// @vitest-environment jsdom
/**
* ui-feedback browser half on a real cordis Context with fake slots/remote
* faces: the plugin registers the feedback entry at
* conversation.chat.assistant-actions, one controller per Session backs every
* message in that Session, a reconnect refreshes only Sessions that were
* already read, and registration plus controller disposal ride the plugin
* fiber (HMR safety). The node half and the invariant companion are exercised
* over the same Context.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup } from '@testing-library/react'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
import type { FeedbackInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
afterEach(cleanup)
const sid = (k: string): SessionId => k as SessionId
const MSG = 'm-1' as MessageId
const seeded: MessageFeedbackItem = {
messageId: MSG,
rating: 'positive',
version: 'v1' as MessageFeedbackVersion,
createdAt: 1,
updatedAt: 1,
}
/** Boot the plugin over fake faces; the Remote namespace records every call. */
async function bench() {
const ctx = new Context()
const calls: { method: string; request: unknown }[] = []
// The generated face wraps every business result in the carrier envelope.
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
const messageFeedback = {
list: (request: unknown) => {
calls.push({ method: 'list', request })
return carried({ ok: true as const, value: { items: [seeded] } })
},
put: (request: unknown) => {
calls.push({ method: 'put', request })
return carried({ ok: true as const, value: seeded })
},
delete: (request: unknown) => {
calls.push({ method: 'delete', request })
return carried({ ok: true as const, value: { absent: true as const } })
},
}
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
ctx.provide('remote.messageFeedback', messageFeedback)
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root',
children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
return {
ctx,
fiber,
calls,
entry: () => {
const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
if (entry === undefined) return undefined
return {
...entry.options,
locale: entry.locale,
inject: entry.inject as unknown as ((sessionId: SessionId) => FeedbackInjected) | undefined,
}
},
}
}
describe('ui-feedback browser plugin', () => {
it('registers the feedback entry with the documented id, order, and locale', async () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
expect(b.entry()?.inject).toBeTypeOf('function')
})
it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
expect(face.ensure).toBeTypeOf('function')
expect(face.rate).toBeTypeOf('function')
expect(face.clear).toBeTypeOf('function')
})
it('shares one controller across every message in the same Session', async () => {
const b = await bench()
await b.fiber.await()
const first = b.entry()!.inject!(sid('s1'))
const second = b.entry()!.inject!(sid('s1'))
expect(first.hooks.feedback).toBe(second.hooks.feedback)
await first.ensure()
await second.ensure()
expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('keeps separate Sessions on separate controllers', async () => {
const b = await bench()
await b.fiber.await()
const one = b.entry()!.inject!(sid('s1'))
const two = b.entry()!.inject!(sid('s2'))
expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
await one.ensure()
await two.ensure()
expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
{ sessionId: 's1' },
{ sessionId: 's2' },
])
})
it('routes rate and clear to the Remote with the addressed message', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
expect(await face.clear(MSG)).toEqual({ ok: true })
expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
})
expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
sessionId: 's1', messageId: MSG,
})
})
it('routes toggle and clearNote to the controller', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
expect(await face.clearNote(MSG)).toEqual({ ok: true })
// The seeded item is positive with no note, so a negative toggle replaces it
// through put, and clearNote has nothing to drop and touches no wire.
const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
expect(puts).toHaveLength(1)
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
})
it('refreshes only Sessions already read when the connection resets', async () => {
const b = await bench()
await b.fiber.await()
const warm = b.entry()!.inject!(sid('warm'))
await warm.ensure()
b.entry()!.inject!(sid('cold'))
const before = b.calls.filter(call => call.method === 'list').length
b.ctx.emit('connection/reset')
await Promise.resolve()
const reads = b.calls.filter(call => call.method === 'list')
expect(reads).toHaveLength(before + 1)
expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
})
it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
await face.ensure()
await b.fiber.dispose()
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
// A disposed controller refuses further mutations, so no request outlives the fiber.
const before = b.calls.length
expect(await face.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(b.calls).toHaveLength(before)
})
it('re-registers cleanly when the plugin is reloaded', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
const reloaded = b.ctx.plugin({ inject: [...inject], apply })
await reloaded.await()
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
expect(b.entry()).toMatchObject({ id: 'feedback' })
})
it('the node half applies without host-side behavior', () => {
// The invariant companion is mounted by the vitest-wide invariant host on
// every Context this suite creates; its registration is covered there.
expect(() => { nodeApply() }).not.toThrow()
})
})
@@ -0,0 +1,674 @@
/**
* FeedbackController: the browser-local object layer over one Session's
* message-feedback sidecar. These specs pin the per-item compare-and-set
* contract — every mutation sends the version last observed, a conflict
* reconciles from the authoritative item carried by the reply, mutations
* serialize per Session, and a disposed controller stops publishing.
*/
import { describe, expect, it, vi } from 'vitest'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
MessageFeedbackItem, MessageFeedbackVersion,
} from '@deepseek-ai/dsh-message-feedback/types'
import { FeedbackController, type MessageFeedbackRemote } from '../src/client/controller.ts'
const SESSION = 's-1' as SessionId
const MSG = 'm-1' as MessageId
const OTHER = 'm-2' as MessageId
const version = (v: string): MessageFeedbackVersion => v as MessageFeedbackVersion
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
return {
messageId: MSG,
rating: 'positive',
version: version('v1'),
createdAt: 1,
updatedAt: 1,
...overrides,
}
}
/** A recording fake Remote whose per-method answers are scripted per call. */
type Script = {
list?: (request: unknown) => Promise<unknown>
put?: (request: unknown) => Promise<unknown>
delete?: (request: unknown) => Promise<unknown>
}
/**
* A recording fake Remote. Scripts return the *business* result; this wraps it
* in the carrier envelope the generated face uses, so specs stay readable. A
* script may also return an already-enveloped `{ok:false,error:{code,message,
* details}}` to exercise a carrier failure.
*/
function fakeRemote(script: Script = {}) {
const calls: { method: string; request: unknown }[] = []
const isCarrier = (v: unknown): boolean =>
typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
&& 'error' in v && 'details' in ((v as { error: object }).error ?? {})
const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
(request: never): Promise<never> => {
calls.push({ method, request })
const business = real === undefined ? Promise.resolve(fallback) : real(request)
return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
}
const remote = {
list: record('list', script.list, { ok: true, value: { items: [] } }),
put: record('put', script.put, { ok: true, value: item() }),
delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
} as unknown as MessageFeedbackRemote
return { remote, calls }
}
describe('FeedbackController', () => {
it('seeds the view from one list read and keys items by message id', async () => {
const seeded = item({ note: 'good' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(controller.getSnapshot().status).toBe('cold')
expect(await controller.ensure()).toEqual({ ok: true })
const view = controller.getSnapshot()
expect(view.status).toBe('ready')
expect(view.items.get(MSG)).toEqual(seeded)
expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
})
it('collapses concurrent loads onto one in-flight read', async () => {
const { remote, calls } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
const first = item({ version: version('v1') })
const second = item({ version: version('v2'), rating: 'negative' })
const { remote, calls } = fakeRemote({
put: request => Promise.resolve({
ok: true,
value: (request as { rating: string }).rating === 'positive' ? first : second,
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
const puts = calls.filter(call => call.method === 'put').map(call => call.request)
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
})
it('forwards an optional note and omits the field when absent', async () => {
const { remote, calls } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
await controller.rate(MSG, 'positive', 'helpful')
await controller.rate(OTHER, 'negative')
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
expect(puts[0]?.note).toBe('helpful')
expect(puts[1]).not.toHaveProperty('note')
})
it('reconciles a version conflict from the authoritative item without refetching', async () => {
const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
const { remote, calls } = fakeRemote({
put: () => Promise.resolve({
ok: false,
error: { code: 'version-conflict', current: authoritative },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('drops the local item when a conflict reports the feedback is gone', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: () => Promise.resolve({
ok: false,
error: { code: 'version-conflict', current: null },
}),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('deletes with the observed version and removes the item on success', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toEqual({ ok: true })
expect(calls.filter(call => call.method === 'delete')[0]?.request)
.toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('treats clearing an unrated message as already satisfied without a call', async () => {
const { remote, calls } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clear(MSG)).toEqual({ ok: true })
expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
})
it('serializes mutations so each one compares against the committed version', async () => {
let inFlight = 0
let overlapped = false
const versions = [version('v1'), version('v2')]
let index = 0
const { remote, calls } = fakeRemote({
put: async () => {
inFlight += 1
if (inFlight > 1) overlapped = true
await Promise.resolve()
inFlight -= 1
const next = versions[index] ?? version('vN')
index += 1
return { ok: true, value: item({ version: next }) }
},
})
const controller = new FeedbackController(remote, SESSION)
await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
expect(overlapped).toBe(false)
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
expect(puts[0]?.ifVersion).toBeNull()
expect(puts[1]?.ifVersion).toBe(version('v1'))
})
it('publishes an error status when the list read is rejected by the Host', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
expect(controller.getSnapshot()).toMatchObject({
status: 'error',
error: 'this session is no longer persisted',
})
})
it('settles a transport throw as a result instead of rejecting', async () => {
const { remote } = fakeRemote({ list: () => Promise.reject(new Error('socket closed')) })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'transport', message: 'socket closed' },
})
expect(controller.getSnapshot().status).toBe('error')
})
it('settles a mutation transport throw without corrupting the view', async () => {
const { remote } = fakeRemote({ put: () => Promise.reject(new Error('socket closed')) })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'transport', message: 'socket closed' },
})
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('notifies subscribers on publication and stops after unsubscribe', async () => {
const { remote } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
const listener = vi.fn()
const unsubscribe = controller.subscribe(listener)
await controller.ensure()
const seen = listener.mock.calls.length
expect(seen).toBeGreaterThan(0)
unsubscribe()
await controller.rate(MSG, 'positive')
expect(listener).toHaveBeenCalledTimes(seen)
})
it('contains a throwing subscriber at the observable boundary', async () => {
const { remote } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
controller.subscribe(() => { throw new Error('subscriber exploded') })
const healthy = vi.fn()
controller.subscribe(healthy)
await controller.ensure()
expect(healthy).toHaveBeenCalled()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('refuses mutations and stops publishing once disposed', async () => {
const { remote, calls } = fakeRemote()
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const listener = vi.fn()
controller.subscribe(listener)
controller.dispose()
const before = calls.length
expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(calls).toHaveLength(before)
expect(listener).not.toHaveBeenCalled()
})
it('renders a human explanation for every business failure code', async () => {
const codes = [
['session-not-found', 'this session is no longer persisted'],
['target-not-found', 'this message is not a persisted assistant message'],
['note-blank', 'a note must contain a non-whitespace character'],
['note-too-large', 'the note is too long'],
] as const
for (const [code, message] of codes) {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
expect(controller.getSnapshot().error).toBe(message)
}
})
it('falls back to the raw code for an unrecognized failure', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
expect(controller.getSnapshot().error).toBe('brand-new-code')
})
it('publishes nothing when the list settles after disposal', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: async () => {
await gate
return { ok: true, value: { items: [item()] } }
},
})
const controller = new FeedbackController(remote, SESSION)
const pending = controller.ensure()
const listener = vi.fn()
controller.subscribe(listener)
controller.dispose()
release()
expect(await pending).toEqual({ ok: true })
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
expect(listener).not.toHaveBeenCalled()
})
it('swallows a rejected list that settles after disposal', async () => {
let reject = (): void => {}
const gate = new Promise<void>((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } })
const { remote } = fakeRemote({ list: () => gate })
const controller = new FeedbackController(remote, SESSION)
const pending = controller.ensure()
controller.dispose()
reject()
expect(await pending).toEqual({ ok: true })
expect(controller.getSnapshot().status).not.toBe('error')
})
it('describes a non-Error list rejection with a stable message', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'transport', message: 'message feedback list failed' },
})
})
it('describes a non-Error mutation rejection with a stable message', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'transport', message: 'message feedback mutation failed' },
})
})
it('propagates a failed load to a queued mutation without calling the wire', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toMatchObject({
ok: false,
error: { code: 'session-not-found' },
})
expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
})
it('keeps a later mutation running after an earlier one settles as a failure', async () => {
let first = true
const { remote } = fakeRemote({
put: () => {
if (first) {
first = false
return Promise.reject(new Error('first blew up'))
}
return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
},
})
const controller = new FeedbackController(remote, SESSION)
const [a, b] = await Promise.all([
controller.rate(MSG, 'positive'),
controller.rate(MSG, 'negative'),
])
expect(a).toMatchObject({ ok: false, error: { code: 'transport' } })
expect(b).toEqual({ ok: true })
expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
})
it('ignores a conflict reconciliation that lands after disposal', async () => {
// The mutate() guard only refuses work admitted after disposal, so this
// exercises commit()'s own guard: the call is already in flight when the
// fiber unloads, and its authoritative item must not be published.
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
put: async () => {
await gate
return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const listener = vi.fn()
controller.subscribe(listener)
const pending = controller.rate(MSG, 'negative')
controller.dispose()
release()
await pending
// publish() drops its listener set on dispose, so no subscriber is told.
expect(listener).not.toHaveBeenCalled()
})
it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: async () => {
await gate
return { ok: false, error: { code: 'version-conflict', current: null } }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const pending = controller.clear(MSG)
const listener = vi.fn()
controller.subscribe(listener)
controller.dispose()
release()
await pending
// The reconciliation still computes, but no subscriber is notified.
expect(listener).not.toHaveBeenCalled()
})
it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
const existing = item({ version: version('v3'), rating: 'positive' })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({
ok: false,
error: { code: 'note-too-large' },
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
})
it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
const existing = item({ version: version('v4') })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({
ok: false,
error: { code: 'session-not-found' },
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
})
it('preserves a stored note when a rating switch omits one', async () => {
// Regression: a control that rendered before the first list read holds no
// item, so it passes note=undefined; that must not erase the stored note.
const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.note).toBe('keep me')
expect(put.rating).toBe('negative')
})
it('toggle retracts when the committed rating already matches', async () => {
const stored = item({ version: version('v1'), rating: 'positive' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('toggle decides from the committed item, not a cold view', async () => {
// The click lands before any list read: the cold view knows no item, yet the
// stored rating matches, so the toggle must retract rather than re-put.
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(controller.getSnapshot().status).toBe('cold')
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
})
it('toggle replaces the opposite rating and carries the note forward', async () => {
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
})
it('clearNote drops the note and keeps the rating', async () => {
const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.rating).toBe('negative')
expect(put).not.toHaveProperty('note')
})
it('clearNote is a no-op when there is no note to drop', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('resync serializes behind an in-flight mutation', async () => {
// Regression: an unserialized reconnect read could land after a newer put
// and resurrect the version that put had already replaced.
const order: string[] = []
let releasePut = (): void => {}
const putGate = new Promise<void>((r) => { releasePut = r })
const { remote } = fakeRemote({
list: () => {
order.push('list')
return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
},
put: async () => {
order.push('put:start')
await putGate
order.push('put:end')
return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const rating = controller.rate(MSG, 'negative')
const resync = controller.resync()
releasePut()
await Promise.all([rating, resync])
// The reconnect read runs only after the mutation settled.
expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
})
it('refuses a mutation disposed while its seeding read is in flight', async () => {
// Dispose only once the seeding list call has actually started, so the
// mutation is already past the admission check and must be stopped by the
// second guard that runs after ensure() resolves.
let release = (): void => {}
const gate = new Promise<void>((r) => { release = r })
let started = (): void => {}
const listStarted = new Promise<void>((r) => { started = r })
const { remote, calls } = fakeRemote({
list: async () => {
started()
await gate
return { ok: true, value: { items: [] } }
},
})
const controller = new FeedbackController(remote, SESSION)
const pending = controller.rate(MSG, 'positive')
await listStarted
controller.dispose()
release()
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('renders a carrier failure from the Remote envelope', async () => {
// The generated face folds transport faults into ok:false with a
// RemoteFailure, so the controller reads them as values, not rejections.
const { remote } = fakeRemote({
list: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('renders a carrier failure on a mutation without touching the view', async () => {
const { remote } = fakeRemote({
put: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('renders a carrier failure on a delete', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } })
expect(controller.getSnapshot().items.has(MSG)).toBe(true)
})
})
@@ -0,0 +1,249 @@
// @vitest-environment jsdom
/**
* FeedbackActions rendering and gestures: the rating buttons reflect the
* shared view, re-clicking the active rating retracts it, the note editor
* saves through the same rate verb, the Session's feedback is read on first
* interaction rather than on mount, and a rejected mutation surfaces inline
* without losing the authoritative state.
*/
import { useSyncExternalStore } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type {
MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion,
} from '@deepseek-ai/dsh-message-feedback/types'
import { FeedbackActions } from '../src/client/FeedbackActions.tsx'
import type { FeedbackActionResult, FeedbackView } from '../src/client/controller.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const MSG = 'm-1' as MessageId
const t = makeTranslate(zh, commonZh)
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
return {
messageId: MSG,
rating: 'positive',
version: 'v1' as MessageFeedbackVersion,
createdAt: 1,
updatedAt: 1,
...overrides,
}
}
/** Render the controls over a fixed view and recording verbs. */
function mount(options: {
current?: MessageFeedbackItem | undefined
rateResult?: FeedbackActionResult
clearResult?: FeedbackActionResult
status?: FeedbackView['status']
} = {}) {
const view: FeedbackView = {
status: options.status ?? 'ready',
items: new Map(options.current === undefined ? [] : [[MSG, options.current]]),
error: null,
}
const ensure = vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true }))
const rate = vi.fn((_id: MessageId, _rating: MessageFeedbackRating, _note?: string) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const clear = vi.fn((_id: MessageId) =>
Promise.resolve(options.clearResult ?? { ok: true as const }))
// The controller owns retract-vs-replace, so the double stands in for it:
// matching the shown rating retracts, anything else replaces.
const toggle = vi.fn((id: MessageId, next: MessageFeedbackRating) =>
(options.current?.rating === next ? clear(id) : rate(id, next)))
const clearNote = vi.fn((_id: MessageId) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
Parameters<typeof FeedbackActions>[0]
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
}
describe('FeedbackActions', () => {
it('renders both rating buttons unpressed with no recorded feedback', () => {
const ui = mount()
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
expect(ui.getByLabelText(zh['action.dislike']).getAttribute('aria-pressed')).toBe('false')
})
it('marks the recorded rating pressed and offers to retract it', () => {
const ui = mount({ current: item({ rating: 'negative' }) })
expect(ui.getByLabelText(zh['action.dislikeActive']).getAttribute('aria-pressed')).toBe('true')
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
})
it('reads the Session feedback on first interaction, once', () => {
const ui = mount()
const like = ui.getByLabelText(zh['action.like'])
fireEvent.pointerEnter(like)
fireEvent.pointerEnter(like)
fireEvent.focus(ui.getByLabelText(zh['action.dislike']))
expect(ui.ensure).toHaveBeenCalledTimes(1)
})
it('does not read the Session feedback on mount', () => {
const ui = mount()
expect(ui.ensure).not.toHaveBeenCalled()
})
it('rates a message that has no feedback yet', async () => {
const ui = mount()
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
expect(ui.clear).not.toHaveBeenCalled()
})
it('replaces the opposite rating and carries the existing note forward', async () => {
const ui = mount({ current: item({ rating: 'positive', note: 'keep me' }) })
fireEvent.click(ui.getByLabelText(zh['action.dislike']))
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
})
it('retracts the feedback when the active rating is clicked again', async () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
// The double routes a matching rating to clear(), mirroring the controller.
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
})
it('saves a typed note through the rate verb and closes the editor', async () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' precise and short ' } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', 'precise and short') })
await waitFor(() => { expect(ui.queryByLabelText(zh['note.aria'])).toBeNull() })
})
it('clears the note when the editor is emptied', async () => {
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
fireEvent.click(ui.getByText('old note'))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' ' } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.clearNote).toHaveBeenCalledWith(MSG) })
})
it('seeds the editor with the recorded note and abandons it on cancel', () => {
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
fireEvent.click(ui.getByText('old note'))
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old note')
fireEvent.click(ui.getByText(zh['note.cancel']))
expect(ui.queryByLabelText(zh['note.aria'])).toBeNull()
expect(ui.rate).not.toHaveBeenCalled()
})
it('offers no note editor before a rating is recorded', () => {
const ui = mount()
expect(ui.queryByText(zh['note.open'])).toBeNull()
})
it('reports a lost race with the conflict copy', async () => {
const ui = mount({
rateResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
})
it('reports any other failure with the generic copy', async () => {
const ui = mount({
rateResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
})
it('keeps the editor open when the note fails to save', async () => {
const ui = mount({
current: item({ rating: 'positive' }),
rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
})
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
// The draft survives so the human can shorten it instead of retyping.
expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
})
it('publishes no state after the row unmounts mid-flight', async () => {
let release = (): void => {}
const gate = new Promise<FeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
})
const view: FeedbackView = { status: 'ready', items: new Map(), error: null }
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => gate),
clearNote: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof FeedbackActions>[0]
const ui = render(<FeedbackActions {...props} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
fireEvent.click(ui.getByLabelText(zh['action.like']))
ui.unmount()
release()
await gate
window.removeEventListener('error', onError)
expect(errors).toEqual([])
})
it('surfaces a failed list load next to the controls', async () => {
const ui = mount({ status: 'error' })
expect(ui.getByText(zh['error.load'])).toBeTruthy()
})
it('prefers the action failure over the load notice', async () => {
const ui = mount({
status: 'error',
rateResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
expect(ui.queryByText(zh['error.load'])).toBeNull()
})
})
+42
View File
@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../feedback/message-feedback"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-feedback', ['lib/types/index.js', 'lib/types/invariant.js'])
@@ -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-plugins/README.md
README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-plugins
English | [中文](README.zh.md)
Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
## Model Experience
None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-plugins
[English](README.md) | 中文
Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`
页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。
## 模型体验
无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。
#### KV Cache 影响
无;本包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugins",
"description": "Read-only Cordis Loader plugin inventory in Web settings",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-plugins"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "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/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-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:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,286 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 760px;
color: var(--dsw-alias-label-primary);
}
.heading h2,
.catalogHeading h3,
.status,
.failure p {
margin: 0;
}
.heading h2 {
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.status,
.failure {
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.failure {
display: flex;
align-items: center;
gap: 10px;
color: var(--dsw-alias-state-error-primary);
}
.failure button {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
padding: 4px 10px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
cursor: pointer;
}
.catalog {
display: flex;
flex-direction: column;
gap: 12px;
}
.search {
position: relative;
display: flex;
align-items: center;
width: 100%;
color: var(--dsw-alias-label-tertiary);
}
.search > svg {
position: absolute;
left: 12px;
pointer-events: none;
}
.search input {
width: 100%;
height: 36px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 0 34px 0 36px;
outline: none;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
}
.search input::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.search input:focus-visible {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
}
.catalogHeading {
display: flex;
align-items: baseline;
gap: 7px;
padding: 0 2px;
}
.catalogHeading h3 {
font-size: 13px;
line-height: 20px;
font-weight: 600;
}
.catalogHeading span {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
.cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.card {
min-width: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-3);
}
.card[data-open='true'] {
border-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-shadow-lv1);
}
.cardContent {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 52px;
border: 0;
padding: 12px 14px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.cardContent:hover,
.card[data-open='true'] > .cardContent {
background: var(--dsw-alias-interactive-bg-hover);
}
.cardContent:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.cardTitle {
min-width: 0;
overflow: hidden;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardTrailing {
display: inline-flex;
flex: none;
align-items: center;
gap: 7px;
color: var(--dsw-alias-label-tertiary);
}
.statusDot {
display: inline-block;
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
background: var(--dsw-alias-label-tertiary);
}
.statusDot[data-phase='active'] {
background: var(--dsw-alias-state-success-primary);
}
.statusDot[data-phase='failed'] {
background: var(--dsw-alias-state-error-primary);
}
.statusDot[data-phase='loading'] {
background: var(--dsw-alias-state-business-primary);
}
.configTag {
display: inline-flex;
align-items: center;
min-height: 20px;
border-radius: 5px;
padding: 1px 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.configTag[data-enabled='true'] {
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
color: var(--dsw-alias-state-success-primary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.card[data-open='true'] .chevron {
transform: rotate(180deg);
}
.cardDetails {
border-top: 1px solid var(--dsw-alias-border-l2);
padding: 10px 14px 12px;
background: var(--dsw-alias-bg-module-platform);
}
.entryValue {
display: block;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.details {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
margin: 8px 0 0;
}
.details div {
display: contents;
}
.details dt {
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 17px;
}
.details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 17px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.chevron {
transition: transform 140ms var(--ds-ease-in-out);
}
}
@media (max-width: 680px) {
.cards {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,195 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconChevronDownOutline14,
IconSearchOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginsKey } from './locales.ts'
import css from './PluginSettingsSection.module.css'
/** Registration-side Remote face used by the section. */
export interface PluginSettingsSectionInjected {
/** Read a current Host inventory snapshot. */
list: () => Promise<PluginInventorySnapshot>
}
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
/** Full component props assembled by the Settings slot renderer. */
export type PluginSettingsSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.plugins'>
& InjectFace<PluginSettingsSectionInjected>
type ViewState =
| { readonly status: 'loading' }
| { readonly status: 'error' }
| { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot }
const PHASE_KEYS = {
pending: 'pending',
loading: 'loadingPhase',
active: 'active',
failed: 'failed',
unloading: 'unloading',
} satisfies Record<Exclude<PluginFiberPhase, null>, PluginsKey>
/** Localized accessible label for one root Fiber phase. */
function phaseLabel(
phase: PluginFiberPhase,
t: PluginSettingsSectionProps['t'],
): string {
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
}
/** Compact a module specifier without guessing whether its Loader id was generated. */
function moduleShortName(moduleName: string): string {
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
return unscoped
.replace(/^cordis:/, '')
.replace(/^cordis-plugin-/, '')
.replace(/^dsh-(?:host-|client-)?/, '')
}
/** Whether an inventory row matches the local catalog query. */
function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean {
if (normalizedQuery.length === 0) return true
return [entry.moduleName, entry.entryId]
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
}
/** Render the read-only current Loader inventory. */
export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode {
const titleId = useId()
const [request, setRequest] = useState(0)
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
const [state, setState] = useState<ViewState>({ status: 'loading' })
useEffect(() => {
let current = true
void Promise.resolve().then(() => list()).then(
(snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
() => { if (current) setState({ status: 'error' }) },
)
return () => { current = false }
}, [list, request])
const normalizedQuery = query.trim().toLocaleLowerCase()
const filteredEntries = useMemo(
() => state.status === 'ready'
? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery))
: [],
[normalizedQuery, state],
)
useEffect(() => {
if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) {
setExpanded(null)
}
}, [expanded, filteredEntries])
const retry = (): void => {
setState({ status: 'loading' })
setRequest(value => value + 1)
}
return (
<section className={css.section} aria-labelledby={titleId} aria-busy={state.status === 'loading'}>
<header className={css.heading}>
<h2 id={titleId}>{t('title')}</h2>
</header>
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
{state.status === 'error' ? (
<div className={css.failure}>
<p role="alert">{t('error')}</p>
<button type="button" onClick={retry}>{t('retry')}</button>
</div>
) : null}
{state.status === 'ready' ? (
<div className={css.catalog}>
<label className={css.search}>
<IconSearchOutline16 aria-hidden="true" />
<span className={css.visuallyHidden}>{t('search')}</span>
<input
type="search"
value={query}
placeholder={t('search')}
aria-label={t('search')}
onChange={(event) => { setQuery(event.currentTarget.value) }}
/>
</label>
<div className={css.catalogHeading}>
<h3>{t('catalog')}</h3>
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
</div>
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
? <p className={css.status}>{t('emptySearch')}</p>
: null}
{filteredEntries.length > 0 ? (
<ul className={css.cards}>
{filteredEntries.map((entry) => {
const status = phaseLabel(entry.fiberPhase, t)
const title = moduleShortName(entry.moduleName)
const open = expanded === entry.entryId
const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}`
return (
<li
className={css.card}
key={entry.entryId}
data-plugin-entry={entry.entryId}
data-open={open ? 'true' : undefined}
>
<button
className={css.cardContent}
type="button"
aria-expanded={open}
aria-controls={detailId}
aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`}
onClick={() => {
setExpanded(current => current === entry.entryId ? null : entry.entryId)
}}
>
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
<span className={css.cardTrailing}>
<span
className={css.statusDot}
data-phase={entry.fiberPhase ?? 'unobserved'}
role="img"
aria-label={status}
title={status}
/>
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
{t(entry.enabled ? 'enabledTag' : 'disabledTag')}
</span>
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
</span>
</button>
{open ? (
<div className={css.cardDetails} id={detailId}>
<code className={css.entryValue} data-loader-entry>{entry.entryId}</code>
<dl className={css.details}>
<div>
<dt>{t('configuration')}</dt>
<dd>{t(entry.enabled ? 'enabledTag' : 'disabledTag')}</dd>
</div>
<div>
<dt>{t('cordis')}</dt>
<dd>{status}</dd>
</div>
</dl>
</div>
) : null}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
</section>
)
}
@@ -0,0 +1,47 @@
/** Read-only Host plugin inventory registered into Web Settings. */
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx'
import { en, zh, type PluginsKey } from './locales.ts'
export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx'
export type { PluginsKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Read-only Host plugin inventory copy. */
'settings.plugins': PluginsKey
}
}
/** Dictionary namespace owned by this plugin. */
export const NS = 'settings.plugins'
/** Services required by the Settings registration and generated Remote face. */
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
/** Register the lazy plugin inventory page below Models in Settings. */
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries')
const t = ctx.locale.bind(NS)
const list: PluginSettingsSectionInjected['list'] = async () => {
const result = await ctx.remote.pluginInventory.list()
if (!result.ok) {
throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
const injected = (): PluginSettingsSectionInjected => ({ list })
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugin-inventory',
order: 15,
label: () => t('nav'),
locale: NS,
inject: injected,
}, PluginSettingsSection))
}
@@ -0,0 +1,50 @@
/** Copy dictionaries for the plugin inventory Settings section. */
/** Simplified Chinese dictionary and key source of truth. */
export const zh = {
nav: '插件',
title: '插件',
loading: '正在读取插件…',
error: '暂时无法读取插件。',
retry: '重试',
search: '搜索插件',
catalog: '插件列表',
empty: '暂无插件。',
emptySearch: '没有匹配的插件。',
enabledTag: '已启用',
disabledTag: '已停用',
configuration: '配置状态',
cordis: 'Cordis 状态',
unobserved: '未挂载',
pending: '等待依赖',
loadingPhase: '加载中',
active: '已挂载',
failed: '挂载失败',
unloading: '卸载中',
} satisfies Record<string, string>
/** Plugin inventory locale key union. */
export type PluginsKey = keyof typeof zh
/** English dictionary checked against the Chinese key set. */
export const en = {
nav: 'Plugins',
title: 'Plugins',
loading: 'Reading plugins…',
error: 'Plugins are temporarily unavailable.',
retry: 'Retry',
search: 'Search plugins',
catalog: 'Plugin list',
empty: 'No plugins are available.',
emptySearch: 'No matching plugins.',
enabledTag: 'Enabled',
disabledTag: 'Disabled',
configuration: 'Configuration',
cordis: 'Cordis status',
unobserved: 'Not mounted',
pending: 'Waiting for dependencies',
loadingPhase: 'Loading',
active: 'Mounted',
failed: 'Mount failed',
unloading: 'Unloading',
} satisfies Record<PluginsKey, string>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+4
View File
@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the plugin settings section. */
export function apply(): void {}
@@ -0,0 +1,20 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/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-plugins'
/** Cordis companion plugin name. */
export const name = 'client-ui-plugins-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this package owns a read-only Settings contribution. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, NS } from '../src/client/index.ts'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx'
usePinnedBrowserLanguages('zh-CN')
afterEach(cleanup)
const EMPTY = { entries: [] }
type ListResult =
| { readonly ok: true; readonly value: typeof EMPTY }
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
const list = vi.fn<() => Promise<ListResult>>()
.mockResolvedValue({ ok: true, value: EMPTY })
ctx.provide('remote.pluginInventory', { list })
return { ctx, slots: ctx.get('slots') as SlotsService, locale, list }
}
function declare(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-plugins browser plugin', () => {
it('declares only the services used by the Settings Remote contribution', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
})
it('registers a localized section without reading the Remote eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(PluginSettingsSection)
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
expect(entry.locale).toBe(NS)
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
await expect(injected.list()).resolves.toEqual(EMPTY)
expect(b.list).toHaveBeenCalledOnce()
b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('settings.section')).toHaveLength(0)
const stop = declare(b.slots)
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
stop()
expect(b.slots.entries('settings.section')).toHaveLength(0)
declare(b.slots)
await vi.waitFor(() => {
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
})
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
await b.ctx.fiber.dispose()
})
})
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type {
PluginSettingsSectionInjected,
PluginSettingsSectionProps,
} from '../src/client/PluginSettingsSection.tsx'
import { en, type PluginsKey } from '../src/client/locales.ts'
afterEach(cleanup)
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
return {
close: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
t,
list,
}
}
const SNAPSHOT = {
entries: [
{ entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
{ entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' },
{ entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' },
{ entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' },
{ entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' },
{ entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null },
],
} as unknown as Snapshot
describe('PluginSettingsSection', () => {
it('renders searchable two-column-card semantics with dots and tags', async () => {
const deferred = Promise.withResolvers<Snapshot>()
const list = vi.fn(() => deferred.promise)
const view = render(<PluginSettingsSection {...props(list)} />)
expect(screen.getByText(en.loading)).toBeTruthy()
await act(async () => { deferred.resolve(SNAPSHOT) })
expect(list).toHaveBeenCalledOnce()
expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6')
expect(screen.getAllByRole('listitem')).toHaveLength(6)
expect(screen.getAllByText(en.enabledTag)).toHaveLength(5)
expect(screen.getByText(en.disabledTag)).toBeTruthy()
for (const value of [
'Mounted',
'Waiting for dependencies',
'Loading',
'Mount failed',
'Unloading',
'Not mounted',
]) {
expect(screen.getByRole('img', { name: value })).toBeTruthy()
}
const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' })
expect(active.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(active)
expect(active.getAttribute('aria-expanded')).toBe('true')
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d')
expect(screen.getByText(en.configuration)).toBeTruthy()
expect(screen.getByText(en.cordis)).toBeTruthy()
fireEvent.click(active)
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
fireEvent.click(active)
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
target: { value: 'disabled-entry' },
})
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' }))
expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
})
it('filters by module name or Loader entry id', async () => {
render(<PluginSettingsSection {...props(async () => SNAPSHOT)} />)
const search = await screen.findByRole('searchbox', { name: en.search })
fireEvent.change(search, { target: { value: 'disabled-entry' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('directory-picker-native')).toBeTruthy()
fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('hmr')).toBeTruthy()
fireEvent.change(search, { target: { value: 'not-a-plugin' } })
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
expect(screen.getByText(en.emptySearch)).toBeTruthy()
})
it('shows a generic failure and retries into the empty state', async () => {
const list = vi.fn<PluginSettingsSectionInjected['list']>()
.mockRejectedValueOnce(new Error('private transport detail'))
.mockResolvedValueOnce({ entries: [] })
render(<PluginSettingsSection {...props(list)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
expect(screen.queryByText('private transport detail')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.retry }))
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
expect(await screen.findByText(en.empty)).toBeTruthy()
})
it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list']
const failed = render(<PluginSettingsSection {...props(syncFailure)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
failed.unmount()
const deferred = Promise.withResolvers<Snapshot>()
const pending = render(<PluginSettingsSection {...props(() => deferred.promise)} />)
pending.unmount()
await act(async () => { deferred.resolve(SNAPSHOT) })
const deferredFailure = Promise.withResolvers<Snapshot>()
const pendingFailure = render(<PluginSettingsSection {...props(() => deferredFailure.promise)} />)
pendingFailure.unmount()
await act(async () => { deferredFailure.reject(new Error('late failure')) })
})
})
@@ -0,0 +1,15 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PluginsInvariant from '../src/invariant.ts'
describe('ui-plugins invariant companion', () => {
it('registers the empty installer and keeps the node half inert', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined()
const { apply } = await import('../src/index.ts')
apply()
await ctx.fiber.dispose()
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-settings"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js'])
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 0cdffdfaad20784535a7ed010ad4b71d63a0a2c1
README.zh.md: 0b72b3db96b335f0c288758b3f7a8b56441ee88c
README.md: d02230d281482d03545a7dd9bb06fd5f1085d017
README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f
@@ -8,7 +8,7 @@ The shell ships no copy of its own — all text arrives from registrants. Nav la
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while the forwarded `settings/document-updated` event makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default, names the `FEEDBACK_ONLY` and `FULL` opt-in modes, and discloses that `FULL` also enables dsh-sdk command telemetry.
## Model Experience
@@ -8,7 +8,7 @@
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;转发的 `settings/document-updated` 事件则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,列出 `FEEDBACK_ONLY``FULL` 两种显式启用模式,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测
## 模型体验
@@ -8,7 +8,7 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
export const WELCOME_NOTICE_VERSION = '2026-08-11.1'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
@@ -17,7 +17,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
@@ -27,7 +27,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
@@ -162,6 +162,7 @@ body {
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
@@ -253,6 +254,7 @@ body[data-ds-dark-theme] {
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);

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