- Shipped
- August 28, 2026 at 2:12 AM UTC
- Author
- Kamo
- Commit
- 1296159
Opening a chat window cost seven requests before a message rendered — history, members, receipts, mark-read, the unread snapshot, the counterpart's avatar and the composer policy — plus up to one translate POST per message, and on the server a PHI audit row written per message, individually. Closing the window threw all of it away, so glancing at a conversation and back paid for a hundred rows to rebuild the list that had just been discarded. Minimizing was never the expensive case: the dock keeps a minimized window mounted. Closing was, and so was the maximized navigator, which opens and demotes windows all day. The window's lifetime and the conversation's are not the same thing. A window now ACQUIRES a conversation and RELEASES it, and the conversation outlives the release by five minutes (@kamo/tool-core's keep-alive registry, bounded by time AND count, never evicting one a window is still showing). Reopening inside that costs nothing, and because the socket was never dropped what comes back is a current conversation rather than a cached one. Past the keep-alive, chat-core's resume() asks only for what arrived after the newest message it holds. The same registry now carries the four windows that each hand-rolled the same bug: SMS, AI, support tickets and social all held their thread in a useState and changes is that the re-fetch happens over the conversation instead of over a spinner. On social that was more than a flash: blanking the list also blanked lastInboundTs, so the composer read Meta's 24-hour reply window as CLOSED on a conversation that was perfectly repliable. The render READS the conversation and the effect HOLDS it. Only an effect guarantees a matching release — a discarded render's hold would be a subscription nothing ever lets go of — but an effect runs after the first paint, which would leave a reopened window flashing empty for a frame before showing what it already had. Reading is not holding, so the peek is safe where an acquire is not. The acquire waits for a known viewer. getMemberIdString() answers '' until useUserInfo loads, and myMemberId decides whether each message is the viewer's own; the old code built the conversation anyway and REBUILT it when the real id arrived, which a registry keyed on the session cannot do. Not building one under an identity we do not have yet is the honest version, and it drops a wasted full load. Also off the per-open path: the unread re-read now fires only when a conversation genuinely starts, not when a window adopts a live one whose unread is already zero and held there; member avatars are memoized as promises, so N windows share one directory read and a reopen costs none; and the composer lock is seeded from the last answer, so a window over a frozen conversation stops rendering an open composer that snaps shut a round trip later. What survives a hard reload is metadata and nothing else. conversationSnapshot holds participant NAMES — which toolWindowSnapshot already persists inside the window title — and two booleans of composer state, so a restored window is not a titled conversation with nobody in it. No message bodies, ever: MediaService audits chat reads as PHI, and a transcript in storage is a disclosure with no read behind it. Bodies are always re-fetched. Sign-out drops the live conversations too, which clearing storage cannot reach. The BFF stops parsing a hundred-message page into objects and serializing them straight back for no change to the bytes, and carries an ETag so a repeat read of an unchanged page costs a validator instead of a transcript. Upstream is still called every time — the OTK is single-use and MediaService is the only thing that can say whether this member may read this session, so a 304 served without asking would be a cache answering authorization questions.