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

fix(ui-conversation): dispose Inbox projection subscription

_Kerman 3 недель назад
Родитель
Сommit
ba51e5483e

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-07-30-web-queue-steer-action.md
-2026-07-30-web-queue-steer-action.md: 4135c465166d88873e5b4595758778839efe83ee
-2026-07-30-web-queue-steer-action.zh.md: 2717c8a53b6a46c4751b1a8bb1e563b7aa37a42e
+2026-07-30-web-queue-steer-action.md: 4193bd9fcb2d20def8b72ff160f3ab3fa0b3e7c4
+2026-07-30-web-queue-steer-action.zh.md: 15dd9719d449afbc5ddab38de31b91aca2ccfed7

+ 2 - 0
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md

@@ -65,3 +65,5 @@ The keyless Web steering scenario queues a message through the real composer whi
 `inbox` is the domain-owned durable session projection. Pending steering survives reconnect and process restart and appears immediately from `next-step`; after claim, the durable conversation node follows through the ordinary event projection. The running bit can change between rendering and the synchronous operation, so an enabled action may return `steer-unavailable` while the product continues through Queue without reporting failure.
 
 The explicit action changes delivery from an independently admitted turn to current-turn steering. Its two durable splices briefly expose the deletion and insertion as adjacent projection revisions, while client snapshot batching converges on the message moving from `next-turn` to `next-step`.
+
+Claim removes the pending `next-step` value before the later `user/message` reaches the independent history stream, so a client may briefly render neither representation when those notifications cross separate flushes. The product accepts this transient gap instead of retaining a queue-specific handoff mirror; the durable log still converges on exactly one conversation node.

+ 2 - 0
.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md

@@ -65,3 +65,5 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、Inbox 投
 `inbox` 是领域拥有的持久会话投影。待处理 steering 会从 `next-step` 立即出现,能在重连与进程重启后恢复;认领后,持久会话节点经普通事件投影跟进。running 标志位可能在渲染与同步操作之间发生变化,因此已启用的操作可能返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。
 
 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering。它的两条持久 splice 会短暂地把删除与插入暴露为相邻投影修订,而客户端快照批处理会收敛到消息从 `next-turn` 移入 `next-step`。
+
+Claim 会先移除待处理的 `next-step` 值,随后 `user/message` 才到达独立的历史流;若两项通知跨越不同的客户端刷新,客户端可能短暂地两种表示都不渲染。产品接受这一瞬态空隙,以避免保留 Queue 专用的交接镜像;持久日志最终仍只产生一个会话节点。

+ 0 - 0
packages/api/session-controller/tests/queue-store.client.spec.ts → packages/api/session-controller/tests/inbox-projection.client.spec.ts


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

@@ -159,6 +159,7 @@ export class SessionInputShell implements SessionInput {
       nodes: [ReferenceChipNode, TextRefNode],
       onError: (error) => { throw error },
     })
+    this.state = createSnapshotStore<InputState>(this.compose())
     this.unregister = mergeRegister(
       registerPlainText(this.editor),
       registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
@@ -166,9 +167,8 @@ export class SessionInputShell implements SessionInput {
       registerClaimDecoration(this.editor, () => this.activeClaimToken()),
       registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()),
       () => { this.lexiconOff?.() },
+      deps.inbox?.subscribe(() => { this.publish() }) ?? (() => {}),
     )
-    this.state = createSnapshotStore<InputState>(this.compose())
-    deps.inbox?.subscribe(() => { this.publish() })
   }
 
   // ---- editor plumbing ----

+ 19 - 0
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -115,6 +115,25 @@ function bench(over?: {
 }
 
 describe('matrix row: plain', () => {
+  it('unsubscribes from the Inbox projection when disposed', () => {
+    const unsubscribe = vi.fn()
+    const subscribe = vi.fn(() => unsubscribe)
+    const shell = new SessionInputShell({
+      actx: SCTX,
+      defaultSink: () => Promise.resolve({ kind: 'success' }),
+      inbox: { getSnapshot: () => undefined, subscribe },
+      commandImages: {
+        serialize: () => Promise.resolve([]),
+        release: () => {},
+        unsupportedNotice: token => `${token.trim()} images-unsupported`,
+      },
+    })
+
+    expect(subscribe).toHaveBeenCalledOnce()
+    shell.dispose()
+    expect(unsubscribe).toHaveBeenCalledOnce()
+  })
+
   it('enter falls to the default sink; no claim on the currency; edits free', async () => {
     const { textarea, shell, sink } = bench()
     act(() => { shell.setDraft('普通消息') })