Summary
Switching tasks while a chat is open leaves ChatView rendering the previous task's transcript. Because the composer targets the bot's live thread while the transcript is read from a frozen one, a message typed on that screen is delivered to a conversation the user is not looking at, and never appears.
The screen looks entirely normal throughout — the old transcript is still plausible, so there is nothing to suggest the two halves disagree.
Steps to reproduce
- Open a bot's chat.
- Tap the name → Tasks, and pick a different task. (Or
+ → New task, which is a single tap.)
- The sheet closes and the transcript does not change — it is still the previous task's.
- Type a message and send it. It is delivered to the newly-selected task's thread, and does not appear on screen.
It also happens with no interaction on the phone at all: switch the task on the desktop, and an open ChatView stays on the old thread as the .bot frame lands.
Cause
ChatView is handed a Chat value by navigation (ios/App/ChatListView.swift:114), which is a snapshot of the bot at push time. The file already accounts for that snapshot going stale — current re-reads the live record from the store:
// ios/App/ChatView.swift:44-51
/// The live chat record, so busy/unread stay current as frames land.
private var current: Chat { … session.state.bot(bot.id).map(Chat.bot) ?? chat … }
…but the transcript is read from the frozen one:
// ios/App/ChatView.swift:40-42
private var messages: [Message] {
session.state.visibleTranscript(forThread: chat.threadId)
}
Switching a task changes bot.threadId (server/store.ts:992, via ios/App/TaskManagerView.swift:23 → ios/App/Session.swift:597-601), so from that moment chat.threadId and current.threadId refer to different conversations.
Nothing rebuilds the view: it has no .id(…) keyed on the thread, none of the three .task {} blocks are keyed, and Chat's ==/hash use only id (ios/App/Session.swift:703-720) — so SwiftUI considers the old and new Chat the same navigation value.
The store keeps the old transcript rather than dropping it (ios/Sources/CompanionCore/Store.swift:196-215 writes messages[bot.threadId] without clearing the previous thread's entry), which is why the stale screen looks healthy instead of empty.
Why it is more than a display bug
The actions on that screen use current — the new thread — over messages rendered from the old one:
ios/App/ChatView.swift:509 — session.send(text, to: current) sends by bot id, and the harness delivers to bot.threadId. The message lands in the task the user is not reading.
ios/App/ChatView.swift:113 — MessageRow(chat: current, …) propagates the mismatch, so a card answered at :845/:874 pairs a requestId from the old thread with the new threadId, and a reaction at :611/:640 does the same with messageId.
ios/App/ChatView.swift:676 — ScreenShot(threadId: chat.threadId) requests an image for a message that is not in the new thread.
ios/App/ChatView.swift:169 — the face already reflects the new thread while the transcript shows the old one.
Deleting the active task (ios/App/Session.swift:611-615) leaves the screen showing a thread that no longer exists.
Not a regression from #232
git show 70805c0:ios/App/ChatView.swift has the same shape (chat.threadId for the transcript, current for the live record). #232 moved the code without changing this.
Switching bots is fine
Chat.id is the bot id, so opening a different bot pushes a distinct navigation value and builds a fresh ChatView with the right thread.
The correct pattern already exists in the repo
Session.open(_ hit:) (ios/App/Session.swift:559-574) does it right for search results — it calls switchTask, applies the frame, and then re-reads the record before handing it to navigation:
bot = try await client.switchTask(botId: bot.id, threadId: hit.threadId)
state.apply(.bot(bot))
…
return state.bot(bot.id).map(Chat.bot)
The same re-read is what's missing when the switch happens with ChatView already on screen.
Suggested direction
Read the transcript from the live record (current.threadId) at the six sites that currently use chat.threadId — :41, :84, :91, :126, :129, :199 — and key the view's identity on the thread (.id(current.threadId), or .task(id: current.threadId)) so streaming, scroll position and the .task blocks restart on a switch. Making Chat's equality account for threadId would also let a re-appended value invalidate the navigation entry.
Notes
Found while porting the companion to Android (#241). Verified against d487882.
Summary
Switching tasks while a chat is open leaves
ChatViewrendering the previous task's transcript. Because the composer targets the bot's live thread while the transcript is read from a frozen one, a message typed on that screen is delivered to a conversation the user is not looking at, and never appears.The screen looks entirely normal throughout — the old transcript is still plausible, so there is nothing to suggest the two halves disagree.
Steps to reproduce
+→ New task, which is a single tap.)It also happens with no interaction on the phone at all: switch the task on the desktop, and an open
ChatViewstays on the old thread as the.botframe lands.Cause
ChatViewis handed aChatvalue by navigation (ios/App/ChatListView.swift:114), which is a snapshot of the bot at push time. The file already accounts for that snapshot going stale —currentre-reads the live record from the store:…but the transcript is read from the frozen one:
Switching a task changes
bot.threadId(server/store.ts:992, viaios/App/TaskManagerView.swift:23→ios/App/Session.swift:597-601), so from that momentchat.threadIdandcurrent.threadIdrefer to different conversations.Nothing rebuilds the view: it has no
.id(…)keyed on the thread, none of the three.task {}blocks are keyed, andChat's==/hashuse onlyid(ios/App/Session.swift:703-720) — so SwiftUI considers the old and newChatthe same navigation value.The store keeps the old transcript rather than dropping it (
ios/Sources/CompanionCore/Store.swift:196-215writesmessages[bot.threadId]without clearing the previous thread's entry), which is why the stale screen looks healthy instead of empty.Why it is more than a display bug
The actions on that screen use
current— the new thread — over messages rendered from the old one:ios/App/ChatView.swift:509—session.send(text, to: current)sends by bot id, and the harness delivers tobot.threadId. The message lands in the task the user is not reading.ios/App/ChatView.swift:113—MessageRow(chat: current, …)propagates the mismatch, so a card answered at:845/:874pairs arequestIdfrom the old thread with the newthreadId, and a reaction at:611/:640does the same withmessageId.ios/App/ChatView.swift:676—ScreenShot(threadId: chat.threadId)requests an image for a message that is not in the new thread.ios/App/ChatView.swift:169— the face already reflects the new thread while the transcript shows the old one.Deleting the active task (
ios/App/Session.swift:611-615) leaves the screen showing a thread that no longer exists.Not a regression from #232
git show 70805c0:ios/App/ChatView.swifthas the same shape (chat.threadIdfor the transcript,currentfor the live record). #232 moved the code without changing this.Switching bots is fine
Chat.idis the bot id, so opening a different bot pushes a distinct navigation value and builds a freshChatViewwith the right thread.The correct pattern already exists in the repo
Session.open(_ hit:)(ios/App/Session.swift:559-574) does it right for search results — it callsswitchTask, applies the frame, and then re-reads the record before handing it to navigation:The same re-read is what's missing when the switch happens with
ChatViewalready on screen.Suggested direction
Read the transcript from the live record (
current.threadId) at the six sites that currently usechat.threadId—:41,:84,:91,:126,:129,:199— and key the view's identity on the thread (.id(current.threadId), or.task(id: current.threadId)) so streaming, scroll position and the.taskblocks restart on a switch. MakingChat's equality account forthreadIdwould also let a re-appended value invalidate the navigation entry.Notes
Found while porting the companion to Android (#241). Verified against
d487882.