Stop showing Updating messages for a chat that has not changed (#9175)

* fix(app): stop showing Updating messages for a chat that has not changed

Every return to a chat fetched from the daemon just to learn nothing had
changed, and the toast covered that fetch. The subscribe request that
attaches a chat now carries its cursor, and the daemon answers current or
behind in the same round trip, so an unchanged chat needs no fetch and
shows nothing.

The daemon attaches before it compares, so a commit landing in between is
either delivered live or counted as behind. It never loads an agent to
answer: an agent it has not loaded answers behind and resumes in the
fetch, as before.

The delay before an unanswered check shows the toast starts when the
request is sent. A cold start's cache read alone can outlast the delay,
which would flash the toast for a chat the daemon then reports current.

* fix(app): keep agent details and stay quiet after a current timeline answer

The timeline fetch that a current answer skips also delivered the full agent
row, and the replica cache stores only part of it. After a cold reload with
an empty incremental directory page, usage, modes, permissions and runtime
info stayed empty. The subscribe answer now carries the row for each current
chat, gated with the rest of the freshness check.

The panel also reported ready before the timeline owner was published, so a
connected render with cached content showed Updating messages. No owner now
reads as a check that has not begun.

* test(app): pin the held running turn across a freshness check

A chat that left mid-turn holds its running footer until its messages are
current. With the freshness check, "current" is that commit with no rows, so
the tests and the timeline-sync doc now cover the check in flight, a current
answer, and a behind answer that holds until the fetched rows land.

* fix(app): keep hidden chats live while a subscription is replaced

The old timeline subscription was released in the same tick the new one
was requested. The client deletes a route on release and adds the new
route only when the daemon responds, so events sent in between were
dropped. Visible chats recover through the cursor check; hidden chats
skip it and stayed behind until something else fetched them.

Release the old subscription when the new one settles. Accepting the new
route and releasing the old one happen without a socket frame in
between, so each event reaches the client once.

* test(app): make the plugin chat reload spec independent of the freshness answer

The spec reloaded and waited for a held timeline fetch. A chat with a
cached cursor now answers current and never fetches, so the wait hung
until the test timed out, depending on whether the cursor had been saved
before the reload. Hold the subscribe request instead: the catalog still
has to arrive while the timeline is outstanding, for either answer.
This commit is contained in:
Mohamed Boudra
2026-11-09 17:56:48 +07:00
committed by GitHub
parent 339a308365
commit 9a5b4aefa4
28 changed files with 1321 additions and 104 deletions
+36 -21
View File
@@ -102,30 +102,45 @@ updated after the checkpoint does not block it. Forking the current context rema
## Resume behavior
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
authoritative range first.
Opening, reconnecting, and returning from app background establish whether the chat is current.
Switching between continuously subscribed open chats needs no fetch. Focus alone does not mutate
timeline state; the response is compared with the local authoritative range first.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
A chat with a cached cursor asks the daemon first. The `agent.timeline.set_subscription.request`
that attaches the chat carries its `{ epoch, seq }`, and the response answers `current` or `behind`
per chat. The daemon attaches before it compares, so an entry committed in between arrives live or
counts as behind. `current` means the agent is loaded, the epoch matches, and the cursor is at the
head; it completes catch-up with no fetch. The response carries the agent row for each `current`
chat, because the timeline fetch is also what refreshes that row and the replica cache stores only
part of it. `behind`, or no entry, runs the bounded catch-up. The subscribe handler never loads an
agent, so an agent the daemon has not loaded answers `behind` and resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
toast. A check on cached content shows nothing. Updating messages appears for a chat that is behind,
until the fetch completes, and for a check still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was
sent. That delay measures the daemon, so it starts at the request: on a cold start the cache read
alone can take longer. Socket connectivity alone cannot certify that the displayed conversation is
current. The timeline owner publishes freshness; the view renders it without a toast timer or a
separate resume workflow.
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
agent directory and messages from the chat's own catch-up, and either can land first. So the timeline
owner keeps an unsettled turn per chat: the turn its messages were captured during. It is set when a
catch-up becomes owed while the turn is open, or when cached messages are painted that were saved
while one was, and it is saved with those messages in the replica. It carries the turn's start time.
While it is set and the agent reads idle, the chat keeps showing that running turn, with its loader
and elapsed timer unchanged, and there is no separate catching-up footer. The commit that brings the
chat current clears it, in the same store write as the rows, so the final messages and the completed
footer land together. A failed catch-up clears it too, and the footer takes the true status. Losing
the connection is not a failed catch-up: none can run or fail until the host returns, so the running
footer stays beside "Reconnecting to host" and the catch-up on reconnect resolves it. Releasing it
offline would put a completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar, tabs, the composer's stop and send controls) read the true status
and never see this state. Live delivery needs no hold because `applyStreamEvent` commits queued rows
before it applies turn liveness.
agent directory and messages from the chat's own catch-up, and either can land first. So the
timeline owner keeps an unsettled turn per chat: the turn its messages were captured during. It is
set when a check or catch-up becomes owed while the turn is open, or when cached messages are
painted that were saved while one was, and it is saved with those messages in the replica. It
carries the turn's start time. While it is set and the agent reads idle, the chat keeps showing that
running turn, with its loader and elapsed timer unchanged, and there is no separate catching-up
footer. The commit that brings the chat current clears it, in the same store write as the rows, so
the final messages and the completed footer land together. A `current` answer is that commit with no
rows. A check in flight keeps the hold, and `behind` keeps it until the fetched rows land. A failed
catch-up clears it too, and the footer takes the true status. Losing the connection is not a failed
catch-up: none can run or fail until the host returns, so the running footer stays beside
"Reconnecting to host" and the check on reconnect resolves it. Releasing it offline would put a
completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar,
tabs, the composer's stop and send controls) read the true status and never see this state. Live
delivery needs no hold because `applyStreamEvent` commits queued rows before it applies turn
liveness.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the