Просмотр исходного кода

fix(web): render command errors as banners

creatixchu 1 месяц назад
Родитель
Сommit
761d9d1978

+ 2 - 2
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml

@@ -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 .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
-2026-08-17-command-image-attachment-envelope.md: d811a37284b944949482a94842287122219d2314
-2026-08-17-command-image-attachment-envelope.zh.md: 38d5cecfbb59949e8c9f86f937ed7a7669dcdb6c
+2026-08-17-command-image-attachment-envelope.md: f651658ef0b655d6d190d25f54c2afaa088ad7be
+2026-08-17-command-image-attachment-envelope.zh.md: dcf2f72feb6dc4c1a158acd6e66924f3c46f1b3e

+ 4 - 1
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md

@@ -16,11 +16,13 @@ The submission envelope is modeled end to end, and every command route either co
 
 **Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot.
 
+**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics.
+
 **Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
 
 **Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place.
 
-**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine renders the rejection as one composer notice with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
+**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
 
 ## Testing
 
@@ -33,6 +35,7 @@ Registry executor enforcement, admission failure settlement, and frozen invocati
 - **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility.
 - **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors.
 - **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself.
+- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`.
 
 ## Consequences
 

+ 4 - 1
.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md

@@ -16,11 +16,13 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片
 
 **声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。
 
+**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。
+
 **执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
 
 **模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。
 
-**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机把拒绝渲染为一条 composer 通知,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
+**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
 
 ## Testing
 
@@ -33,6 +35,7 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片
 - **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。
 - **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。
 - **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。
+- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。
 
 ## Consequences
 

+ 7 - 5
apps/web/tests/command-image-envelope.snapshot.ts

@@ -3,7 +3,7 @@
 // bundles via AppWebEntry, keyless FixtureApiClient transport): an enter
 // submission carrying composer images resolves only through a command whose
 // descriptor declares `input.images`. A non-declaring command refuses with
-// one composer notice and everything retained; a declaring command consumes
+// one composer error banner and everything retained; a declaring command consumes
 // the images — serialized through the real draft-image chain into the
 // commands/execute payload — and clears the composer on success.
 import { fireEvent, screen, waitFor } from '@testing-library/react'
@@ -46,15 +46,17 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   fireEvent.change(textarea, { target: { value: '/echo hello' } })
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
-  // Several live-region elements exist (session activity among them); the
-  // refusal is the status whose text is the localized notice.
+  // The refusal rides the same transient error banner as other composer
+  // failures; session activity remains on its separate status live region.
   const notice = await waitFor(() => {
-    const el = [...document.querySelectorAll('[role="status"]')]
+    const el = [...document.querySelectorAll('[role="alert"]')]
       .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
-    if (el === undefined) throw new Error('composer refusal notice missing')
+    if (el === undefined) throw new Error('composer refusal banner missing')
     return el
   }, { timeout: 5_000 })
   expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
+  expect([...document.querySelectorAll('[role="status"]')]
+    .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
   // The whole envelope is retained: draft text and the rail thumbnail.
   expect(textarea.value).toBe('/echo hello')
   const rail = document.querySelector('[role="group"][aria-label="Pending images"]')

+ 2 - 2
packages/client/ui-commands/README.i18n.yaml

@@ -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-commands/README.md
-README.md: 7d4a700f70eb93ce1feea6b88eeeae6643039445
-README.zh.md: 0896ee0393ab93927b6b7ce2712028e3e0825ba3
+README.md: 2140495a44110d5e4b33e4cc8f539959752ac185
+README.zh.md: afa47cd18505b9afbd3e867d131e9796db598895

+ 1 - 1
packages/client/ui-commands/README.md

@@ -8,7 +8,7 @@ Client command API (`ctx.commandUi`): the session-keyed command-directory cache,
 
 `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
 
-`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine renders as one composer notice with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering.
+`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine publishes as one error notice and the composer renders as a transient Toast banner with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering.
 
 After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running.
 

+ 1 - 1
packages/client/ui-commands/README.zh.md

@@ -8,7 +8,7 @@
 
 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
 
