Skip to content

fix(chat): show a message sent mid-turn as a message, not a strip - #636

Merged
xintaofei merged 11 commits into
xintaofei:mainfrom
Adam-Dalloul:fix/steered-messages-in-transcript
Sep 4, 2026
Merged

fix(chat): show a message sent mid-turn as a message, not a strip#636
xintaofei merged 11 commits into
xintaofei:mainfrom
Adam-Dalloul:fix/steered-messages-in-transcript

Conversation

@Adam-Dalloul

Copy link
Copy Markdown
Contributor

Messages sent while a turn is streaming are delivered to the agent, but the live view never shows them as messages.

Two things go wrong. The strip above the composer flips to "received" and then sits there for the rest of the turn, so a sent message never appears as one. And because no user turn lands between them, the reply to that message continues inside the same assistant bubble as the previous one, gluing two separate answers together mid-paragraph ("...I'll report both links once CI is green.Not done - those are the two PRs...").

A note that arrives already delivered came in over the native _session/steering push, which means the agent has the text as a user message. It now becomes a steering block on the live message: the assistant turn closes there, the message renders as its own user turn, and the reply to it starts a new turn. This is what project_turns in parsers/acp_native.rs already does with a mid-turn user_message_chunk, so the live view now matches what you get on reload. A detail fetch that lands mid-turn and brings back the agent's own copy is matched on content, keeping the live copy, since only that one sits between the two halves of the reply.

Pending notes are unchanged. The check_user_feedback pull channel reaches the agent as a tool result and never as a user message, so it has no user turn on reload either and stays a strip. Queued messages, which are only sent once the turn ends, are untouched. A note that arrives with no turn running is not adopted and keeps its strip, so nothing can end up showing in neither place.

A message sent while the agent is replying reaches it over the native
_session/steering channel, and the agent records it as a user message.
The live view dropped the echo, so the only trace was the strip above
the composer, which then sat there for the rest of the turn. And with
no user turn between them, the reply to that message continued inside
the same assistant bubble as the one before it, running two separate
answers together mid-paragraph.

A delivered note now becomes a `steering` block on the live message,
which closes the assistant turn at that point, renders the message as
its own user turn, and starts the reply to it as a new turn. That is
what the transcript projection already does with a mid-turn
user_message_chunk, so the live view and a reload agree; a mid-turn
detail fetch that brings back the agent's own copy is matched on
content and the live copy kept, since only it sits in the right place.

Pending notes are untouched: the check_user_feedback pull channel
reaches the agent as a tool result, never as a user message, so those
stay strips. Queued messages, which are not delivered until the turn
ends, are untouched too.
Two ways the mid-turn message could end up in the wrong place.

The persisted copy was matched on content across the whole loaded
window, so a steer that repeats an earlier prompt's words — "continue",
"stop", the short things people steer with — suppressed that older turn
too, and kept it hidden for as long as the turn ran. The agent writes
the copy DURING the running turn, so the round's own prompt is the
scope: only persisted user turns after it are candidates, and with no
anchor in the window nothing is suppressed at all. The narrower scan
also stops the content key (full text, full image data) from being
recomputed for every prompt in history on every streaming batch.

A snapshot hydrate — a mid-turn re-attach, i.e. any WS reconnect in
server mode — replaces the live message with the backend's, which has
no steering block, but left the adoption ids in place; the message was
then gone from the transcript with its strip still suppressed, showing
nowhere at all. The ids are dropped with the live message that carried
them, so the notes list shows those messages as strips again.
… round

Anchoring by position in the timeline still reached turns that are not
part of this round's projection: `localTurns` render as phase
"persisted" and a mid-turn refetch preserves them, so an earlier
round's promoted prompt sits after the anchor and a steer repeating its
words hid it. The candidates are now the detail's own user turns after
the in-flight prompt, matched by id — the only turns the agent can have
written during the running turn.
The persisted copy could only be recognized by its content, and the
scope that kept the match off history — the turn's own prompt, located
through `detail.in_flight_user_turn_id` — is gone precisely when it is
needed: the backend stamps that id by matching the pending prompt
against the transcript TAIL, and once the agent has written the steered
message the tail IS that message. So the guard was dead in the shape it
exists for, and the version before it reached back through the whole
window instead.

