fix(chat): keep the transcript steady at Turn end and when steering lands

Measured against 0.2.1-rc.4 and master with the frame-sampled motion matrix,
the transcript moved back and forth at Turn end and around input landing.
This keeps rc.4's landing behavior, master's steering layout, and fixes the
reversals that remain.

- Completion-time folding keeps the first visible surviving row still when
  work rows close at once, filling the gap with bottom space capped at half
  the scrollport, instead of letting the browser clamp the position.
- After the Session stops running, the running status keeps its tail slot
  hidden and silent until the Turn's actions row replaces it, so the tail
  does not shrink and grow back.
- A Turn's duration control takes its gap in one frame instead of sliding.
- A fold batch that takes over following for a content change absorbs
  growth outside the batch before its first frame.
- Steering lands at once under either fold timing: the running Turn keeps
  streaming above its echo, which a native animation would push down.
- The running status no longer slides below a Step's first output, and the
  column no longer carries `data-chat-tail-pinned`; the steering entrance
  keeps its animation.
- A queued prompt is accepted by its Inbox row as well as by its receipt,
  so the composer clears the draft in the frame the queue dock shows it.
- A queued echo waits for the Inbox projection instead of retiring on the
  durable splice, so the queue dock does not empty for a frame.
This commit is contained in:
imccyu committed 2026-10-27 20:44:52 +08:00
1 parent 6300195071
commit ae07093132
21 files changed
+539 -366

No files matched your search

@@ -800,7 +800,10 @@ export class Session implements SessionFace {
}
}
this.observeSubmissionInsertions(target, inserted, start, event.seq)
for (const message of inserted) this.observeSubmissionMessage(message, false)
// A queued echo waits for the inbox projection the queue dock renders, which can arrive after this
// event; retiring it here would empty the dock for a frame. A claim in the same projection notification
// still retires it through the durable user message.
for (const message of inserted) this.observeSubmissionMessage(message, false, false)
return
}
if (event.type === 'request/context' || event.type === 'turn/end') {
@@ -818,21 +821,29 @@ export class Session implements SessionFace {
const source = message.source
if (source.kind !== 'user' || !('rpcId' in source)) continue
const settlement = this.submissionSettlements.get(source.rpcId)
if (settlement === undefined || settlement.placement === 'queued'
|| settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
if (settlement === undefined || settlement.retiring) continue
// Either proof accepts a prompt; an Inbox row can reach the Client before the prompt receipt. A queued
// prompt's row is the queue dock's own row, so its acceptance there lets the composer clear the draft in
// the frame that shows the row; its retirement follows the Inbox projection, not a receipt position.
if (settlement.placement === 'queued') {
this.acceptPrompt(source.rpcId)
continue
}
if ((settlement.receipt?.seq ?? -1) > seq) continue
settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) }
// An Inbox row can reach the Client before the prompt receipt.
this.acceptPrompt(source.rpcId)
}
}
private observeSubmissionMessage(message: UserMessage, admitted: boolean): void {
private observeSubmissionMessage(message: UserMessage, admitted: boolean, retireQueued = true): void {
const source = message.source
if (source.kind !== 'user' || !('rpcId' in source)) return
const settlement = this.submissionSettlements.get(source.rpcId)
if (settlement === undefined || settlement.retiring) return
if (!admitted) {
if (settlement.placement === 'queued') this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
if (settlement.placement === 'queued' && retireQueued) {
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
}
return
}
settlement.admitted = attachmentRefsIn(message.content)