-`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机将其渲染为一条 composer 通知,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。
+`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机发布一条错误通知,composer 以瞬态 Toast 横幅呈现它,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。
 
 `command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。
 

+ 1 - 1
packages/client/ui-conversation/src/client/input/facade.ts

@@ -78,7 +78,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
 export class SessionInputShell implements SessionInput {
   /** Published machine state + queue overlay (the InputZone currency source). */
   readonly state: SnapshotStore<InputState>
-  /** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
+  /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
   readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
   /** The public provide-channel action face (one stable identity per session). */
   readonly actions: InputActions = {

+ 1 - 1
packages/client/ui-conversation/src/client/input/hub.ts

@@ -159,7 +159,7 @@ export class InputHub implements SessionInputResolver {
    * Default sink: optimistic clear + prompt. The session is always a real
    * host entity (materialized when its workspace was picked), so there is
    * exactly one path; a failed first prompt is an ordinary prompt failure
-   * (error strip via promptError, draft restored only while untouched).
+   * (banner via promptError, draft restored only while untouched).
    */
   private sink(
     session: SessionFace,

+ 1 - 6
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -24,7 +24,7 @@
   /* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
      the sides narrow with the shared width axis); the bottom gradient mask
      is owned by the chat scroller. No top pad: the composer stack's gap owns
-     the space above; error/status strips still carry their own margin. */
+     the space above; the status strip still carries its own margin. */
   padding: 0 var(--dsh-composer-side-clearance) 8px;
 }
 
@@ -47,11 +47,6 @@
   line-height: 18px;
 }
 
-.noticeError {
-  background: var(--dsw-alias-interactive-bg-hover-danger);
-  color: var(--dsw-alias-state-error-primary);
-}
-
 .card {
   box-sizing: border-box;
   position: relative; /* overlay anchor positioning context */

+ 8 - 5
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -65,9 +65,9 @@ export function InputBar({
     [draftImages, input?.imageIds],
   )
   const empty = draft.trim() === '' && attachments.length === 0
-  // Transient error banner (image-intake rejections and prompt failures): the
-  // seq keys the Toast so an identical repeated message restarts the
-  // hold-then-fade cycle instead of silently reusing the faded one.
+  // Transient error banner (machine notices, image-intake rejections, and
+  // prompt failures): the seq keys the Toast so an identical repeated message
+  // restarts the hold-then-fade cycle instead of reusing the faded one.
   const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
   const toastSeq = useRef(0)
   const showToast = useCallback((text: string) => {
@@ -91,6 +91,9 @@ export function InputBar({
       ? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
       : `${promptError.error.message} (${promptError.error.code})`)
   }, [promptError, showToast, t, imageLimits])
+  useEffect(() => {
+    if (notice?.level === 'error') showToast(notice.text)
+  }, [notice, showToast])
   const inputRef = useRef<HTMLTextAreaElement | null>(null)
   const cardRef = useRef<HTMLDivElement | null>(null)
   const scrollRef = useRef<HTMLDivElement | null>(null)
@@ -580,8 +583,8 @@ export function InputBar({
           onDone={dismissToast}
         />
       )}
-      {notice !== null && (
-        <div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
+      {notice?.level === 'info' && (
+        <div className={css.notice} role="status">
           {notice.text}
         </div>
       )}

+ 19 - 4
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -2,7 +2,7 @@
 // InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
 // Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
 // semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
-// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
+// decoration backdrop, error banners, status strips, and the focus-keeping mousedown.
 
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -1202,10 +1202,25 @@ describe('strips and variants', () => {
     }
   })
 
-  it('renders the notice strip from the machine notice store', () => {
+  it('announces an error notice from the machine store as a fading toast', () => {
+    vi.useFakeTimers()
+    try {
+      const { view, shell } = bench()
+      act(() => { shell.notify('error', '命令失败了') })
+      expect(view.getByRole('alert').textContent).toContain('命令失败了')
+      expect(view.queryByRole('status')).toBeNull()
+      act(() => { vi.advanceTimersByTime(4000) })
+      expect(view.queryByRole('alert')).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('renders an information notice from the machine store as a status strip', () => {
     const { view, shell } = bench()
-    act(() => { shell.notify('error', '命令失败了') })
-    expect(view.getByText('命令失败了')).toBeTruthy()
+    act(() => { shell.notify('info', '命令完成了') })
+    expect(view.getByRole('status').textContent).toBe('命令完成了')
+    expect(view.queryByRole('alert')).toBeNull()
   })
 
   it('hero variant adds the hero class and accessory row renders', () => {