The `steering` block now carries the note's `created_at`, the instant
the backend injected the text, stamped on the machine the agent runs
on. A persisted user turn older than that cannot be a copy of it —
including this round's prompt, written before the user steered — so the
match needs no prompt anchor and cannot touch history. Candidates stay
limited to what the detail projected, keeping every compared timestamp
on the agent's clock, and anything unreadable suppresses nothing.

The message also shows the time it was actually sent rather than the
turn's start, which is the same stamp read for display.
…gent

`created_at` was taken on the way back from `_session/steering`, after
the adapter had already handed the text to the agent. The agent can
write its own transcript copy of the message inside that round trip, so
the note could end up NEWER than the copy it caused — and the frontend,
which folds a persisted copy away only when it postdates the note (so
that the same words sent in an earlier round are never hidden), would
then show the message twice.

Taken before the command is enqueued instead, which is also when the
pull path records its own notes. A test pins the ordering against the
instant the connection loop sees the injection.
main reworked the composer around per-message forking (fork-and-send
gone) and added the async-task strip, which lands a second stable-empty
constant next to this branch's own in `use-connection.ts` — the only
conflict, and both are kept.

Nothing else collides: the steering work adds `steeredMessageIds` beside
main's new `asyncTasks` on the connection, and the timeline's steered-copy
suppression sits on the same seam the fork work left alone.
Forking sends a turn id, and the backend cannot resolve one this client
minted for its own live stream — `fork_session` degrades an unresolvable
fork point to a TAIL fork rather than refusing the click. That is the
right answer for the newest reply, where the tail IS the fork point, and
until now it was the only reply that could be in that state: consecutive
assistant turns merge into one group, so a live-named group was always
the last one.

A steered reply breaks that. It promotes as assistant / user message /
assistant, so its first half is a settled, non-tail group with a fork
button, and `source_turn_id` — the parser's name for it — does not
arrive until the post-turn reparse a second and a half later. Forking
there silently produced the whole reply, steer included, instead of the
half the user pointed at.

That case now greys the button out and says why, the way an in-flight
turn already does, until the reparse names the turn.
…st reply

The exception that keeps a live-named reply forkable — the backend's
tail fork lands exactly there — was keyed on "newest assistant run",
which is one message short of the tail when the user steers at the very
end of a turn: that promotes as assistant + user message, so the reply
is still the newest one and the tail fork would land after the steered
message. `computeTurnMetadataPatches` withholds `source_turn_id` from a
parse ending on a user turn, so nothing would have corrected it later
either.

Render items now carry `isThreadTail` — nothing follows, counting a
user message or a compaction divider and stepping over blocks that
render nothing — and the fork gate reads that instead.
The walk that decides which render item is the thread's tail was inline
in the view, so only the policy it feeds was under test. Lifted to
`markThreadTail` (same code, exported) and covered: a trailing message,
a trailing compaction divider, and a trailing turn that renders nothing
each land on a different answer.
@xintaofei

Copy link
Copy Markdown
Owner

codeg work task 143 is done — #636 (99 files, +8942/-698).

@xintaofei
xintaofei merged commit 4f0df9e into xintaofei:main Sep 4, 2026
7 checks passed
xintaofei added a commit that referenced this pull request Sep 5, 2026
Talking to an agent while it is still replying now works on every session that has a delivery channel — with attachments, shown as a real message in the transcript, and recoverable if the agent finished without reading it.
Mobile gets a terminal key bar and a ⋯ menu on every file-tree row.

## New

