fix(ui-conversation): dispose Inbox projection subscription

This commit is contained in:
_Kerman committed 2026-08-27 12:43:11 +08:00
1 parent 8c8c5b2680
commit ba51e5483e
6 files changed
+27 -4

No files matched your search

@@ -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
@@ -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.
@@ -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 专用的交接镜像;持久日志最终仍只产生一个会话节点。
@@ -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 ----
@@ -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('普通消息') })