A model switch persists display_kind=model_switch with role=user
(tui_gateway/server.py). Hydration renders that row as a system message
("model changed"), so the authoritative latest page holds one more message
than the window looking at the same chat. The stale-transcript guard measured
staleness as `remoteChat.length > localMessages.length`, so a session that had
switched models reported "This window was behind another view of the same
chat", refused the send, and repeated the refusal on every retry. No second
window existed, and nothing in the session was damaged.
Measure authored content instead of array length:
* hydration.ts marks a converted backend notice with ChatMessage.systemNotice,
via one NOTICE_DISPLAY_KINDS predicate that now also drives the existing
system-role decision.
* stale-transcript-guard.ts compares authoredMessageCount on both sides, so a
notice never counts as another view's work.
* use-session-actions/utils.ts classifies the new field in IGNORED_FIELDS: the
transcript paints the row from role + parts, and role is already COMPARED.
Notices still render unchanged. Tool rows folded into an assistant bubble keep
the existing behavior. A genuinely forked chat is still refused, covered by
tests.
Trade-off: a difference consisting only of notices no longer installs the page,
so a "model changed" row can wait for the next natural hydrate. The send
proceeds, which is the point of the guard.
Tests: new apps/desktop/src/lib/stale-transcript-guard.test.ts, 5 cases (the
notice regression, both real-fork cases, the identical-page case, and the
empty-page contracts). The guard had no test before. Verified with
`vitest run --project ui` (8944 passed; the 2 failures in voice-prefs.test.ts
are pre-existing and reproduce with these edits stashed) and `npm run
typecheck` (clean).