You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Proposal: Render the optimistic user message where it will settle (transcript vs composer-queue jump)
#5695
Sending a message in the desktop app makes the same user message change homes twice, and the direction of the jump depends on which surface you sent from:
Sending into a running Session (default send → follow-up):
The message appears immediately at the transcript tail (the "user and model output" area) as an optimistic row.
When the submitMessage IPC reply lands, the real placement (next_turn) is backfilled — the row vanishes from the transcript and reappears in the composer queue above the input box, under "followup pending".
When the Host settles it into the durable transcript, it moves back to the transcript.
Queueing a follow-up (enqueueMessage with next_turn):
The message appears first in the composer queue above the input box.
When the Host starts a turn with it, it moves to the transcript.
Two surfaces, opposite directions, same root cause.
Why this hurts
The damage is not that an element moves — it is that object constancy breaks: one user action (pressing send) produces a row that first presents itself as "already said" (transcript) and then demotes itself to "intent not yet taken over" (queue). Concretely:
Position memory fails. The user's eyes are still on the transcript tail where the row just was; it is gone, and they have to rescan to find it above the composer.
State semantics regress. "Sent" → "pending" reads like a send failure, every single time.
Trust cost. Every send demonstrates that the UI's first answer cannot be believed, so users stop trusting first paint.
The evaluation standard should be: zero displacement > predictable displacement > displacement masked by animation.
Root cause
Rendering position is decided purely by transientPlacement / pendingSteering:
packages/ui/src/chat-view.tsx — the transcript only renders transient messages with !pendingSteering && transientPlacement !== 'next_turn'.
packages/ui/src/composer-message-queue.tsx — the composer queue renders transient messages with pendingSteering || transientPlacement === 'next_turn'.
But apps/desktop/src/renderer/app-shell-chat-actions.ts hardcodes the optimistic row:
send() publishes { transientPlacement: 'current_turn' } for both the new-task first send and the existing-Session send, so it lands in the transcript immediately.
submitAndProject() then backfills the real placement from the submission — next_turn for a default send — which is what ejects the row from the transcript into the composer queue.
enqueueMessage() preserves the caller's placement from the start, which is why queued follow-ups show the opposite order.
The Host queue projection (projectQueuedTransientMessages) and transient reconciliation (reconcileTransientMessages) are already authoritative and correct; the flicker is entirely in where the optimistic row is first drawn.
Constraints (correctness, not negotiable)
outcome_unknown keeps its row. When the IPC outcome is unknown the Host may already have admitted the message; removing the row would invite a duplicate resend. Only an explicit refusal removes it. Any proposal must leave this untouched.
The composer queue stays. The area above the composer is the steering / follow-up surface — grouped by current turn vs next turn, with edit / delete / drag-reorder. It is a real differentiator of the chat UX, and proposals that dissolve it into transcript badges (queued messages rendered inline in the timeline with status chips) were considered and rejected: they pollute the timeline semantics, cannot express ordering among multiple queued items, and would be a large rework for a worse result.
The new-task first send must keep staging current_turn. It exists so the empty-session hero cannot paint between activation and the first message appearing (see the comment at the staging site).
Proposal
Draw the optimistic row where it will finally settle, split by session state at send time:
Scenario
Optimistic placement
Rationale
Running session, default send (follow-up)
next_turn (composer queue)
That is where it will end up — eliminates the first jump entirely
The Host will start a turn almost immediately; drawing the queue first would just add a reverse jump
New-session first send
current_turn (transcript tail)
Required to prevent the hero flash; unchanged
send() already has access to the running state (deps.getRunningTurnId, which enqueueMessage uses), so the change is a small, localized branch at the two optimistic publish sites in app-shell-chat-actions.ts.
The residual edge: if the running check races the previous turn's end, an idle-bound message may briefly sit in the queue before the Host starts a turn with it. That jump is an upgrade (queued → speaking) rather than today's demotion (sent → pending), and the Host queue snapshot already corrects any mis-guess — wrong for at most one beat, never incorrectly.
Explicitly not proposed
Debouncing the optimistic row (200–300ms). Send feedback has a ~100ms golden window; delaying the optimistic row trades perceived latency for visual stability, in the wrong direction, and makes the row "pop in late" whenever the Host is slow. Rejected.
Cross-container FLIP animation between transcript tail and composer queue. These are two separate containers, one inside a virtualized scroller; imperfect animation at the exact moment the user cares most is worse than none. At most, a light same-container entrance fade and key-stable DOM reuse on durable replacement — as polish, not as the fix.
Dissolving the composer queue into transcript status badges. See Constraints — the queue stays.
This is not the only option — proposals welcome
The proposal above is one option I worked out, not a settled direction. There are certainly other ways to restore object constancy for the optimistic row, and better ones may exist. If you see a different trade-off — whether in where the row renders, how the queue communicates state, or something structural — please bring it up below.
Validation
Send 3 follow-ups in a row into a running session: count visible displacements per message (should drop from 2 to 0–1).
Confirm no change in duplicate sends / mistaken retries (outcome_unknown retention is untouched, so this should be zero).
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Problem
Sending a message in the desktop app makes the same user message change homes twice, and the direction of the jump depends on which surface you sent from:
Sending into a running Session (default send → follow-up):
submitMessageIPC reply lands, the real placement (next_turn) is backfilled — the row vanishes from the transcript and reappears in the composer queue above the input box, under "followup pending".Queueing a follow-up (
enqueueMessagewithnext_turn):Two surfaces, opposite directions, same root cause.
Why this hurts
The damage is not that an element moves — it is that object constancy breaks: one user action (pressing send) produces a row that first presents itself as "already said" (transcript) and then demotes itself to "intent not yet taken over" (queue). Concretely:
The evaluation standard should be: zero displacement > predictable displacement > displacement masked by animation.
Root cause
Rendering position is decided purely by
transientPlacement/pendingSteering:packages/ui/src/chat-view.tsx— the transcript only renders transient messages with!pendingSteering && transientPlacement !== 'next_turn'.packages/ui/src/composer-message-queue.tsx— the composer queue renders transient messages withpendingSteering || transientPlacement === 'next_turn'.But
apps/desktop/src/renderer/app-shell-chat-actions.tshardcodes the optimistic row:send()publishes{ transientPlacement: 'current_turn' }for both the new-task first send and the existing-Session send, so it lands in the transcript immediately.submitAndProject()then backfills the real placement from the submission —next_turnfor a default send — which is what ejects the row from the transcript into the composer queue.enqueueMessage()preserves the caller's placement from the start, which is why queued follow-ups show the opposite order.The Host queue projection (
projectQueuedTransientMessages) and transient reconciliation (reconcileTransientMessages) are already authoritative and correct; the flicker is entirely in where the optimistic row is first drawn.Constraints (correctness, not negotiable)
outcome_unknownkeeps its row. When the IPC outcome is unknown the Host may already have admitted the message; removing the row would invite a duplicate resend. Only an explicit refusal removes it. Any proposal must leave this untouched.current_turn. It exists so the empty-session hero cannot paint between activation and the first message appearing (see the comment at the staging site).Proposal
Draw the optimistic row where it will finally settle, split by session state at send time:
next_turn(composer queue)current_turn+pendingSteering(queue, current-turn group)current_turn(transcript tail)current_turn(transcript tail)send()already has access to the running state (deps.getRunningTurnId, whichenqueueMessageuses), so the change is a small, localized branch at the two optimistic publish sites inapp-shell-chat-actions.ts.The residual edge: if the running check races the previous turn's end, an idle-bound message may briefly sit in the queue before the Host starts a turn with it. That jump is an upgrade (queued → speaking) rather than today's demotion (sent → pending), and the Host queue snapshot already corrects any mis-guess — wrong for at most one beat, never incorrectly.
Explicitly not proposed
This is not the only option — proposals welcome
The proposal above is one option I worked out, not a settled direction. There are certainly other ways to restore object constancy for the optimistic row, and better ones may exist. If you see a different trade-off — whether in where the row renders, how the queue communicates state, or something structural — please bring it up below.
Validation
outcome_unknownretention is untouched, so this should be zero).中文版(点击展开)
问题
桌面端发送消息时,同一条用户消息会换两次"家",而且跳的方向取决于你从哪个界面发送:
向正在运行的会话发送(默认发送 → follow-up):
submitMessageIPC 回执到达后,真实 placement(next_turn)被回填——行从 transcript 消失,重新出现在输入框上方的队列区,归在"followup pending"下。排队一条 follow-up(
enqueueMessage,placement 为next_turn):两个界面,方向相反,同一个根因。
为什么这是伤害
伤的不是"元素动了",而是对象恒常性被破坏:用户只有一个动作(按下发送),产生的行却先以"已经说出去的话"(transcript)自居,再降级为"还没被接管的意图"(队列)。具体来说:
评估标准应该是:零位移 > 位移可预期 > 位移被动画掩盖。
根因
渲染位置完全由
transientPlacement/pendingSteering决定:packages/ui/src/chat-view.tsx——transcript 只渲染!pendingSteering && transientPlacement !== 'next_turn'的 transient 消息。packages/ui/src/composer-message-queue.tsx——输入框上队列只渲染pendingSteering || transientPlacement === 'next_turn'的 transient 消息。但
apps/desktop/src/renderer/app-shell-chat-actions.ts把乐观行写死了:send()对新任务首发和老会话发送都发布{ transientPlacement: 'current_turn' },所以行立刻落在 transcript。submitAndProject()随后从提交结果回填真实 placement——默认发送是next_turn——这一下把行从 transcript 顶进输入框上队列。enqueueMessage()从一开始就保留调用方的 placement,这就是排队 follow-up 方向相反的原因。Host 队列投影(
projectQueuedTransientMessages)和 transient 对账(reconcileTransientMessages)本来就是权威且正确的;闪烁完全出在乐观行最初画在哪。约束(正确性,不可协商)
outcome_unknown保行。 IPC 结果未知时 Host 可能已经收下了消息;删行会诱导用户重发造成重复。只有明确拒绝才删行。任何方案都不许碰这条。current_turn暂存。 它存在的意义是不让空会话 hero 在激活和首条消息出现之间闪一下(见暂存处的注释)。提案
乐观行直接画在它最终会停下的地方,按发送时的会话状态分流:
next_turn(输入框上队列)current_turn+pendingSteering(队列当前轮组)current_turn(transcript 尾)current_turn(transcript 尾)send()已经拿得到 running 状态(deps.getRunningTurnId,enqueueMessage在用),所以改动只是app-shell-chat-actions.ts里两处乐观发布点的一个小的局部分支。残留的边界情况:如果 running 判定和上一轮结束抢跑,一条本该进 transcript 的消息可能短暂停在队列里,等 Host 开 turn。这个跳是升级(排队 → 开说)而不是今天的降级(已发送 → 排队中),而且 Host 队列快照会纠正任何误判——最多错一拍,永远不会错下去。
明确不提案的
这不是唯一方案——欢迎提方案
上面的提案是我推出来的一个选项,不是已定方向。恢复乐观行对象恒常性的做法肯定还有很多,而且很可能有更好的。如果你看到不同的取舍——不管是在行的渲染位置、队列的状态表达,还是更结构性的层面——都请在下面提出来。
验证
outcome_unknown保行不动,预期为零)。All reactions