diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e043..68ab4ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Reopening a channel no longer hides the end of the last conversation + +Opening a channel joins the realtime gateway, and the snapshot the join returns can lag the durable +store. When it did, the last exchange of a finished turn was missing from the transcript on every +reload, with no unread marker or anything else to explain the gap, and the stored copy never got a +chance to replace it because history was only restored into an empty channel. + +The stored thread now wins whenever it holds more than the channel does and holds everything the +channel already shows. A message typed while history is still loading is not in the store yet, and +a run still streaming has messages the store has not seen, so neither is rolled back. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 4be9ba33..2f5be26e 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -161,12 +161,26 @@ export function ChannelChat({ channel.threadId, runtimeAgentId, ); - // Never overwrite local messages that arrived while history was loading. - if ( - current && - stored.messages.length > 0 && - agent.messages.length === 0 - ) { + /* + * The durable store wins when it is ahead of what the join delivered. + * + * The join replaces the agent's messages with the realtime gateway's snapshot of the thread, + * and that snapshot can lag the store: a turn that finished, was persisted and answered in + * full came back from the join without its last exchange, on every reload, with no + * unreadable count to explain the gap. Restoring only into an empty agent kept that stale + * snapshot for good. + * + * So the store is applied when it holds more than the agent does AND everything the agent + * holds is in the store. The second half is the guard this replaced: a message typed while + * history was loading is not in the store yet, so it is never overwritten, and a run still + * streaming has messages the store has not seen, so its snapshot is never rolled back. + */ + const local = agent.messages; + const storedIds = new Set(stored.messages.map((m) => m.id)); + const storeIsAhead = + stored.messages.length > local.length && + local.every((m) => storedIds.has(m.id)); + if (current && stored.messages.length > 0 && storeIsAhead) { agent.setMessages(stored.messages); } /*