- **Send a message into a running turn on any session with a delivery channel** — Codex, Grok, Gemini and OpenCode sessions get the mid-turn send too, worded honestly per channel: Claude inserts into the current turn, the others leave a note the agent picks up at its next check, shown as waiting until it actually does. (#637, @Adam-Dalloul)
- **A message sent mid-turn now reads as a message** — it lands in the transcript as its own turn, and the reply to it starts a new turn instead of running into the previous answer mid-paragraph. (#636, @Adam-Dalloul)
- **Mid-turn sends carry images and file references**, instead of going dark the moment you stage an attachment. An image shows on the message right away in the running turn rather than only after reopening the conversation, and if the turn ends first the whole draft — attachment included — is queued for the next one rather than dropped. (#640, @Adam-Dalloul)
- **A note the agent finished without reading is no longer lost** — the list stays up after the turn, says the agent never got to it, and offers to send it as an ordinary message or dismiss it.
- **"Adapter version" is now a per-agent setting** — follow the newest adapter release instead of waiting for the reviewed pin, which is what makes a brand-new model reachable the day it ships. Pinned stays the default and recommended; the newest release is unreviewed and can break the agent, and an install that can't fetch it falls back to the pinned version rather than failing. (#635, @Adam-Dalloul)
- **A virtual key bar for the terminal on mobile** — ESC, TAB, CTRL, ALT, arrows, Home/End and PgUp/PgDn, with CTRL/ALT latches that wrap the next key you type, lifted clear of the soft keyboard and collapsible from the tab bar. (#647, @mrytsr)
- **Every file-tree row gets a ⋯ button** that opens the same menu right-click does, so touch devices have a way in now that long-press is reserved for drag. (#648, @mrytsr)
- **Transcript file links open beside the conversation when the workspace column is off screen** — on the task board and the infinite canvas, a file badge, a markdown link or "view diff" now opens a read-only viewer next to the transcript, with "Open in workspace" one click away, instead of doing nothing visible.
- **Codex reports its background terminals** (codex-acp 1.10.0) — a long-running command Codex pushes to the background is badged "Background" and joins the live task strip with its Stop button and output link, the way Claude's already did.
- **"Disable hardware acceleration" now works on Linux**, not just Windows.

## Improved

- **Updated bundled agents:** Kimi Code 0.41.0, CodeBuddy 2.144.0, Qoder 1.1.44, Cursor 2026.09.02, Antigravity 1.1.1, OpenCode 1.18.29, plus the Claude and Codex adapters (claude-agent-acp 0.74.0, codex-acp 1.10.0).
- **Antigravity now takes a custom version** in Agent Settings, like the other downloaded agents — and a version that was never published now fails the download instead of quietly filing the pinned build under the number you asked for.
- **Signing out of Claude mid-session now ends only that turn** — the session stays open and asks to be signed in again, instead of the connection dropping and the conversation flipping to cancelled. A delegated sub-agent in the same state reports "needs sign-in" rather than an unrecognized failure.

## Fixed

- **A Codex search that finds nothing now says "No matches"** instead of a red failed tool call. (#651, reported by @rubybrowncoat; fixed by @dawNotPoi in #652)
- **Cleaning up a task with no changes now removes its worktree completely**, instead of half-removing it and then reporting it still holds uncommitted files so the retry never finished. (#642, reported by @rubybrowncoat; fixed by @dawNotPoi in #650)
- **Worktree removal now acts on exactly the checkout you pointed at.** Git matches a worktree argument by path suffix before resolving it as a path, so a same-named checkout belonging to another project could be deleted, uncommitted files included; a path that resolves two ways is now refused rather than guessed at.
- **A `/goal` turn no longer appears twice** — a slash command is now matched against what codeg actually sent, so its turn stops coming back as a background overlay beside itself. (#643, @Adam-Dalloul)
- **"Fork from here" no longer silently forks the whole reply** when you point at the half before a mid-turn message — the button greys out with an explanation until the fork point is named, the way it already does during a running turn.
- **Turning "Disable hardware acceleration" back off now takes effect** after the restart, instead of the setting being inherited by the new process and staying on.

Thanks to @Adam-Dalloul, @mrytsr and @dawNotPoi for contributing to this release, and to @rubybrowncoat for the reports.

-----------------------------

# 发布版本 0.30.3

智能体正在回复时插话,现在在每一个有投递通道的会话上都能用了——能带图片和文件、在会话记录里就是一条正经消息,智能体没来得及读完就收尾的,也还能捞回来。
移动端还多了终端虚拟按键条,文件树每一行也多了 ⋯ 菜单。

## 新增

- **只要会话有投递通道,就能往正在跑的这一轮里发消息**——Codex、Grok、Gemini、OpenCode 也都有了,而且措辞按通道如实区分:Claude 是直接插进当前轮次,其余的是留一张便条、等智能体下次查看时取走,在它真的读到之前一直显示为等待中。(#637@Adam-Dalloul)
- **中途发出的消息现在就是一条消息**——它会作为独立的一轮出现在会话记录里,针对它的回答也会另起一轮,不再和上一段答案粘在同一个气泡里连成一片。(#636@Adam-Dalloul)
- **中途发送支持图片和文件引用**,不再一贴附件按钮就变灰。图片当场就显示在正在进行的这一轮里,不用关掉会话再打开才看得到;万一那一轮刚好结束了,整条草稿(连同附件)会排进下一轮,而不是被丢掉。(#640@Adam-Dalloul)
- **智能体没读就收尾的便条不会再消失**——列表在这一轮结束后仍然保留,写明智能体没来得及读,并提供「作为消息发送」或直接忽略。
- **「适配器版本」成为每个智能体各自的设置项**——可以选择跟随最新的适配器发布,不必等官方钉住的版本更新,新模型上线当天就能用上。默认仍是钉住的版本并标为推荐;最新版未经审核、可能让智能体不可用,安装时若取不到最新版会自动回退到钉住的版本,而不是直接装不上。(#635@Adam-Dalloul)
- **移动端终端虚拟按键条**——ESC、TAB、CTRL、ALT、方向键、Home/End、PgUp/PgDn,CTRL/ALT 按下后会锁定并作用于你输入的下一个字符,按键条会浮在软键盘上方,也可以从标签栏收起。(#647@mrytsr)
- **文件树每一行都多了 ⋯ 按钮**,点开就是右键那份菜单——长按手势已留给拖拽,触屏这下有了入口。(#648@mrytsr)
- **工作区被挡住时,会话里的文件链接改在旁边打开**——在任务看板和无限画布这类整页视图下,点文件徽章、Markdown 链接或「查看差异」会在会话旁弹出只读预览(「在工作区中打开」也只有一步之遥),不再点了没反应。
- **Codex 的后台终端能报上来了**(随 codex-acp 1.10.0 到位)——被 Codex 挂到后台的长命令会标上「Background」,并进入顶部的实时任务条,带停止按钮和输出链接,和 Claude 的一样。
- **「禁用硬件加速」在 Linux 上也生效了**,不再只支持 Windows。

## 改进

- **内置智能体版本更新:** Kimi Code 0.41.0、CodeBuddy 2.144.0、Qoder 1.1.44、Cursor 2026.09.02、Antigravity 1.1.1、OpenCode 1.18.29,以及 Claude 和 Codex 的适配器(claude-agent-acp 0.74.0、codex-acp 1.10.0)。
- **Antigravity 现在也能指定自定义版本了**,和其他下载安装的智能体一样;填了一个从未发布过的版本号会直接下载失败,而不是把钉住的那份安装包悄悄记成你填的号。
- **会话进行中登出 Claude 现在只结束当前这一轮**——会话本身保留,只是提示需要重新登录,不再整条连接断开、会话被标成已取消。委托出去的子智能体遇到同样情况会报「需要登录」,而不是一句看不懂的异常结束。

## 修复

- **Codex 搜索没有命中时显示「无匹配结果」**,不再是一张红色的失败工具卡。(#651,由 @rubybrowncoat 反馈;@dawNotPoi#652 中修复)
- **清理没有改动的任务时,工作树现在能被完整删除**,不再删一半、然后反过来说它还有未提交的改动,导致重试永远走不完。(#642,由 @rubybrowncoat 反馈;@dawNotPoi#650 中修复)
- **删除工作树只会作用于你指定的那一个。** git 会先按路径后缀去匹配工作树参数、再当作路径解析,因此另一个项目下同名的 checkout 有可能被连同未提交文件一起删掉;现在遇到会有两种解释的路径会直接拒绝,而不是替你猜。
- **`/goal` 之类的斜杠命令不再出现两遍**——斜杠命令现在会和 codeg 实际发出的内容比对,那一轮不会再以「后台活动」的形式在自己旁边重复一次。(#643@Adam-Dalloul)
- **「从这里分叉」不再悄悄把整条回复都分出去**——当你点的是中途插话之前的那半段时,按钮会像回复生成中那样变灰并说明原因,直到分叉点被确定。
- **「禁用硬件加速」关掉后重启真的会生效**,不再被新进程继承下来、看起来像是没关掉。

感谢 @Adam-Dalloul@mrytsr@dawNotPoi 为本次发布做出的贡献,也感谢 @rubybrowncoat 的问题反馈。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants