Merge pull request #3111 from deepseek-harness/worktree/3003-instant-pending-submit

feat(web): 图片询问点击发送即回显,压缩与传输转入后台
This commit is contained in:
CreatixChu
2026-08-27 15:31:01 +08:00
committed by GitHub
72 changed files with 2104 additions and 284 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
2026-08-26-local-submission-echoes.md: 258df8f3f4bdd0441b714c7602273897d2d79fc7
2026-08-26-local-submission-echoes.zh.md: 57a9523a1eaa399e596f0c8eb6f8913545976f87
@@ -0,0 +1,37 @@
# Agent Note: Local submission echoes over the prompt rpcId
Status: implemented
English | [中文](2026-08-26-local-submission-echoes.zh.md)
## Problem
A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one.
## Decision
**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions).
**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins.
**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). Failed detached sends are restored together in submission order while the composer is empty or still contains the preceding automatic restoration; a user edit ends that restoration sequence. Draft images remain owned by the detached attempt through echo retirement, so Session scope disposal can release them after they have left the rail. An observed echo gives each preview URL to `HistoricalImageCache.seed` under the admitted reference. The cache exposes that preview synchronously, fetches the durable attachment, replaces the preview with the canonical URL, and revokes both URLs with their respective lifetimes. Direct subagent continuations do not register echoes because their transport assigns a different RPC identity and image input is unsupported.
Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here.
## Consequences
The submit click paints its message and docks the composer on the same frame, for ordinary text and image prompts, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. An admitted image keeps its local preview until the durable bytes resolve, then displays the host-authoritative rendition without a loading placeholder.
## Verification
Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, one retirement when queue and durable observations coincide, frame-delayed removal, abandon, and disposal. Machine and shell specs pin optimistic commit, concurrent detached settlement, ordered multi-failure restoration, image-only cancellation, and image ownership through scope disposal. ChatView specs pin flow-tail rendering and node- and queue-keyed dedupe with the echo still in the snapshot. Host control specs pin the queue rpcId projection; cache and attachment specs pin synchronous seeded display, canonical replacement, and URL revocation. The connection fixture echoes `requestId`, and the `fresh-round-trip` recorded-session snapshot captures the local echo before durable admission.
## Alternatives considered
**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing.
**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement.
**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent.
**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove.
@@ -0,0 +1,37 @@
# Agent Note:基于 prompt rpcId 的本地提交回显
状态:implemented
[English](2026-08-26-local-submission-echoes.md) | 中文
## 问题
多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。
## 决定
**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。
**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。
**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行,允许并发发送,唯一的冻结 in-flight 槽只留给命令。多个 detached 发送失败时,只要 composer 为空或仍是上一次自动还原的内容,就按提交顺序合并还原;用户编辑后停止这一轮自动还原。草稿图片由 detached attempt 持有到回显退休,因此图片离开 rail 后销毁 Session scope 仍能释放它们。回显以 observed 退休时,`HistoricalImageCache.seed` 把每个预览 URL 挂到 admitted 引用名下。缓存同步公开预览 URL,同时读取 durable 附件;读取完成后用规范化 URL 替换预览,并按各自生命周期撤销两个 URL。直接 subagent continuation 不注册回显,因为它的 transport 会分配另一个 RPC id,而且不支持图片输入。
客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。
## 后果
普通文本与图片 prompt 点击提交后会在当帧显示消息并让 composer 落底,admission 时机不变。默认发送不再冻结 composer,发送期间可以继续输入和提交;machine 的 `submitting` 阶段只用于命令提交。RPC 响应丢失但 admission 已成功的 prompt 通过观察确认结果,不会重复发送。图片在 durable 字节返回前显示本地预览,随后显示 host 保存的版本,中间没有加载占位。
## 验证
Session client spec 覆盖同步插入、requestId 透传、event、queue 与窗口观察、queue 和 durable 同时观察时只退休一次、延帧移除、abandon 与销毁。Machine 与 shell spec 覆盖乐观提交、并发 detached settlement、多个失败按提交顺序还原、图片纯发送的取消,以及图片随 scope 销毁而释放。ChatView spec 覆盖流尾渲染,以及回显仍在 snapshot 时按节点和队列去重。Host control spec 覆盖 queue rpcId 投影;缓存与附件 spec 覆盖 seed 首帧显示、规范化替换和 URL 撤销。connection fixture 回显 `requestId``fresh-round-trip` 的 recorded-session snapshot 在 durable admission 前记录本地回显。
## 考虑过的替代方案
**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。
**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。
**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever``PartialAssistant` 式的旁路状态符合现有先例。
**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。
+17 -1
View File
@@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } fro
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url))
const MODE = webSnapshotMode()
@@ -72,7 +73,21 @@ describe('web e2e: fresh round trip through the real assembly', () => {
// Arm the host-side settled barrier BEFORE the send click.
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const echoSnapshot = await input.evaluate(async (element, prompt) => {
element.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
}))
// sendSession registered its paint yield first. This frame observes the
// committed echo while admission remains queued on the following task.
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
return [
`echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`,
`composer: ${JSON.stringify(element.textContent ?? '')}`,
`contenteditable: ${element.getAttribute('contenteditable')}`,
].join('\n')
}, PROMPT)
await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE)
const sessionId = await settled
settledSessionId = sessionId
if (MODE === 'record') {
@@ -188,6 +203,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'submission-echo.expected.md',
'system-prompt.expected.md',
'tool-schemas.expected.json',
'web-context.expected.md',
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
// Local submission echo over the BUILT client graph (keyless FixtureApiClient
// transport): a text-plus-image send paints its echo bubble synchronously on
// the submit keystroke — before serialization, transport, or the fixture's
// durable admission — with the composer already cleared and editable, and the
// durable user/message replaces the echo without a duplicate. The fixture host
// echoes the prompt requestId as the durable source's rpcId, so the retirement
// path here is the production correlation, not a test hook.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const composer = await waitFor(() => {
const surface = document.querySelector<HTMLElement>('[data-composer-input]')
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' })
fireEvent.paste(composer, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
getData: () => '',
},
})
await waitFor(() => {
if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) {
throw new Error('attachment rail missing')
}
}, { timeout: 5_000 })
fireEvent.paste(composer, {
clipboardData: { items: [], getData: () => '回显这条消息' },
})
await waitFor(() => { expect(composer.textContent).toBe('回显这条消息') })
fireEvent.keyDown(composer, { key: 'Enter' })
// Synchronously after the keystroke: the echo bubble is in the flow with
// the draft text and the object-URL preview, while the prompt has not even
// been serialized yet (it starts after a paint yield). The composer is
// already cleared, editable, and free of the rail.
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
if (echo === null) throw new Error('submission echo missing on the send keystroke')
expect(echo.textContent).toContain('回显这条消息')
expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
expect(composer.textContent).toBe('')
expect(composer.getAttribute('contenteditable')).toBe('true')
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
// The fixture's durable user/message (source.rpcId echoes the prompt
// requestId) replaces the echo: one bubble, no marker left, and the image
// now renders from the durable gallery.
await waitFor(() => {
if (document.querySelector('[data-submission-echo]') !== null) {
throw new Error('submission echo still present after the durable node arrived')
}
}, { timeout: 10_000 })
expect(screen.getAllByText('回显这条消息')).toHaveLength(1)
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null) {
throw new Error('durable user gallery missing')
}
}, { timeout: 10_000 })
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 91bfb814a4370fdafb6d62bc79259495b0069dda
event-producer-consumer.zh.md: e7dcd089f28888bdb235ee0a6df97dc4189f0723
event-producer-consumer.md: 9bd351a204ceb6ab262d2f2b9a0276c44be20cc5
event-producer-consumer.zh.md: 453d22632dbcc73edec67a44759f1de42490be88
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:504`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:484`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:511`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:490`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:497`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:504`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:484`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:511`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:490`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:497`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -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/api/session-controller/README.md
README.md: 815276847f538f99352e0f0e55fc19af5da67470
README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b
README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0
README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4
@@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone.
-----
<a id="configuration"></a>
@@ -29,6 +29,8 @@ kind: "package-reference"
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。
-----
<a id="configuration"></a>
@@ -12,9 +12,37 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PromptContentPart, QueueAction } from '../../types.ts'
import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
import type { ClientResult } from './result.ts'
import type { SessionSnapshot } from './snapshot.ts'
import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
/**
* Why a local submission echo left the snapshot: `observed` when its durable
* `user/message` event or host queue occurrence arrived (with the admitted
* image references in prompt order), `failed` when the prompt was rejected,
* threw, or was aborted before acceptance.
*/
export type PendingSubmissionRetirement =
| { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] }
| { readonly reason: 'failed' }
/** Input registering one local submission echo ahead of its prompt call. */
export interface BeginSubmissionInput {
/** Prompt text exactly as the upcoming prompt will send it. */
readonly text: string
/** Ordered image previews matching the upcoming prompt's image parts. */
readonly images: readonly PendingSubmissionImage[]
/** Settlement callback fired exactly once when the echo retires. */
readonly onRetire?: (retirement: PendingSubmissionRetirement) => void
}
/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */
export interface SubmissionHandle {
/** The prompt RPC identity; pass it to {@link ISession.prompt}. */
readonly requestId: SessionRequestId
/** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */
abandon(): void
}
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
export interface ProjectionsFace {
@@ -33,16 +61,29 @@ export interface ISession {
readonly sessionId: SessionId
/** Host-computed projection values by key (the useProjection seat). */
readonly projections: ProjectionsFace
/**
* Register one local submission echo in `snapshot.pendingSubmissions`,
* synchronously, before the caller serializes and sends the prompt. The
* echo retires when a durable `user/message` event or queue occurrence
* carrying the returned identity arrives, or when the identified prompt
* call fails.
* @param input - echo content and the optional settlement callback.
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
*/
beginSubmission(input: BeginSubmissionInput): SubmissionHandle
/**
* Send a prompt into the session.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @param signal - optional caller cancellation for the complete admission round-trip.
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(
content: PromptContentPart[],
mode: 'queue' | 'steer',
signal?: AbortSignal,
requestId?: SessionRequestId,
): Promise<ClientResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
@@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionRequestId } from '../../types.ts'
import type { ClientFailure } from './result.ts'
/** One transient inbox occurrence from the authoritative queue snapshot. */
@@ -10,11 +11,42 @@ export interface QueuedMessage {
readonly id: MessageId
readonly messageId: MessageId
readonly placement: 'queued' | 'steering' | 'context'
/** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */
readonly rpcId?: SessionRequestId
readonly content: readonly ContentBlock[]
readonly preview: string
readonly text: string | null
}
/** One image displayed by a local submission echo before durable admission. */
export interface PendingSubmissionImage {
/** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */
readonly previewUrl: string
/** Browser file name, when the file had one. */
readonly name?: string
/** Intrinsic pixel width, when the submitter has probed it. */
readonly width?: number
/** Intrinsic pixel height, when the submitter has probed it. */
readonly height?: number
}
/**
* One local prompt-submission echo: inserted synchronously when a submission
* begins, so the conversation can show the message before serialization,
* transport, and durable admission complete. Client-memory only — reload and
* reconnect rebuild the conversation from durable events alone.
*/
export interface PendingSubmission {
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
readonly requestId: SessionRequestId
/** Client wall-clock ms when the submission began. */
readonly time: number
/** Prompt text exactly as it will be sent (one text block). */
readonly text: string
/** Ordered image previews matching the prompt's image parts. */
readonly images: readonly PendingSubmissionImage[]
}
/** History-open lifecycle of a Session event window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
@@ -28,6 +60,8 @@ export interface PromptError {
export interface SessionSnapshot {
readonly sessionId: SessionId
readonly queue: readonly QueuedMessage[]
/** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
readonly pendingSubmissions: readonly PendingSubmission[]
readonly running: boolean
readonly subagent: {
readonly address: SubagentAddress
@@ -40,7 +40,14 @@ export type {
SessionProjectionMap,
UseProjection,
} from './sessions/projection-store.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
BeginSubmissionInput,
ISession,
PendingSubmissionRetirement,
ProjectionsFace,
SessionFace,
SubmissionHandle,
} from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export { MutableSessionEventSource } from './contract/events.ts'
export type {
@@ -53,6 +60,8 @@ export type {
} from './contract/events.ts'
export type {
OpenState,
PendingSubmission,
PendingSubmissionImage,
PromptError,
QueuedMessage,
SessionSnapshot,
@@ -43,6 +43,7 @@ export class SessionQueueMirror {
id: item.id,
messageId: item.message.id,
placement: item.placement,
...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }),
content,
preview: previewOf(content),
text: textOf(content),
@@ -22,9 +22,11 @@ import type {
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
import type { SessionFace } from '../contract/session.ts'
import type {
OpenState, PromptError, SessionSnapshot,
BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
} from '../contract/session.ts'
import type {
OpenState, PendingSubmission, PromptError, SessionSnapshot,
} from '../contract/snapshot.ts'
import { MutableSessionEventSource } from '../contract/events.ts'
import type {
@@ -99,6 +101,14 @@ export class Session implements SessionFace {
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
private pendingSubmissions: readonly PendingSubmission[] = []
/** Per-echo settlement state; `retiring` latches the first observation so a
* queue frame and its durable event cannot both retire one echo. */
private readonly submissionSettlements = new Map<SessionRequestId, {
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
retiring: boolean
}>()
/** Owns the addressed page/follow lifecycle while this Session is open. */
private events: SessionEventStream | undefined
@@ -168,16 +178,42 @@ export class Session implements SessionFace {
// ---- Operations ----
/**
* Register one local submission echo (see the ISession declaration).
* Synchronous through markDirty: the echo is in the very next snapshot, so
* the conversation can paint it before the caller starts serializing.
* @param input - echo content and the optional settlement callback.
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
*/
beginSubmission(input: BeginSubmissionInput): SubmissionHandle {
const requestId = randomUUID() as SessionRequestId
this.pendingSubmissions = [...this.pendingSubmissions, {
requestId,
time: Date.now(),
text: input.text,
images: input.images,
}]
this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
// The blank → engaging edge flips here, ahead of prompt(): the composer
// docks and the echo renders on the click's own frame.
this.promptAttempted = true
this.notifier.markDirty()
return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } }
}
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - queue appends after the current turn; steer interrupts it.
* @param signal - optional caller cancellation for the complete admission round-trip.
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(
content: PromptContentPart[],
mode: 'queue' | 'steer',
signal?: AbortSignal,
requestId?: SessionRequestId,
): Promise<ClientResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
@@ -192,7 +228,7 @@ export class Session implements SessionFace {
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
result = toSessionResult(await this.remote.session.prompt({
requestId: randomUUID() as SessionRequestId,
requestId: requestId ?? randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
@@ -235,6 +271,7 @@ export class Session implements SessionFace {
result = transportResult(error)
}
if (!result.ok) {
if (requestId !== undefined) this.retireFailedSubmission(requestId)
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
return result
@@ -437,6 +474,7 @@ export class Session implements SessionFace {
*/
replaceControl(queue: readonly SessionQueuedItem[]): void {
this.queueMirror.replace(queue)
this.observeSubmissionQueue(queue)
this.notifier.markDirty()
}
@@ -446,6 +484,7 @@ export class Session implements SessionFace {
*/
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
this.queueMirror.replace(frame.items)
this.observeSubmissionQueue(frame.items)
this.notifier.markDirty()
}
@@ -526,6 +565,12 @@ export class Session implements SessionFace {
* @returns when the Remote iterator has completed teardown.
*/
async dispose(): Promise<void> {
// Unsettled echoes retire as failed so their owners can restore or
// release browser resources; echoes already scheduled as observed keep
// that settlement.
for (const requestId of [...this.submissionSettlements.keys()]) {
this.retireFailedSubmission(requestId)
}
this.openGeneration++
const events = this.events
this.events = undefined
@@ -584,6 +629,7 @@ export class Session implements SessionFace {
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(entries, hasMore)
for (const entry of entries) this.observeSubmissionEvent(entry.event)
this.notifier.markDirty()
}
@@ -601,9 +647,70 @@ export class Session implements SessionFace {
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
this.eventSource.append(entry)
// After the feed append: the conversation assembly's animation frame is
// registered by the feed subscribers above, so the echo-retirement frame
// scheduled here always runs after the durable node became renderable.
this.observeSubmissionEvent(event)
return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
}
/** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return
// Structural read: window entries may be compact history records, so the
// fields are narrowed rather than trusted (same posture as Conversation
// assembly matchers).
const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
}
/** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void {
if (this.submissionSettlements.size === 0) return
for (const item of items) {
if (item.rpcId !== undefined) {
this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
}
}
}
/**
* Latch one observed settlement and remove the echo an animation frame
* later. The delay keeps the echo in the snapshot until the frame in which
* the durable node (whose assembly frame was registered first) is
* renderable; the render-time rpcId dedupe hides the one-frame overlap.
*/
private scheduleObservedRetirement(
requestId: SessionRequestId,
attachments: readonly ImageAttachmentRef[],
): void {
const settlement = this.submissionSettlements.get(requestId)
if (settlement === undefined || settlement.retiring) return
settlement.retiring = true
scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) })
}
/** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
private retireFailedSubmission(requestId: SessionRequestId): void {
const settlement = this.submissionSettlements.get(requestId)
if (settlement === undefined || settlement.retiring) return
settlement.retiring = true
this.finishSubmission(requestId, { reason: 'failed' })
}
/** Single removal point: drop the echo, publish, then notify the owner. */
private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void {
const settlement = this.submissionSettlements.get(requestId)
/* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */
if (settlement === undefined) return
this.submissionSettlements.delete(requestId)
this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId)
this.notifier.markDirty()
settlement.onRetire?.(retirement)
}
/** Publish a terminal background failure only while this stream still owns the Session. */
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
if (generation !== this.openGeneration || this.events !== events) return
@@ -620,6 +727,7 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
queue: this.queueMirror.snapshot(),
pendingSubmissions: this.pendingSubmissions,
running: this.running,
subagent: this.address === undefined
? null
@@ -647,6 +755,26 @@ export class Session implements SessionFace {
}
}
/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */
function scheduleFrame(fn: () => void): void {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() })
else setTimeout(fn, 0)
}
/** Image attachment references in one structurally-read content block list, in block order. */
function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
if (!Array.isArray(content)) return []
const refs: ImageAttachmentRef[] = []
for (const block of content) {
if (typeof block !== 'object' || block === null) continue
const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
refs.push(candidate.attachment as ImageAttachmentRef)
}
}
return refs
}
/** Convert a terminal Session stream failure to the Client error vocabulary. */
function openFailure(error: unknown): ClientFailure {
const failure = sessionStreamFailure(error)
@@ -188,16 +188,24 @@ function queueItems(
...project('next-turn').map(message => ({
id: message.id,
placement: 'queued' as const,
...promptRpcId(message),
message: { id: message.id, content: message.content as unknown as JsonValue[] },
})),
...project('next-step').map(message => ({
id: message.id,
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
...promptRpcId(message),
message: { id: message.id, content: message.content as unknown as JsonValue[] },
})),
]
}
/** Prompt-RPC identity carried by a browser-submitted message's user source. */
function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
const source = message.source
return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
}
function jobView(job: JobSnapshot): SessionJob {
return {
id: job.id,
@@ -432,6 +432,8 @@ export type SessionFollowFrame =
export interface SessionQueuedItem {
readonly id: MessageId
readonly placement: 'queued' | 'steering' | 'context'
/** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */
readonly rpcId?: SessionRequestId
/** JSON-safe message fields consumed by pending-queue presentation. */
readonly message: {
readonly id: MessageId
@@ -69,6 +69,30 @@ describe('Session control queue projection', () => {
await iterator.next()
})
it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => {
const { control, inbox } = await harness()
const identified = createUserMessage({
content: [{ type: 'text', text: 'browser prompt' }],
source: { kind: 'user', rpcId: 'req-42' as never },
})
inbox.append('next-turn', identified)
inbox.append('next-step', message('plain steering'))
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const opened = await iterator.next()
if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
const items = opened.value.value.queues['queue-session' as SessionId] ?? []
expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([
{ id: identified.id, placement: 'queued', rpcId: 'req-42' },
{ id: items[1]?.id, placement: 'steering', rpcId: undefined },
])
expect('rpcId' in (items[1] ?? {})).toBe(false)
abort.abort()
await iterator.next()
})
it('ignores inbox events without the exact live Agent session', async () => {
const { ctx, control, agent, inbox } = await harness()
const abort = new AbortController()
@@ -0,0 +1,258 @@
/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { Session } from '../src/client/sessions/session.ts'
import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
import { historyValue } from './event-script.client.ts'
const SID = 'fk-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, fakeRemote(api)) }
}
function imageRef(id: string): ImageAttachmentRef {
return {
attachmentId: id,
mediaType: 'image/png',
bytes: 1,
width: 2,
height: 2,
} as unknown as ImageAttachmentRef
}
/** A durable browser-prompt user/message whose source echoes `rpcId`. */
function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
return {
seq,
time: 1_700_000_000_000 + seq,
type: 'user/message',
surfaceOp: 'append',
data: createUserMessage({
content: [
...refs.map(attachment => ({ type: 'image' as const, attachment })),
{ type: 'text' as const, text: '发送' },
],
source: { kind: 'user', rpcId },
}),
} as unknown as SessionEvent
}
function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
return {
id: 'm-queued' as SessionQueuedItem['id'],
placement: 'queued',
rpcId,
message: {
id: 'm-queued' as SessionQueuedItem['id'],
content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
},
}
}
/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
function settleFrames(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('beginSubmission', () => {
it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => {
const { session } = makeSession()
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
const handle = session.beginSubmission({
text: '你好',
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
})
expect(session.getSnapshot().promptAttempted).toBe(true)
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
requestId: handle.requestId,
text: '你好',
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
}])
})
it('abandon retires the echo as failed exactly once', () => {
const { session } = makeSession()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '放弃',
images: [],
onRetire: retirement => retirements.push(retirement),
})
handle.abandon()
handle.abandon()
expect(session.getSnapshot().pendingSubmissions).toEqual([])
expect(retirements).toEqual([{ reason: 'failed' }])
})
})
describe('prompt-coupled retirement', () => {
it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '失败的',
images: [],
onRetire: retirement => retirements.push(retirement),
})
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
expect(result.ok).toBe(false)
expect(session.getSnapshot().pendingSubmissions).toEqual([])
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' })
expect(retirements).toEqual([{ reason: 'failed' }])
})
it('sends the echo identity as the prompt requestId', async () => {
const { api, session } = makeSession()
const handle = session.beginSubmission({ text: '带 id', images: [] })
await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
})
it('an unidentified prompt failure leaves registered echoes alone', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
session.beginSubmission({ text: '还在', images: [] })
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
})
})
describe('observed retirement', () => {
it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '发送',
images: [{ previewUrl: 'blob:p1' }],
onRetire: retirement => retirements.push(retirement),
})
const refs = [imageRef('att-1')]
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never })
// Synchronously after the append the echo is still in the snapshot; the
// render-time dedupe owns the overlap frame.
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
await settleFrames()
expect(session.getSnapshot().pendingSubmissions).toEqual([])
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
})
it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => {
const { session } = makeSession()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '排队',
images: [{ previewUrl: 'blob:p1' }],
onRetire: retirement => retirements.push(retirement),
})
const refs = [imageRef('att-q')]
session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] })
await settleFrames()
expect(session.getSnapshot().pendingSubmissions).toEqual([])
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
// The queue projection keeps the correlation id for render-time dedupe.
expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
})
it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
const { api, session } = makeSession()
const handle = session.beginSubmission({ text: '重连', images: [] })
api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
await session.open()
await settleFrames()
expect(session.getSnapshot().pendingSubmissions).toEqual([])
})
it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '先观察',
images: [],
onRetire: retirement => retirements.push(retirement),
})
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
handle.abandon()
await settleFrames()
expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
})
it('retires once when the queue and durable event report the same request id', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
text: '同一请求',
images: [],
onRetire: retirement => retirements.push(retirement),
})
session.handleControlFrame({
type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])],
})
await api.pushFollow(SID, {
type: 'event', event: promptEvent(0, handle.requestId) as never,
})
await settleFrames()
expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
expect(session.getSnapshot().pendingSubmissions).toEqual([])
})
it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
frames.push(fn)
return frames.length
})
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const handle = session.beginSubmission({ text: '帧', images: [] })
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
expect(frames).toHaveLength(1)
frames[0]?.(0)
expect(session.getSnapshot().pendingSubmissions).toEqual([])
})
})
describe('disposal', () => {
it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
const observed = session.beginSubmission({
text: '已观察',
images: [],
onRetire: retirement => retirements.push({ text: '已观察', retirement }),
})
session.beginSubmission({
text: '未settle',
images: [],
onRetire: retirement => retirements.push({ text: '未settle', retirement }),
})
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never })
await session.dispose()
await settleFrames()
expect(retirements).toEqual([
{ text: '未settle', retirement: { reason: 'failed' } },
{ text: '已观察', retirement: { reason: 'observed', attachments: [] } },
])
expect(session.getSnapshot().pendingSubmissions).toEqual([])
})
})
@@ -2962,9 +2962,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
return { type: 'image', attachment }
})
// The host echoes the prompt's requestId as the user source's rpcId;
// the Session object retires its local submission echo on it. The
// user-rpc source member is declared by dsh-api-session-controller,
// which this standalone fixture does not import — hence the assertion.
const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource
if (mode === 'steer' && replays.has(id)) {
// Steering: the durable user/message lands inside the current turn; the replay continues.
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) })
return sessionOk({ accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
@@ -2977,7 +2982,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
@@ -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: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5
README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e
README.md: 9fa03432b43686dd55af494640708a8bf0983f9a
README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18
+1 -1
View File
@@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima
| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly |
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal |
+1 -1
View File
@@ -54,7 +54,7 @@ kind: "package-reference"
| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 |
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 |
| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal |
@@ -4,8 +4,22 @@ import { ImageLightbox } from './ImageLightbox.tsx'
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */
export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise<string>) & {
peek?: (attachment: ImageAttachmentRef) => string | undefined
}
/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */
export type MessageImageSpec =
| { readonly attachment: ImageAttachmentRef }
| {
readonly preview: {
readonly url: string
readonly name?: string
readonly width?: number
readonly height?: number
}
}
/** Message-image strings the owner resolves from its own locale namespace. */
export interface MessageImageLabels {
@@ -28,11 +42,13 @@ export interface MessageImageLabels {
* `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
function singleFit(
dimensions: { readonly width: number; readonly height: number },
): { width: number; height: number; objectPosition: string } {
const natural = dimensions.width / dimensions.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)
const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height)
return {
width: Math.max(1, Math.round(box.width * scale)),
height: Math.max(1, Math.round(box.height * scale)),
@@ -40,24 +56,36 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num
}
}
/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */
function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined {
if ('attachment' in image) return image.attachment
return image.preview.width !== undefined && image.preview.height !== undefined
? { width: image.preview.width, height: image.preview.height }
: undefined
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile.
* several renders as a fixed 64px square tile. The preview arm displays its
* local URL directly — no loader round-trip, no failure/retry surface.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.image - the durable reference to load, or the local preview to display.
* @param props.load - session-authorized URL loader for the durable arm.
* @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, variant, labels }: {
attachment: ImageAttachmentRef
export function MessageImage({ image, load, variant, labels }: {
image: MessageImageSpec
load: ImageLoader
variant: 'single' | 'tile'
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
const preview = 'preview' in image ? image.preview : undefined
const attachment = 'attachment' in image ? image.attachment : undefined
const [loaded, setLoaded] = useState<string | null>(() =>
attachment === undefined ? null : (load.peek?.(attachment) ?? null))
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
@@ -65,20 +93,30 @@ export function MessageImage({ attachment, load, variant, labels }: {
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const dimensions = useMemo(() => dimensionsOf(image), [image])
const fit = useMemo(
() => (variant === 'single' ? singleFit(attachment) : undefined),
[attachment, variant],
() => {
if (variant !== 'single') return undefined
// A preview whose intake probe has not resolved sizes as a square crop;
// the durable replacement restores the exact fit.
return dimensions === undefined
? { width: 240, height: 240, objectPosition: 'center' }
: singleFit(dimensions)
},
[dimensions, variant],
)
useEffect(() => {
if (attachment === undefined) return
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
setLoaded(load.peek?.(attachment) ?? null)
void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
const src = preview?.url ?? loaded
const label = (preview?.name ?? attachment?.name) ?? labels.image
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
return (
<>
@@ -103,7 +141,7 @@ export function MessageImage({ attachment, load, variant, labels }: {
/** 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 }[]
images: readonly MessageImageSpec[]
load: ImageLoader
align: 'start' | 'end'
labels: MessageImageLabels
@@ -113,7 +151,13 @@ export function ImageGallery({ images, load, align, labels }: {
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
<MessageImage
key={`${'attachment' in image ? image.attachment.attachmentId : image.preview.url}:${index}`}
image={image}
load={load}
variant={variant}
labels={labels}
/>
))}
</div>
)
@@ -47,9 +47,19 @@ const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_S
const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
describe('MessageImage', () => {
it('renders a cached URL on the first frame while refreshing it', () => {
const load = Object.assign(vi.fn(() => new Promise<string>(() => {})), {
peek: vi.fn(() => 'blob:seeded'),
})
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
expect(view.queryByText('图片加载中…')).toBeNull()
expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded')
expect(load).toHaveBeenCalledWith(attachment)
})
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} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ 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')
@@ -64,7 +74,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} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
@@ -74,7 +84,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} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
@@ -84,7 +94,7 @@ describe('MessageImage', () => {
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
@@ -96,7 +106,7 @@ describe('MessageImage', () => {
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} />,
<MessageImage image={{ 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')
@@ -105,7 +115,7 @@ describe('MessageImage', () => {
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} />,
<MessageImage image={{ 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')
@@ -114,7 +124,7 @@ describe('MessageImage', () => {
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} />,
<MessageImage image={{ 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')
@@ -123,7 +133,7 @@ describe('MessageImage', () => {
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 view = render(<MessageImage image={{ 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()
@@ -131,7 +141,7 @@ describe('MessageImage', () => {
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 view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
expect(retry.getAttribute('data-variant')).toBe('tile')
})
@@ -139,29 +149,74 @@ describe('MessageImage', () => {
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} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ 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} variant="single" labels={labels} />)
const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
})
})
describe('MessageImage preview arm', () => {
it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => {
const load = vi.fn()
const view = render(
<MessageImage
image={{ preview: { url: 'blob:echo', name: 'echo.png', width: 640, height: 320 } }}
load={load}
variant="single"
labels={labels}
/>,
)
expect(load).not.toHaveBeenCalled()
const img = view.getByAltText('echo.png') as HTMLImageElement
expect(img.src).toContain('blob:echo')
const frame = img.closest('button') as HTMLButtonElement
expect(frame.style.width).toBe('240px')
expect(frame.style.height).toBe('120px')
})
it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
const load = vi.fn()
const view = render(
<MessageImage image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
)
const img = view.getByAltText('图片') as HTMLImageElement
const frame = img.closest('button') as HTMLButtonElement
expect(frame.style.width).toBe('240px')
expect(frame.style.height).toBe('240px')
})
it('opens the lightbox from a preview thumbnail', () => {
const view = render(
<MessageImage image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
)
fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' }))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
})
})
describe('ImageGallery', () => {
it('renders nothing without images and an aligned wrapping group with them', async () => {
const load = vi.fn().mockResolvedValue('blob:gallery')
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
expect(empty.container.firstChild).toBeNull()
const view = render(
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
<ImageGallery
images={[{ attachment }, { preview: { url: 'blob:echo', name: 'echo.png' } }, { attachment }]}
load={load}
align="end"
labels={labels}
/>,
)
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
expect(view.getByAltText('echo.png')).toBeTruthy()
})
it('renders a lone image large and several images as square tiles', () => {
+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-chat/README.md
README.md: 26d712f58726f13d390efa2e6fd42b38fb373a1f
README.zh.md: 53a98828ccfc9c4841ccc8b4d27d4df527af0ec4
README.md: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff
README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77
+1 -1
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members.
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic.
## Table of Contents
+1 -1
View File
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。
## 目录
+5 -1
View File
@@ -1,5 +1,6 @@
/** Register the Chat Conversation target, renderers, stats, and details surface. */
import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -100,7 +101,10 @@ export function apply(ctx: Context): void {
return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path))
},
loadOlder: () => { void session.loadOlder() },
loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
loadImage: Object.assign(
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
{ peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
),
chatScroll: {
save: (position) => {
if (position === null) chatScrollPositions.delete(sessionId)
@@ -7,8 +7,8 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { TurnNavigationItem } from '../contract/snapshot.ts'
import { PendingSteeringBubble } from './MessageItem.tsx'
import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { TurnNavigator } from './TurnNavigator.tsx'
import { formatRunDuration } from './message-chrome.ts'
@@ -124,6 +124,33 @@ function isFolderOpenPath(path: string): boolean {
return path === '.'
}
/**
* Prompt-RPC identities already rendered by durable material: user/steering
* node sources plus queue occurrences. A submission echo whose identity
* appears here is hidden in the same render, so the echo→durable swap is
* atomic — no duplicate, no gap — regardless of when the echo leaves the
* session snapshot.
*/
function observedRpcIds(
order: readonly string[],
nodes: ChatSnapshot['nodes'],
queue: readonly { readonly rpcId?: string }[],
): ReadonlySet<string> {
const observed = new Set<string>()
for (const key of order) {
const node = nodes.get(key)
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue
const source = (node.data as { readonly source?: unknown }).source as
| { readonly kind?: unknown; readonly rpcId?: unknown }
| undefined
if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId)
}
for (const item of queue) {
if (item.rpcId !== undefined) observed.add(item.rpcId)
}
return observed
}
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
let latest: number | null = null
for (const turn of timeline.turns.values()) {
@@ -234,6 +261,15 @@ export function ChatView({
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const pendingSubmissions = useSession(s => s.pendingSubmissions)
// Submission echoes still awaiting their durable counterpart. `order` is the
// recompute trigger: durable user material always arrives as an append, and
// every append replaces the order array.
const visibleSubmissions = useMemo(() => {
if (pendingSubmissions.length === 0) return pendingSubmissions
const observed = observedRpcIds(order, nodeStore, inbox)
return pendingSubmissions.filter(submission => !observed.has(submission.requestId))
}, [pendingSubmissions, order, nodeStore, inbox])
const renderMessageImages = useCallback<RenderMessageImages>(
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
[loadImage, renderSlot],
@@ -256,6 +292,7 @@ export function ChatView({
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const lastSteeringIdRef = useRef<string | null>(null)
const lastSubmissionIdRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (which would snap inertial
* scrolls the rest of the way to the floor). */
@@ -266,7 +303,8 @@ export function ChatView({
const lastKey = order.at(-1) ?? null
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}`
const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}`
const syncActiveTurn = useCallback((): void => {
const local = listRef.current
@@ -358,6 +396,7 @@ export function ChatView({
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
@@ -374,6 +413,7 @@ export function ChatView({
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
@@ -382,13 +422,15 @@ export function ChatView({
// (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el)
})
const onScrollRef = useRef(() => {})
@@ -575,6 +617,14 @@ export function ChatView({
t={t}
/>
))}
{visibleSubmissions.map(submission => (
<PendingSubmissionBubble
key={submission.requestId}
submission={submission}
renderMessageImages={renderMessageImages}
t={t}
/>
))}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>
@@ -1,5 +1,7 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
@@ -146,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, renderMessageImages, actions, pending = false, referenceLabels = [], t,
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
}: {
content: readonly unknown[]
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -154,15 +156,25 @@ function UserStyleBubble({
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */
echo?: boolean
/** Exact session mention labels associated by the adjacent recall node. */
referenceLabels?: readonly string[]
/** Local submission-echo previews replacing the content-derived image group. */
previewImages?: readonly MessageImageSource[]
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, images, rest } = contentParts(content)
const { text, images: contentImages, rest } = contentParts(content)
const images = previewImages ?? contentImages
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
<div
className={css.userRow}
data-pending-steering={pending || undefined}
data-submission-echo={echo || undefined}
data-time-hover-root
>
<div className={css.userStack}>
{renderMessageImages({ images, align: 'end' })}
{showBubble && <div className={css.bubble}>
@@ -209,6 +221,54 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: {
)
}
/**
* Render one local submission echo with the exact visual language of the
* durable user node that replaces it: draft text plus object-URL previews,
* visible from the submit click until the durable `user/message` (or its
* queue occurrence) renders.
* @param props - the session snapshot's pending submission and render seats.
* @returns the echoed user bubble.
*/
export function PendingSubmissionBubble({ submission, renderMessageImages, t }: {
submission: PendingSubmission
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
t: ChatViewSlotProps['t']
}): ReactNode {
const content = useMemo(
() => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]),
[submission.text],
)
const previewImages = useMemo<readonly MessageImageSource[]>(
() => submission.images.map(image => ({
preview: {
url: image.previewUrl,
...(image.name === undefined ? {} : { name: image.name }),
...(image.width === undefined ? {} : { width: image.width }),
...(image.height === undefined ? {} : { height: image.height }),
},
})),
[submission.images],
)
return (
<UserStyleBubble
content={content}
previewImages={previewImages}
renderMessageImages={renderMessageImages}
echo
t={t}
actions={text => (
<MessageIconActions
text={text}
time={submission.time}
clock="start"
className={css.actions}
t={t}
/>
)}
/>
)
}
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, renderMessageImages, t,
@@ -1,8 +1,7 @@
/** Chat-owned Slot declarations and composed component props. */
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type {
ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
@@ -102,7 +101,7 @@ export interface ChatViewInjected {
openDetails: (target: SelectionTarget) => void
openFile: (path: string) => Promise<void>
loadOlder: () => void
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
loadImage: MessageImageLoader
chatScroll: {
save: (position: ChatScrollPosition | null) => void
read: () => ChatScrollPosition | null
@@ -169,8 +169,10 @@ describe('Chat inject API', () => {
injected.chatScroll.save(null)
expect(injected.chatScroll.read()).toBeNull()
await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String))
const loaded = await injected.loadImage(ATTACHMENT)
expect(loaded).toEqual(expect.any(String))
expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId)
expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded)
await b.runtime.dispose()
})
})
@@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
return {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
removed: false,
openState: 'open',
@@ -756,6 +757,99 @@ describe('ChatView', () => {
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
})
it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => {
const h = makeHarness(
{ nodes: [assistant(1, 'working')] },
{
pendingSubmissions: [
{ requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] },
],
},
)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull()
// The durable node arrives while the echo is STILL in the session
// snapshot: the render-time rpcId dedupe keeps exactly one bubble.
act(() => {
h.setChat({
nodes: [
assistant(1, 'working'),
{
kind: 'user', seq: 2, time: 2_000,
content: [{ type: 'text', text: '即发即显' }] as never,
source: { kind: 'user', rpcId: 'req-1' },
},
],
})
})
expect(view.getAllByText('即发即显')).toHaveLength(1)
expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
// The delayed snapshot retirement changes nothing visible.
act(() => { h.setSession({ pendingSubmissions: [] }) })
expect(view.getAllByText('即发即显')).toHaveLength(1)
})
it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => {
const h = makeHarness(
{ nodes: [assistant(1, 'working')] },
{
running: true,
pendingSubmissions: [
{ requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] },
],
},
)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('排队中')).toBeTruthy()
act(() => {
h.setSession({
queue: [{
id: 'q-occurrence' as never,
messageId: 'q-message' as never,
placement: 'queued' as const,
rpcId: 'req-q' as never,
content: [{ type: 'text' as const, text: '排队中' }],
preview: '排队中',
text: '排队中',
}],
})
})
// The queued occurrence renders in the queue dock, not the flow; the
// flow-tail echo yields to it in the same snapshot.
expect(view.queryByText('排队中')).toBeNull()
})
it('an image echo renders its previews through the message-image slot', () => {
const h = makeHarness(
{ nodes: [] },
{
pendingSubmissions: [{
requestId: 'req-img' as never,
time: 7_000,
text: '',
images: [
{ previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
{ previewUrl: 'blob:echo-b' },
],
}],
},
)
const baseRenderSlot = h.props.renderSlot
const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
const images = (owner as { images: readonly unknown[] }).images
return <div data-testid="echo-images" data-count={images.length} data-first={JSON.stringify(images[0])} />
}) as unknown as ChatViewSlotProps['renderSlot']
const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
const gallery = view.getByTestId('echo-images')
expect(gallery.getAttribute('data-count')).toBe('2')
expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({
preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
})
})
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
@@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot {
return {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
removed: false,
openState: 'open',
@@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages {
calls.push(owner)
return (
<div data-testid="message-images" data-align={owner.align} data-count={owner.images.length}>
{owner.images.map(({ attachment: image }, index) => (
<span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
))}
{owner.images.map((entry, index) => {
if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references')
const image = entry.attachment
return <span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
})}
</div>
)
}
@@ -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: 188a4461c1ab46e8be4f96c402c4707b1c66c415
README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80
README.md: bcce1e77c45cfa75c6a7fe9c45a98b1036d9ea63
README.zh.md: 1d24141945c68d7e948a730ff5b93e8d2e38e9bb
@@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id.
While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
<a id="temporary-composer-entries"></a>
@@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inertWorkspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrencequeue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
<a id="temporary-composer-entries"></a>
@@ -336,14 +336,16 @@ export interface InputState {
/**
* One in-flight submission attempt: the ONLY id concept in the submit plane.
* Created on enter; carried by adjudicated/submit-settled events; stale
* attempts are dropped (anti-backwash). release/session teardown aborts the
* current attempt, keeping the promise bounded.
* Created on enter; carried by adjudicated/submit-settled/sink-settled
* events; stale attempts are dropped (anti-backwash). Command attempts hold
* the single frozen in-flight slot; default-sink attempts run detached and
* concurrently. release/session teardown aborts them all, keeping every
* promise bounded.
*/
export interface SubmitAttempt {
readonly seq: number
readonly signal: AbortSignal
/** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */
/** Clipboard-projection draft captured before an optimistic default-send commit. */
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
@@ -365,6 +367,8 @@ export type InputEvent =
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string }
/** Settlement of one optimistic default send, independent of the frozen command slot. */
| { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
/** Commit an image-only send whose empty draft did not need an attempt. */
| { readonly type: 'send-committed' }
| { readonly type: 'release' }
@@ -376,7 +380,13 @@ export type InputEvent =
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode }
/** Detached default send; the shell captures its editor projection before the following commit effect. */
| {
readonly type: 'default-sink'
readonly attempt: SubmitAttempt
readonly draft: string
readonly mode: InputSubmitMode
}
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
/**
* Clear the committed draft in the editor and cut undo history. A string
@@ -28,6 +28,10 @@ export interface ComposerAttachment {
id: DraftAttachmentId
file: File
previewUrl: string
/** Intrinsic pixel width, filled asynchronously by the intake header probe. */
width?: number
/** Intrinsic pixel height, filled asynchronously by the intake header probe. */
height?: number
}
/** Input state handed to the optional attachment presentation plugin. */
@@ -44,12 +48,35 @@ export interface ComposerAttachmentsOwnerProps {
dropLimits?: { readonly count: number; readonly size: string } | undefined
}
/** Durable image group handed to the optional attachment presentation plugin. */
/**
* One image inside a message record: a durable admitted reference, or the
* local preview of a submission echo whose admission is still in flight.
*/
export type MessageImageSource =
| { readonly attachment: ImageAttachmentRef }
| {
readonly preview: {
/** Browser-owned preview URL (lifecycle stays with the submitter). */
readonly url: string
readonly name?: string
/** Intrinsic pixel width, when the intake probe has resolved it. */
readonly width?: number
/** Intrinsic pixel height, when the intake probe has resolved it. */
readonly height?: number
}
}
/** Durable image loader with an optional synchronous cache read. */
export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise<string>) & {
peek?: (attachment: ImageAttachmentRef) => string | undefined
}
/** Message image group handed to the optional attachment presentation plugin. */
export interface MessageImagesOwnerProps {
/** Durable image references in source order. */
images: readonly { readonly attachment: ImageAttachmentRef }[]
/** Session-authorized image URL loader. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
/** Durable references or submission-echo previews in source order. */
images: readonly MessageImageSource[]
/** Session-authorized image URL loader for the durable arm. */
loadImage: MessageImageLoader
/** Horizontal placement inside the owning record. */
align: 'start' | 'end'
}
@@ -214,6 +214,29 @@ export class UiConversation extends Service {
return this.images.resolve(sessionId, attachment)
}
/**
* Read a cached durable image URL synchronously when one is available.
* @param sessionId - Session authorization and lifetime scope.
* @param attachment - Durable image reference from a session event.
* @returns current preview or canonical URL, if cached.
*/
peekImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined {
return this.images.peek(sessionId, attachment)
}
/**
* Adopt an already-displayable URL for one durable reference (see
* HistoricalImageCache.seed): the transcript node then renders it without a
* byte round-trip.
* @param sessionId - Session authorization and lifetime scope.
* @param attachment - Durable image reference the URL displays.
* @param url - browser URL to adopt.
* @returns whether the cache took URL ownership.
*/
seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
return this.images.seed(sessionId, attachment, url)
}
/**
* Canonicalize one `request/header` event against the previous prompt state.
*
@@ -8,7 +8,8 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
interface ImageUrlEntry {
readonly sessionId: SessionId
readonly generation: number
readonly pending: Promise<string>
current?: string
pending: Promise<string>
}
/** Resolve durable Conversation images and release their browser URLs with Session scope. */
@@ -35,7 +36,7 @@ export class HistoricalImageCache {
*/
resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed'))
const key = `${sessionId}:${attachment.attachmentId}`
const key = this.key(sessionId, attachment)
const cached = this.entries.get(key)
if (cached !== undefined) return cached.pending
const binding = this.sessions.binding(sessionId)
@@ -43,28 +44,105 @@ export class HistoricalImageCache {
return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`))
}
this.bindScope(sessionId, binding.ctx)
const generation = this.generations.get(sessionId) ?? 0
const pending = binding.session.readAttachment(attachment.attachmentId)
const entry: ImageUrlEntry = {
sessionId,
generation: this.generations.get(sessionId) ?? 0,
pending: Promise.resolve(''),
}
this.entries.set(key, entry)
entry.pending = this.loadCanonical(key, entry, attachment)
return entry.pending
}
/**
* Return an already-displayable URL without starting a read.
* @param sessionId - Session authorization and lifetime scope.
* @param attachment - Durable image reference.
* @returns current preview or canonical URL when cached.
*/
peek(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined {
return this.entries.get(this.key(sessionId, attachment))?.current
}
/**
* Adopt a submission preview while fetching the durable admitted bytes.
* The preview is available synchronously, then replaced and revoked when
* the canonical attachment read completes.
* @param sessionId - Session authorization and lifetime scope.
* @param attachment - Durable image reference the URL temporarily displays.
* @param url - browser URL to adopt.
* @returns whether the cache took ownership.
*/
seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
if (this.disposed) return false
const key = this.key(sessionId, attachment)
if (this.entries.has(key)) return false
const binding = this.sessions.binding(sessionId)
if (binding === undefined) return false
this.bindScope(sessionId, binding.ctx)
const entry: ImageUrlEntry = {
sessionId,
generation: this.generations.get(sessionId) ?? 0,
current: url,
pending: Promise.resolve(url),
}
this.urls.add(url)
this.entries.set(key, entry)
entry.pending = this.loadCanonical(key, entry, attachment).catch((error: unknown) => {
if (this.entries.get(key) === entry && entry.current === url) {
this.entries.delete(key)
this.releaseUrl(url)
}
throw error
})
// Seed begins the durable read before a transcript image necessarily
// mounts. Keep that legitimate no-consumer path from becoming an
// unhandled rejection; resolve() still returns the rejecting promise.
void entry.pending.catch(() => {})
return true
}
private key(sessionId: SessionId, attachment: ImageAttachmentRef): string {
return `${sessionId}:${attachment.attachmentId}`
}
private loadCanonical(
key: string,
entry: ImageUrlEntry,
attachment: ImageAttachmentRef,
): Promise<string> {
const binding = this.sessions.binding(entry.sessionId)
if (binding === undefined) return Promise.reject(new Error(`ui-conversation: unknown session "${entry.sessionId}"`))
return binding.session.readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
if ((this.generations.get(sessionId) ?? 0) !== generation) {
throw new Error('ui-conversation image scope was released before loading completed')
}
this.assertLive(key, entry)
let url: string
if (typeof URL.createObjectURL !== 'function') {
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
url = `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
} else {
const bytes = Uint8Array.from(result.value.data)
url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
}
const bytes = Uint8Array.from(result.value.data)
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
this.assertLive(key, entry)
this.urls.add(url)
const previous = entry.current
entry.current = url
if (previous !== undefined && previous !== url) this.releaseUrl(previous)
return url
})
.catch((error: unknown) => {
if (this.entries.get(key)?.generation === generation) this.entries.delete(key)
if (this.entries.get(key) === entry && entry.current === undefined) this.entries.delete(key)
throw error
})
this.entries.set(key, { sessionId, generation, pending })
return pending
}
private assertLive(key: string, entry: ImageUrlEntry): void {
if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
if (this.entries.get(key) !== entry
|| (this.generations.get(entry.sessionId) ?? 0) !== entry.generation) {
throw new Error('ui-conversation image scope was released before loading completed')
}
}
private bindScope(sessionId: SessionId, scope: Context): void {
@@ -81,15 +159,15 @@ export class HistoricalImageCache {
for (const [key, entry] of this.entries) {
if (entry.sessionId !== sessionId) continue
this.entries.delete(key)
void entry.pending.then((url) => {
if (!this.urls.delete(url)) return
revokeUrl(url)
}, () => {
// Failed and invalidated loads create no browser URL.
})
if (entry.current !== undefined) this.releaseUrl(entry.current)
}
}
private releaseUrl(url: string): void {
if (!this.urls.delete(url)) return
revokeUrl(url)
}
private dispose(): void {
if (this.disposed) return
this.disposed = true
@@ -53,7 +53,8 @@ export type {
ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews,
MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,
UseConversationViews,
} from './contract/slots.ts'
export type {
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
@@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment,
Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment,
SubmitOutcome, TokenSpan,
} from '../contract/input.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -113,6 +113,13 @@ const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
/** Editor and attachment snapshot owned by one detached default send. */
interface DetachedDraft {
readonly draft: string
readonly occurrences: readonly Occurrence[]
readonly imageIds: readonly DraftAttachmentId[]
}
/**
* The per-session input facade: scoped-event application verbs +
* setDraft/submit + the published InputState store, over a shell-owned
@@ -144,13 +151,24 @@ export class SessionInputShell implements SessionInput {
private noticeSeq = 0
private lastMirroredDraft = ''
private imageIds: readonly DraftAttachmentId[] = []
/** One image-only send at a time: Enter during the Host round-trip is a no-op. */
private imageSendInFlight = false
private disposed = false
/** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
private mirrorFn: ((text: string) => void) | undefined
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
/** Default sends retained until admission settles or scope disposal releases their images. */
private readonly detachedDrafts = new Map<number, DetachedDraft>()
/** Failed default sends waiting to be restored together in submission order. */
private readonly failedDetached = new Map<number, DetachedDraft>()
/** Revision of the last automatic failure restoration. */
private failedRestoreRev: number | undefined
private restoringFailures = false
private imageFlightSeq = 0
/** Image-only sends retained until admission settles or scope disposal releases their images. */
private readonly imageFlights = new Map<number, {
readonly controller: AbortController
readonly imageIds: readonly DraftAttachmentId[]
}>()
constructor(private readonly deps: SessionInputDeps) {
this.editor = createEditor({
@@ -218,6 +236,10 @@ export class SessionInputShell implements SessionInput {
// caret motion and subscribers do not re-render per caret move.
if (projectionContentChanged(prev, this.projection)) {
this.rev += 1
if (!this.restoringFailures && this.failedRestoreRev !== undefined) {
this.failedDetached.clear()
this.failedRestoreRev = undefined
}
this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText }))
}
const caret = this.projection.caret
@@ -337,17 +359,22 @@ export class SessionInputShell implements SessionInput {
*/
submit(mode: InputSubmitMode = 'queue'): void {
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) {
if (this.snapshot.phase === 'plain') {
const imageIds = [...this.imageIds]
this.imageSendInFlight = true
void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => {
this.imageSendInFlight = false
if (this.disposed) return
if (outcome.kind === 'success') this.commitSend(imageIds)
else if (outcome.text !== undefined) this.notify('error', outcome.text)
const controller = new AbortController()
this.imageFlightSeq += 1
const flight = this.imageFlightSeq
this.imageFlights.set(flight, { controller, imageIds })
this.commitSend(imageIds)
void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.imageFlights.delete(flight)) return
if (outcome.kind === 'success') return
this.restoreImages(imageIds)
if (outcome.text !== undefined) this.notify('error', outcome.text)
}, (error: unknown) => {
this.imageSendInFlight = false
if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error))
if (this.disposed || !this.imageFlights.delete(flight)) return
this.restoreImages(imageIds)
this.notify('error', error instanceof Error ? error.message : String(error))
})
}
return
@@ -534,12 +561,29 @@ export class SessionInputShell implements SessionInput {
// ---- wiring-layer extras (not on the frozen SessionInput face) ----
/** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */
dispose(): void {
/**
* Teardown the shell and return every browser-owned image still retained by
* the draft or an unsettled default send.
* @returns image ids the scope disposer must release.
*/
dispose(): readonly DraftAttachmentId[] {
if (this.disposed) return []
const retained = new Set(this.imageIds)
for (const record of this.detachedDrafts.values()) {
for (const imageId of record.imageIds) retained.add(imageId)
}
for (const flight of this.imageFlights.values()) {
for (const imageId of flight.imageIds) retained.add(imageId)
flight.controller.abort()
}
this.disposed = true
this.dispatchRun(({ type: 'release' }))
this.unregister()
this.editor.setRootElement(null)
this.detachedDrafts.clear()
this.failedDetached.clear()
this.imageFlights.clear()
return [...retained]
}
/** Read the live input state (guard derivation reads here). */
@@ -635,25 +679,34 @@ export class SessionInputShell implements SessionInput {
/**
* Prompt serialization before the sink: expand each chip occurrence to its
* owner's model form via the session controller's codec routing. Owner
* missing / serialize failure / disposal blocks the send notice + draft
* and chips retained, never a silent downgrade to the clipboard text.
* Chip-free drafts skip the async detour.
* missing or serialization failure rejects the detached send and restores
* its editor snapshot. Chip-free drafts skip the async detour.
*/
private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void {
private sinkSerialized(
attempt: SubmitAttempt,
draft: string,
mode: InputSubmitMode,
): void {
const imageIds = [...this.imageIds]
this.imageIds = []
const occurrences = this.projection.occurrences
const record = { draft, occurrences, imageIds }
this.detachedDrafts.set(attempt.seq, record)
if (this.failedRestoreRev === this.rev) {
this.failedDetached.clear()
this.failedRestoreRev = undefined
}
if (occurrences.length === 0) {
this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal))
return
}
const inputTriggers = this.deps.inputTriggers?.()
const controller = new AbortController()
void Promise.all(occurrences.map(async (o) => {
if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
return {
offset: o.offset,
length: o.length,
text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal),
text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal),
}
})).then(
(parts) => {
@@ -668,53 +721,116 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + part.length
}
out += draft.slice(cursor)
this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds)
this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal))
},
(error: unknown) => {
controller.abort()
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.dispatchRun(({
type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message,
}))
this.settleDetachedFailure(attempt, message)
},
)
}
/** Settle one admission attempt; successful sends consume only their captured images. */
private settleSubmit(
/** Settle one detached default send independently of other sends. */
private settleSink(
attempt: SubmitAttempt,
pending: Promise<SubmitOutcome>,
imageIds: readonly DraftAttachmentId[] = [],
): void {
pending.then(
(outcome) => {
if (this.dead(attempt)) return
if (outcome.kind === 'success' && imageIds.length > 0) {
const submitted = new Set(imageIds)
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
if (outcome.kind !== 'success') {
this.settleDetachedFailure(attempt, outcome.text)
return
}
this.dispatchRun(({
type: 'submit-settled',
attempt,
ok: outcome.kind === 'success',
draft: this.projection.clipboardText,
outcome,
}))
this.detachedDrafts.delete(attempt.seq)
this.dispatchRun(({ type: 'sink-settled', attempt, ok: true, outcome }))
},
(error: unknown) => {
if (this.dead(attempt)) return
this.dispatchRun(({
type: 'submit-settled',
attempt,
ok: false,
draft: this.projection.clipboardText,
message: error instanceof Error ? error.message : String(error),
}))
this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error))
},
)
}
/** Restore one failed detached send without overwriting text entered after a restoration. */
private settleDetachedFailure(attempt: SubmitAttempt, message?: string): void {
const record = this.detachedDrafts.get(attempt.seq)
if (record === undefined) return
this.detachedDrafts.delete(attempt.seq)
this.restoreImages(record.imageIds)
this.failedDetached.set(attempt.seq, record)
if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) {
this.restoreFailedDrafts()
}
this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, ...(message === undefined ? {} : { message }) }))
}
/** Rebuild all currently failed snapshots in submission order. */
private restoreFailedDrafts(): void {
const records = [...this.failedDetached.entries()].sort(([a], [b]) => a - b).map(([, record]) => record)
if (records.length === 0) return
const separator = '\n\n'
let draft = ''
const occurrences: Occurrence[] = []
for (const record of records) {
const base = draft.length + (draft === '' ? 0 : separator.length)
if (draft !== '') draft += separator
draft += record.draft
for (const occurrence of record.occurrences) {
occurrences.push({ ...occurrence, offset: base + occurrence.offset })
}
}
this.restoringFailures = true
try {
this.editor.update(() => {
const root = $getRoot()
root.clear()
let paragraph = $createParagraphNode()
root.append(paragraph)
const appendText = (text: string): void => {
const lines = text.split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (line !== '') paragraph.append($createTextNode(line))
if (i < lines.length - 1) {
paragraph = $createParagraphNode()
root.append(paragraph)
}
}
}
let cursor = 0
for (const occurrence of occurrences) {
appendText(draft.slice(cursor, occurrence.offset))
paragraph.append(new ReferenceChipNode({
source: occurrence.source,
ref: occurrence.ref,
label: occurrence.label,
...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
clipboardText: occurrence.clipboardText,
}, occurrence.invalid === true))
cursor = occurrence.offset + occurrence.length
}
appendText(draft.slice(cursor))
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
this.failedRestoreRev = this.rev
} finally {
this.restoringFailures = false
}
}
/** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */
private restoreImages(imageIds: readonly DraftAttachmentId[]): void {
if (imageIds.length === 0) return
const current = new Set(this.imageIds)
const restored = imageIds.filter(id => !current.has(id))
if (restored.length === 0) return
this.imageIds = [...restored, ...this.imageIds]
this.publish()
}
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
private adjudicate(attempt: SubmitAttempt, draft: string): void {
const inputTriggers = this.deps.inputTriggers?.()
@@ -121,8 +121,7 @@ export class InputHub implements SessionInputResolver {
]
return () => {
for (const off of offs) off()
const drafts = shell.snapshot.imageIds
shell.dispose()
const drafts = shell.dispose()
this.shells.delete(id)
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
@@ -1,13 +1,11 @@
/**
* SubmitMachine: the pure per-session submit-plane state machine.
* Events in, effects out; zero React / DOM / cordis. Package-private the
* SessionInput shell is the only caller and the sole executor of the
* returned effects.
* Events in, effects out; zero React / DOM / cordis. Package-private; the
* SessionInput shell owns editor state and executes the returned effects.
*
* The machine owns phase, claim, and the in-flight SubmitAttempt; it never
* holds the draft. Text truth lives in the shell's Lexical editor, and every
* decision that needs the draft reads it from the event payload (claim
* integrity watch, enter snapshots, settlement suffix/re-entry decisions).
* Claimed commands occupy the frozen in-flight slot. Ordinary messages detach
* at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight.
*/
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
@@ -17,13 +15,7 @@ function unreachable(value: never): never {
throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
}
/**
* Strip the claim token off a draft to yield submit args. Leading whitespace
* (incl. newlines leading-trigger trim) is tolerated; a bare `/name`
* missing the token's trailing separator yields empty args. Exactly one
* separator char is consumed; the remainder newlines included stays
* verbatim (`/goal x\ny` `x\ny`).
*/
/** Strip a claimed command token from its submit-time draft. */
function argsAfter(draft: string, token: string): string {
const s = draft.trimStart()
if (s.startsWith(token)) return s.slice(token.length)
@@ -41,14 +33,7 @@ export interface SubmitSnapshot {
readonly claim?: InputState['claim']
}
/**
* Pure submit machine, one instance per session (per-session isolation is by
* construction). The machine constructs one AbortController per SubmitAttempt
* at enter time and aborts it itself on release; the shell never aborts, it
* only observes attempt.signal on its adjudicate/submit promises. Stale
* attempts (any adjudicated / adjudication-failed / submit-settled whose seq
* is not the in-flight one) are dropped: same state, zero effects.
*/
/** Pure phase, claim, and attempt owner for one Session input. */
export class SubmitMachine {
private phase: InputState['phase'] = 'plain'
private claim: CommandClaim | undefined
@@ -57,6 +42,8 @@ export class SubmitMachine {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} | undefined
/** Ordinary sends detached from the editor, retained for settlement validation and cancellation. */
private readonly detached = new Map<number, AbortController>()
/** Read-only snapshot of the submit-plane state. */
get state(): SubmitSnapshot {
@@ -77,8 +64,8 @@ export class SubmitMachine {
/**
* Feed one event through the machine.
* @param ev - Input event; the single write path for all submit-plane state.
* @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events.
* @param ev - submit-plane event.
* @returns effects for the SessionInput shell, in execution order.
*/
dispatch(ev: InputEvent): readonly InputEffect[] {
switch (ev.type) {
@@ -88,14 +75,15 @@ export class SubmitMachine {
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
case 'sink-settled': return this.onSinkSettled(ev)
case 'send-committed': return this.onSendCommitted()
case 'release': return this.onRelease()
default: return unreachable(ev)
}
}
/** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */
private onDraftChanged(draft: string): InputEffect[] {
/** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */
private onDraftChanged(draft: string): readonly InputEffect[] {
if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) {
this.phase = 'plain'
this.claim = undefined
@@ -103,26 +91,52 @@ export class SubmitMachine {
return []
}
/** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */
private onClaim(claim: CommandClaim): InputEffect[] {
/** The editor applied a claim-token replacement; busy phases refuse another claim. */
private onClaim(claim: CommandClaim): readonly InputEffect[] {
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
this.claim = claim
this.phase = 'claimed'
return []
}
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
/** Mint an attempt and controller without assigning its lifecycle owner. */
private mintAttempt(mode: InputSubmitMode, draft: string): {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode }
this.inflight = { attempt, controller }
return attempt
return {
attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode },
controller,
}
}
private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] {
/** Mint the frozen command/adjudication attempt. */
private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
const flight = this.mintAttempt(mode, draft)
this.inflight = flight
return flight.attempt
}
/** Mint an ordinary send that leaves the phase plain. */
private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt {
const flight = this.mintAttempt(mode, draft)
this.detached.set(flight.attempt.seq, flight.controller)
this.claim = undefined
this.phase = 'plain'
return flight.attempt
}
/** Default-send effects capture the sink input before the editor commit. */
private detachedEffects(attempt: SubmitAttempt): readonly InputEffect[] {
return [
{ type: 'default-sink', attempt, draft: attempt.draftSnapshot, mode: attempt.mode },
{ type: 'commit-draft', retainSuffixOf: attempt.draftSnapshot },
]
}
private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode, draft)
@@ -136,12 +150,13 @@ export class SubmitMachine {
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }]
}
const attempt = this.beginAttempt(mode, draft)
this.phase = 'submitting'
return [{ type: 'default-sink', attempt, draft, mode }]
return this.detachedEffects(this.beginDetached(mode, draft))
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
private onAdjudicated(
attempt: SubmitAttempt,
outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome'],
): readonly InputEffect[] {
const flight = this.inflight
if (this.phase !== 'adjudicating' || flight === undefined || flight.attempt.seq !== attempt.seq) return []
if (outcome !== undefined && outcome !== 'handled' && 'claim' in outcome) {
@@ -154,31 +169,22 @@ export class SubmitMachine {
args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
}]
}
// 'handled' (source dealt internally), {insert}/{text} (no enter-time span
// semantics), or a miss: all land plain; only the miss flows to the sink.
if (outcome === undefined) {
this.phase = 'submitting'
return [{
type: 'default-sink',
attempt,
draft: attempt.draftSnapshot,
mode: attempt.mode,
}]
}
this.inflight = undefined
this.phase = 'plain'
return []
if (outcome !== undefined) return []
this.detached.set(attempt.seq, flight.controller)
return this.detachedEffects(attempt)
}
private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] {
private onAdjudicationFailed(attempt: SubmitAttempt, message: string): readonly InputEffect[] {
if (this.phase !== 'adjudicating' || this.inflight?.attempt.seq !== attempt.seq) return []
this.inflight = undefined
this.phase = 'plain'
// Draft retained: warmup failure never silently downgrades to a prompt.
return [{ type: 'notice', level: 'error', text: message }]
}
private onSubmitSettled(ev: Extract<InputEvent, { type: 'submit-settled' }>): InputEffect[] {
/** Claimed command settlement retains the frozen transaction semantics. */
private onSubmitSettled(ev: Extract<InputEvent, { type: 'submit-settled' }>): readonly InputEffect[] {
const flight = this.inflight
if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
this.inflight = undefined
@@ -192,10 +198,6 @@ export class SubmitMachine {
return effects
}
const text = ev.message ?? ev.outcome?.text
// Keep the same command claim only while the live draft still equals the
// enter-time draft; user input typed during flight wins.
// Claimed re-entry additionally requires the watch to hold — an
// enter-path snapshot may carry leading whitespace the token never had.
if (ev.draft === flight.attempt.draftSnapshot
&& this.claim !== undefined && ev.draft.startsWith(this.claim.token)) {
this.phase = 'claimed'
@@ -206,18 +208,28 @@ export class SubmitMachine {
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
}
/** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */
private onSendCommitted(): InputEffect[] {
/** Settle one ordinary send independently of current phase and other detached sends. */
private onSinkSettled(ev: Extract<InputEvent, { type: 'sink-settled' }>): readonly InputEffect[] {
if (!this.detached.delete(ev.attempt.seq)) return []
const text = ev.message ?? ev.outcome?.text
if (text === undefined) return []
return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }]
}
/** Clear after an accepted image-only send; it has no text suffix to retain. */
private onSendCommitted(): readonly InputEffect[] {
if (this.phase !== 'plain') return []
this.claim = undefined
return [{ type: 'commit-draft', retainSuffixOf: null }]
}
private onRelease(): InputEffect[] {
private onRelease(): readonly InputEffect[] {
if (this.inflight !== undefined) {
this.inflight.controller.abort()
this.inflight = undefined
}
for (const controller of this.detached.values()) controller.abort()
this.detached.clear()
this.phase = 'plain'
this.claim = undefined
return []
@@ -9,11 +9,13 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ISessions, PendingSubmissionRetirement, SessionFace,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ComposerAttachment } from './contract/slots.ts'
@@ -72,6 +74,62 @@ function browserDraftAttachment(file: File): ComposerAttachment {
}
}
/**
* Fill the draft's intrinsic dimensions once the browser parses the image
* header (a metadata read off the preview URL, not a full decode). Failures
* and non-browser runtimes leave them absent consumers size those images
* from CSS constraints instead. The descriptors stay registry-owned; submit
* reads the dimensions into an immutable echo snapshot, so this late write
* does not require a store notification.
*/
function probeDimensions(attachment: ComposerAttachment): void {
if (typeof Image !== 'function') return
const probe = new Image()
probe.onload = () => {
attachment.width = probe.naturalWidth
attachment.height = probe.naturalHeight
}
probe.src = attachment.previewUrl
}
/** Give the echo one paint opportunity without letting a throttled frame clock block admission. */
function nextPaint(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === 'function') {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
setTimeout(resolve, 0)
return
}
let settled = false
const finish = () => {
if (settled) return
settled = true
clearTimeout(fallback)
setTimeout(resolve, 0)
}
const fallback = setTimeout(finish, 100)
requestAnimationFrame(finish)
} else {
setTimeout(resolve, 0)
}
})
}
/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */
function base64Of(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const url = reader.result as string
resolve(url.slice(url.indexOf(',') + 1))
}
reader.onerror = () => {
reject(reader.error ?? new Error('conversation: image read failed'))
}
reader.readAsDataURL(file)
})
}
/** Unsupported browser-declared image type, localized by the UI boundary. */
export class UnsupportedImageMediaTypeError extends Error {
/** Browser-declared MIME value, possibly empty. */
@@ -125,7 +183,12 @@ export class ConversationController extends Service implements IConversation {
}
/**
* Submit ordered draft images with text through one host admission.
* Submit ordered draft images with text through one host admission. A local
* submission echo enters the session snapshot synchronously; serialization
* and the prompt round-trip start after the browser can paint it. On the
* echo's observed retirement the draft images hand their preview URLs to
* the durable image cache and leave the registry; on failure they stay
* registered so the composer can restore them.
* @param session - target session.
* @param text - serialized prompt text.
* @param imageIds - ordered draft-local attachment ids.
@@ -144,11 +207,41 @@ export class ConversationController extends Service implements IConversation {
if (attachments.length !== imageIds.length) {
throw new Error('conversation.sendSession: one or more draft images are no longer available')
}
const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode, signal)
if (session.getSnapshot().subagent !== null) {
const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode, signal)
return result.ok ? { kind: 'success' } : { kind: 'error' }
}
let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined
const retirement = attachments.length === 0
? undefined
: new Promise<PendingSubmissionRetirement>((resolve) => { finishRetirement = resolve })
const submission = session.beginSubmission({
text,
images: attachments.map(attachment => ({
previewUrl: attachment.previewUrl,
...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
...(attachment.width === undefined ? {} : { width: attachment.width }),
...(attachment.height === undefined ? {} : { height: attachment.height }),
})),
onRetire: (settlement) => {
this.settleSubmittedImages(session.sessionId, attachments, settlement)
finishRetirement?.(settlement)
},
})
let content: Parameters<SessionFace['prompt']>[0]
try {
await nextPaint()
const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
} catch (error) {
submission.abandon()
throw error
}
const result = await session.prompt(content, mode, signal, submission.requestId)
if (!result.ok) return { kind: 'error' }
this.releaseDraftImages(attachments)
if (retirement !== undefined && (await retirement).reason !== 'observed') return { kind: 'error' }
return { kind: 'success' }
}
@@ -162,6 +255,7 @@ export class ConversationController extends Service implements IConversation {
return files.map((file) => {
const attachment = browserDraftAttachment(file)
this.draftAttachments.set(attachment.id, attachment)
probeDimensions(attachment)
return attachment
})
}
@@ -264,6 +358,31 @@ export class ConversationController extends Service implements IConversation {
return sessions
}
/**
* Settle one submission's draft images when its echo retires. Observed:
* each image leaves the registry, handing its preview URL to the durable
* image cache (seeded under the admitted reference so the transcript node
* renders immediately while the cache reads canonical bytes) or revoking it
* when the cache already holds that reference. Failed: nothing changes;
* the ids stay registered for the composer's rail restore.
*/
private settleSubmittedImages(
sessionId: SessionId,
attachments: readonly ComposerAttachment[],
retirement: PendingSubmissionRetirement,
): void {
if (retirement.reason !== 'observed') return
const uiConversation = this.ctx.get('uiConversation')
attachments.forEach((attachment, index) => {
const live = this.draftAttachments.get(attachment.id)
if (live === undefined) return
this.draftAttachments.delete(attachment.id)
const ref = retirement.attachments[index]
if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return
revokePreview(attachment.previewUrl)
})
}
/** Convert browser files to canonical base64 prompt parts. */
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) })))
@@ -273,7 +392,7 @@ export class ConversationController extends Service implements IConversation {
private async encodeImage(file: File): Promise<SubmitImageAttachment> {
return {
mediaType: imageMediaType(file.type),
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
data: await base64Of(file),
...(file.name === '' ? {} : { name: file.name }),
}
}
@@ -102,10 +102,14 @@ describe('Conversation inject API', () => {
actions.setDraft('hello')
actions.submit()
await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
expect(b.sessionFake.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
)
// Optimistic commit clears the draft at enter; the prompt lands after the
// paint-yield inside the send pipeline.
expect(state.getSnapshot().draft).toBe('')
await vi.waitFor(() => {
expect(b.sessionFake.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String),
)
})
b.sessionFake.prompt.mockResolvedValueOnce({
ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },
@@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot {
return {
sessionId: SESSION_ID,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -43,6 +44,7 @@ function fakeSession(): SessionFace {
projections: { faceOf: () => createSnapshotStore<unknown>(undefined) },
getSnapshot: () => snapshot.getSnapshot(),
subscribe: listener => snapshot.subscribe(listener),
beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }),
prompt: () => Promise.reject(new Error('unused fake Session operation')),
readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -25,4 +25,75 @@ describe('HistoricalImageCache', () => {
await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
await runtime.dispose()
})
it('shows a seeded URL synchronously, replaces it with canonical bytes, and revokes both', async () => {
const revoked: string[] = []
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:canonical')
const originalRevoke = URL.revokeObjectURL.bind(URL)
URL.revokeObjectURL = (url: string) => { revoked.push(url) }
try {
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
const runtime = await SlotTestRuntime.create()
const sessionId = await runtime.sessions.add({ id: 's1', session: { readAttachment: () => read.promise } })
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true)
expect(cache.peek(sessionId, attachment)).toBe('blob:seeded')
expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false)
const canonical = cache.resolve(sessionId, attachment)
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
await expect(canonical).resolves.toBe('blob:canonical')
expect(cache.peek(sessionId, attachment)).toBe('blob:canonical')
expect(revoked).toContain('blob:seeded')
await runtime.sessions.remove(sessionId)
await Promise.resolve()
expect(revoked).toContain('blob:canonical')
await runtime.dispose()
} finally {
created.mockRestore()
URL.revokeObjectURL = originalRevoke
}
})
it('revokes a seeded preview when canonical bytes cannot be read', async () => {
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const runtime = await SlotTestRuntime.create()
const sessionId = await runtime.sessions.add({
id: 's1',
session: {
readAttachment: () => Promise.resolve({
ok: false,
error: { code: 'attachment-error', message: 'missing', details: {} },
} as never),
},
})
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true)
await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-error: missing')
expect(cache.peek(sessionId, attachment)).toBeUndefined()
expect(revoked).toHaveBeenCalledWith('blob:seeded')
await runtime.dispose()
} finally {
revoked.mockRestore()
}
})
it('refuses to seed for an unknown session', async () => {
const runtime = await SlotTestRuntime.create()
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false)
await runtime.dispose()
})
})
@@ -376,11 +376,28 @@ describe('image draft rail', () => {
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
// Optimistic commit: the rail clears at submit, before the admission settles.
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
await act(async () => { settle({ kind: 'success' }) })
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
})
it('returns an image-only draft to the rail when its admission fails', async () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachments = [
{ kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
]
const result = bench({ attachments })
const { textarea, sink } = result
let fail!: (outcome: SubmitOutcome) => void
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { fail = resolve }))
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) })
await vi.waitFor(() => {
expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
})
expect(result.view.getByRole('alert').textContent).toContain('图片发送失败')
})
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
@@ -121,8 +121,9 @@ describe('matrix row: plain', () => {
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot.phase).toBe('submitting')
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
// The detached default send never freezes the composer.
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe('')
expect(shell.snapshot.claim).toBeUndefined()
})
})
@@ -99,9 +99,12 @@ describe('reference submission', () => {
})
shell.submit('queue')
expect(shell.snapshot.phase).toBe('submitting')
// Optimistic commit: the composer clears at enter and stays unlocked
// while the detached flight runs.
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe('')
await vi.waitFor(() => {
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe(`${mention} `)
})
expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot).toMatchObject({
@@ -114,10 +117,10 @@ describe('reference submission', () => {
})
shell.submit('queue')
expect(shell.snapshot.draft).toBe('')
await vi.waitFor(() => {
expect(shell.snapshot.draft).toBe('')
expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
})
expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot.occurrences).toEqual([])
expect(serializeReference).toHaveBeenCalledTimes(2)
})
@@ -137,11 +140,11 @@ describe('reference submission', () => {
})
chip(shell)
shell.submit()
// The serializer rejection restores the optimistic commit with its chip.
await vi.waitFor(() => {
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe(`${mention} `)
})
expect(sink).not.toHaveBeenCalled()
expect(shell.snapshot.draft).toBe(`${mention} `)
expect(shell.snapshot.occurrences).toHaveLength(1)
expect(shell.notices.getSnapshot()).toMatchObject({
level: 'error',
@@ -165,7 +168,9 @@ describe('reference submission', () => {
shell.dispose()
expect(signal?.aborted).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.draft).toBe('send this')
// The optimistic commit stands: disposal drops the settlement, so the
// sent draft is not restored into the dying composer.
expect(shell.snapshot.draft).toBe('')
})
it('retains a rejected default message without duplicating its prompt error notice', async () => {
@@ -182,6 +187,25 @@ describe('reference submission', () => {
expect(shell.snapshot.draft).toBe('retry this')
expect(shell.notices.getSnapshot()).toBeNull()
})
it('restores concurrent failed messages in submission order', async () => {
const settlements: Array<(outcome: SubmitOutcome) => void> = []
const shell = new SessionInputShell({
actx: {} as Context,
defaultSink: () => new Promise<SubmitOutcome>((resolve) => { settlements.push(resolve) }),
commandImages,
})
shell.setDraft('first')
shell.submit()
shell.setDraft('second')
shell.submit()
expect(shell.snapshot.draft).toBe('')
settlements[0]?.({ kind: 'error' })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first') })
settlements[1]?.({ kind: 'error' })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first\n\nsecond') })
})
})
describe('submit transaction hardening', () => {
@@ -223,6 +247,24 @@ describe('submit transaction hardening', () => {
expect(shell.notices.getSnapshot()).toBeNull()
})
it('aborts an unsettled image-only send and returns its image id at disposal', () => {
let signal: AbortSignal | undefined
const imageId = 'img-flight' as DraftAttachmentId
const shell = new SessionInputShell({
actx: {} as Context,
defaultSink: (_text, _ids, _mode, received) => {
signal = received
return new Promise<SubmitOutcome>(() => {})
},
commandImages,
})
shell.addImages([imageId])
shell.submit()
expect(signal?.aborted).toBe(false)
expect(shell.dispose()).toEqual([imageId])
expect(signal?.aborted).toBe(true)
})
it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => {
const track = vi.fn()
const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} }
@@ -39,6 +39,7 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
return {
sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
pendingSubmissions: [],
lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
}
}
@@ -102,6 +102,28 @@ describe('ConversationController', () => {
await b.runtime.dispose()
})
it('releases an image removed from the rail by an unsettled optimistic send', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [attachment] = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }),
])
if (attachment === undefined) throw new Error('draft attachment missing')
b.shell.addImages([attachment.id])
b.shell.submit()
expect(b.shell.snapshot.imageIds).toEqual([])
await b.runtime.sessions.remove('s1')
expect(b.root.draftImages([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:detached')
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('validates every MIME type before allocating previews', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
@@ -131,6 +153,220 @@ describe('ConversationController', () => {
})
})
describe('sendSession submission echo', () => {
/** Bench with an observable beginSubmission on the session face. */
async function echoBench() {
const b = await bench()
const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {}
const abandon = vi.fn()
const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => {
retire.onRetire = input.onRetire
return { requestId: 'req-echo' as never, abandon }
})
await b.runtime.sessions.updateSessionSnapshot('s1', () => {})
const face = b.runtime.sessions.binding('s1')!.session as unknown as Record<string, unknown>
face['beginSubmission'] = beginSubmission
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
const restore = () => {
created.mockRestore()
revoked.mockRestore()
}
return { ...b, beginSubmission, abandon, retire, revoked, restore }
}
it('registers the echo before serialization and prompts with its identity', async () => {
const b = await echoBench()
try {
const [attachment] = b.root.createDraftImages([
new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }),
])
const session = b.runtime.sessions.binding('s1')!.session
const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue')
// Synchronous: the echo is registered before any encoding starts.
expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
text: '带图',
images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })],
}))
expect(b.prompt).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
expect(b.prompt).toHaveBeenCalledWith(
[
{ type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' },
{ type: 'text', text: '带图' },
],
'queue',
undefined,
'req-echo',
)
// The draft stays registered until the echo's observed retirement.
expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
b.retire.onRetire?.({ reason: 'observed', attachments: [] })
await expect(sending).resolves.toEqual({ kind: 'success' })
expect(b.root.draftImages([attachment!.id])).toEqual([])
expect(b.revoked).toHaveBeenCalledWith('blob:echo-1')
} finally {
b.restore()
}
await b.runtime.dispose()
})
it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => {
const b = await echoBench()
try {
const seedImageUrl = vi.fn(() => true)
b.runtime.ctx.provide('uiConversation')
b.runtime.ctx.set('uiConversation', { seedImageUrl })
const [attachment] = b.root.createDraftImages([
new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }),
])
const session = b.runtime.sessions.binding('s1')!.session
const sending = b.root.sendSession(session, '', [attachment!.id], 'queue')
await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
const ref = { attachmentId: 'att-1' }
b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
await expect(sending).resolves.toEqual({ kind: 'success' })
expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1')
expect(b.root.draftImages([attachment!.id])).toEqual([])
expect(b.revoked).not.toHaveBeenCalled()
// Failed retirement keeps nothing to do; a second retire of released ids is a no-op.
b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
} finally {
b.restore()
}
await b.runtime.dispose()
})
it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => {
const b = await echoBench()
try {
b.prompt.mockResolvedValueOnce({
ok: false, error: { code: 'attachment-error', message: 'nope', details: {} },
} as never)
const [attachment] = b.root.createDraftImages([
new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }),
])
const session = b.runtime.sessions.binding('s1')!.session
await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue'))
.resolves.toEqual({ kind: 'error' })
b.retire.onRetire?.({ reason: 'failed' })
expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
expect(b.revoked).not.toHaveBeenCalled()
} finally {
b.restore()
}
await b.runtime.dispose()
})
it('abandons the echo when encoding fails before the prompt', async () => {
const b = await echoBench()
class FailingReader {
onload: (() => void) | null = null
onerror: (() => void) | null = null
error = new Error('read failed')
readAsDataURL(): void {
queueMicrotask(() => this.onerror?.())
}
}
vi.stubGlobal('FileReader', FailingReader)
try {
const [attachment] = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }),
])
const session = b.runtime.sessions.binding('s1')!.session
await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue'))
.rejects.toThrow('read failed')
expect(b.abandon).toHaveBeenCalledOnce()
expect(b.prompt).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
b.restore()
}
await b.runtime.dispose()
})
it('yields through the macrotask fallback where no frame clock exists', async () => {
const b = await echoBench()
vi.stubGlobal('requestAnimationFrame', undefined)
try {
const session = b.runtime.sessions.binding('s1')!.session
await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' })
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo')
} finally {
vi.unstubAllGlobals()
b.restore()
}
await b.runtime.dispose()
})
it('bounds the paint yield when the frame clock is throttled', async () => {
const b = await echoBench()
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1))
try {
const session = b.runtime.sessions.binding('s1')!.session
const sending = b.root.sendSession(session, '后台标签', [], 'queue')
expect(b.prompt).not.toHaveBeenCalled()
await expect(sending).resolves.toEqual({ kind: 'success' })
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '后台标签' }], 'queue', undefined, 'req-echo')
} finally {
vi.unstubAllGlobals()
b.restore()
}
await b.runtime.dispose()
})
it('sends a subagent continuation without registering an unobservable echo', async () => {
const b = await bench()
const session = b.runtime.sessions.binding('s1')!.session
const snapshot = session.getSnapshot()
const beginSubmission = vi.spyOn(session, 'beginSubmission')
vi.spyOn(session, 'getSnapshot').mockReturnValue({
...snapshot,
subagent: {
address: { parentSessionId: 'parent', childSessionId: 'child', mode: 'continuable' } as never,
},
})
const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } })
await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' })
expect(beginSubmission).not.toHaveBeenCalled()
expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined)
await b.runtime.dispose()
})
})
describe('draft image dimension probe', () => {
it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe')
class InstantImage {
onload: (() => void) | null = null
naturalWidth = 0
naturalHeight = 0
set src(_value: string) {
this.naturalWidth = 640
this.naturalHeight = 480
this.onload?.()
}
}
vi.stubGlobal('Image', InstantImage)
try {
const [probed] = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }),
])
expect(probed).toMatchObject({ width: 640, height: 480 })
vi.stubGlobal('Image', undefined)
const [unprobed] = b.root.createDraftImages([
new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }),
])
expect(unprobed?.width).toBeUndefined()
} finally {
vi.unstubAllGlobals()
created.mockRestore()
}
await b.runtime.dispose()
})
})
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,
@@ -60,7 +60,8 @@ describe('submit-machine: plain × enter', () => {
expect(sink.draft).toBe('hello')
expect(sink.mode).toBe('queue')
expect(sink.attempt.draftSnapshot).toBe('hello')
expect(m.state.phase).toBe('submitting')
expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('hello')
expect(m.state.phase).toBe('plain')
})
it('retains an explicit steer mode on the default sink effect', () => {
@@ -122,7 +123,8 @@ describe('submit-machine: adjudication outcomes', () => {
const sink = effectAt(fx, 0, 'default-sink')
expect(sink.draft).toBe('/unknown thing')
expect(sink.mode).toBe('steer')
expect(m.state.phase).toBe('submitting')
expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('/unknown thing')
expect(m.state.phase).toBe('plain')
})
it("'handled' lands plain with zero effects (popup shell path)", () => {
@@ -315,7 +317,7 @@ describe('submit-machine: per-session isolation', () => {
expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' })
expect(a.state.phase).toBe('plain')
expect(b.state.phase).toBe('submitting')
expect(b.state.phase).toBe('plain')
})
})
@@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
const snapshot = createSnapshotStore<SessionSnapshot>({
sessionId: id,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -1,9 +1,8 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages,
AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, RenderMessageImages,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -70,7 +69,7 @@ export interface TrajectoryViewInjected {
duration: SnapshotStore<boolean>
}
loadOlder: () => Promise<boolean>
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
loadImage: MessageImageLoader
setActualDuration: (actualDuration: boolean) => void
}
@@ -3,6 +3,7 @@
* slot without defining a service.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -95,7 +96,10 @@ export function apply(ctx: Context): void {
await session.loadOlder()
return trajectory.getSnapshot() !== before
},
loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
loadImage: Object.assign(
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
{ peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
),
setActualDuration: (value) => { duration.set(value) },
}
},
@@ -13,7 +13,10 @@ import { t, tZh } from './locale.client.ts'
const renderImagesStub: RenderMessageImages = ({ images }) => (
<div data-testid="record-images" data-count={images.length}>
{images.map((image, index) => (
<span key={index} data-attachment-id={image.attachment.attachmentId} />
<span
key={index}
data-attachment-id={'attachment' in image ? image.attachment.attachmentId : image.preview.url}
/>
))}
</div>
)
@@ -111,6 +111,7 @@ function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapsh
return {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -28,6 +28,7 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
const sessionState: SessionState = {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -27,6 +27,7 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
const sessionState: SessionState = {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -442,6 +442,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'BakedActions',
declaration: 'export type BakedActions<T, A extends ActionsDecl<T>> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
},
{
name: 'BeginSubmissionInput',
declaration: 'export interface BeginSubmissionInput {\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}',
},
{
name: 'BoundActions',
declaration: 'export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never;',
@@ -548,7 +552,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise<ClientResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<ClientResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{\n accepted: true;\n }>>;\n cancel(): Promise<ClientResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<ClientResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<ClientResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<ClientResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{\n accepted: true;\n }>>;\n cancel(): Promise<ClientResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<ClientResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'KeyPropsOf',
@@ -602,6 +606,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OwnerOf',
declaration: 'export type OwnerOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n owner: infer O extends object;\n} ? O : object;',
},
{
name: 'PendingSubmission',
declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}',
},
{
name: 'PendingSubmissionImage',
declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}',
},
{
name: 'PendingSubmissionRetirement',
declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};',
},
{
name: 'ProjectionsFace',
declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot<unknown>;\n}',
@@ -702,13 +718,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionProviderComponent',
declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;',
},
{
name: 'SessionRequestId',
declaration: 'export type SessionRequestId = Branded<\'session-request-id\'>;',
},
{
name: 'SessionSearchResultItem',
declaration: 'export interface SessionSearchResultItem {\n sessionId: SessionId;\n snippet: string;\n}',
},
{
name: 'SessionSnapshot',
declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}',
declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}',
},
{
name: 'SessionStandardProps',
@@ -782,6 +802,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'StoreSpec',
declaration: 'export interface StoreSpec<T, A extends ActionsDecl<T>> {\n init: () => T;\n persist?: string;\n actions: A;\n}',
},
{
name: 'SubmissionHandle',
declaration: 'export interface SubmissionHandle {\n readonly requestId: SessionRequestId;\n abandon(): void;\n}',
},
{
name: 'ThemeDefinition',
declaration: 'export interface ThemeDefinition {\n id: string;\n colorScheme: \'light\' | \'dark\';\n tokens: ThemeTokens;\n}',
@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:186',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:185',
},
{
key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:174',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:173',
},
{
key: 'conversation.chat.node',
@@ -311,7 +311,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:155',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:154',
},
{
key: 'conversation.chat.turnTail',
@@ -356,7 +356,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:180',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:179',
},
{
key: 'conversation.composer',
@@ -405,7 +405,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119',
},
{
key: 'conversation.composer.bar',
@@ -441,7 +441,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137',
},
{
key: 'conversation.composer.dock',
@@ -499,7 +499,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
},
{
key: 'conversation.details.tool',
@@ -535,7 +535,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:192',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
},
{
key: 'conversation.hero.agentPreset',
@@ -563,7 +563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
},
{
key: 'conversation.hero.brand.mark',
@@ -591,7 +591,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
},
{
key: 'conversation.hero.workspace',
@@ -621,7 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -687,7 +687,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
},
{
key: 'conversation.input.dock',
@@ -747,7 +747,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
},
{
key: 'conversation.input.left',
@@ -803,7 +803,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
},
{
key: 'conversation.input.model',
@@ -839,7 +839,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147',
},
{
key: 'conversation.input.overlay',
@@ -893,7 +893,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129',
},
{
key: 'conversation.input.plan',
@@ -929,7 +929,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145',
},
{
key: 'conversation.input.right',
@@ -985,7 +985,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135',
},
{
key: 'conversation.message.images',
@@ -995,10 +995,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.',
registerOptions: [],
ownerProps: [
'/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}',
'/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}',
],
ownerPropsReferences: [
'ImageAttachmentRef',
'Message',
'MessageImageLoader',
'MessageImageSource',
],
standardProps: [
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
@@ -1023,7 +1025,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:168',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:167',
},
{
key: 'conversation.session',
@@ -1057,7 +1059,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95',
},
{
key: 'conversation.session.header',
@@ -1091,7 +1093,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97',
},
{
key: 'conversation.session.header.actions',
@@ -1148,7 +1150,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105',
},
{
key: 'conversation.session.header.lineage',
@@ -1186,7 +1188,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99',
},
{
key: 'conversation.session.header.utilities',
@@ -1241,7 +1243,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111',
},
{
key: 'conversation.trajectory.images',
@@ -1251,10 +1253,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.',
registerOptions: [],
ownerProps: [
'/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}',
'/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}',
],
ownerPropsReferences: [
'ImageAttachmentRef',
'Message',
'MessageImageLoader',
'MessageImageSource',
],
standardProps: [
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
@@ -1337,7 +1341,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117',
},
{
key: 'details',
@@ -4864,7 +4864,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionQueuedItem',
declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly message: {\n readonly id: MessageId;\n readonly content: readonly JsonValue[];\n };\n}',
declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly rpcId?: SessionRequestId;\n readonly message: {\n readonly id: MessageId;\n readonly content: readonly JsonValue[];\n };\n}',
},
{
name: 'SessionRawArtifact',
@@ -66,6 +66,7 @@ export function sessionSnapshot(sessionId: SessionId): SessionSnapshot {
return {
sessionId,
queue: [],
pendingSubmissions: [],
running: false,
subagent: null,
removed: false,
@@ -7,8 +7,9 @@ import {
import type {
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
SessionSnapshot, SessionSummary,
SessionSnapshot, SessionSummary, SubmissionHandle,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -94,6 +95,22 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Minimal local-echo registration: mints an identity without touching the
* fixture snapshot (submission echoes are client-only presentation state).
* Supply `beginSubmission` on the fixture's session face to observe echoes.
* @returns a handle whose abandon is a no-op.
*/
beginSubmission(): SubmissionHandle {
this.submissionSeq += 1
return {
requestId: `test-submission-${this.submissionSeq}` as SessionRequestId,
abandon: () => {},
}
}
private submissionSeq = 0
/**
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
* @param _attachmentId - opaque durable attachment id.
@@ -433,6 +433,9 @@ describe('fixture session face', () => {
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
const submission = bare.beginSubmission()
expect(submission.requestId).toBe('test-submission-1')
expect(() => { submission.abandon() }).not.toThrow()
await runtime.dispose()
})
@@ -0,0 +1,3 @@
echo: Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.
composer: ""
contenteditable: true