From 6110c6dbd209e982fb09ca0a4966e0b1cba501a1 Mon Sep 17 00:00:00 2001 From: Sajal Narang Date: Tue, 13 Oct 2026 05:21:20 +0000 Subject: [PATCH] fix(dashboard): let reading a session clear a stuck unread dot A shared unread watermark newer than every row the gateway still reports can never be covered by a read, so opening the session clears the dot and the window's own slot_read echo lights it again. Builds before #15014 left such watermarks from a turn's unsaved done row. A read now retires the watermark its dot carried when a page the gateway built after the read began has no row as new as it: the transcript switchSlot already fetches, or a one-row page for Mark as read. A page whose rows carry no time, such as a turn still streaming, proves nothing. Mark as read relays the watermark it captured at the click, so a row that arrives while its page is in flight stays unread. A read that may retire first writes a fresh per-slot marker, and retires nothing if it cannot or if the marker changed while it waited: every new message removes the marker before the tombstone, and the retire checks the marker before its write and again after, removing the tombstone if it changed. The read removes its own marker when it is done. Removing the session, or an authoritative slot list that drops a session this window listed, removes both per-slot keys. The retire never rewrites the shared record, which another window may be writing. It records the disproved value in a key of its own per slot, and every reader of the record treats a matching entry as absent. The slot's next message removes that key, so a value that comes back after it shows a dot. Another window already showing the dot keeps it until that window reloads or reads the session. --- .../system-specs/modules/app-notifications.md | 18 + website/src/hooks/useSessionActions.ts | 12 +- website/src/lib/slotReadRelay.ts | 37 +- .../members/MembersPage.openThread.test.tsx | 22 + website/src/store/chat/slotSwitch.ts | 26 +- website/src/store/dashboard/readSlot.ts | 44 + website/src/store/dashboard/selectors.ts | 89 ++ website/src/store/dashboard/unread.ts | 78 +- website/src/store/dashboardSlice.ts | 157 ++-- website/src/store/unreadArrivalMarker.ts | 18 + website/src/test/slotReadRelay.test.tsx | 305 ++++++- .../src/test/unreadOrphanedWatermark.test.ts | 843 ++++++++++++++++++ 12 files changed, 1528 insertions(+), 121 deletions(-) create mode 100644 website/src/store/dashboard/readSlot.ts create mode 100644 website/src/store/dashboard/selectors.ts create mode 100644 website/src/store/unreadArrivalMarker.ts create mode 100644 website/src/test/unreadOrphanedWatermark.test.ts diff --git a/docs/system-specs/modules/app-notifications.md b/docs/system-specs/modules/app-notifications.md index 30e0aeb13cd..3998f7591d6 100644 --- a/docs/system-specs/modules/app-notifications.md +++ b/docs/system-specs/modules/app-notifications.md @@ -149,6 +149,24 @@ Settings › Notifications › "Mark sessions unread only when they need you" (` A member DM thread (`slot.mode === 'member'`, key prefix `member-`, which `slot_registry.py` reserves for that mode) is narrower in both modes: `memberThreadRowMarksUnread` badges it only on a row the mate wrote to the user, `assistant` or `permission`, with the opt-in still applied on top. Its `tool_call`, `tool_result`, `inject` and streaming rows never badge it: the Mates rail dot means "a mate said something to you", not "a mate ran a tool". `isMemberThreadSlot` reads the mode from `dashboard.slots` and falls back to the reserved prefix for a thread the list has not caught up to. Its `chat_done` is narrowed the same way (`turnCompletion.ts`): `chatStream` records every such row on the thread's running turn (`noteMemberThreadRow`, on or off screen), and the finished turn badges only when the turn delivered one (`takeMemberThreadSpoke`, which also clears the record) or paused for input (`needs_input` / a live question card). A patrol turn that only ran tools and ended quietly badges nothing. The question-card path is unchanged. +### Shared unread record and retired watermarks + +Windows of one browser share unread state through `localStorage`. `store/dashboard/unread.ts` reads and writes the record and its tombstones, `store/unreadArrivalMarker.ts` the markers, and the reducers in `store/dashboardSlice.ts` apply the ordering rules below. Three key families take part: + +- `mc-unread-shared` is one JSON document mapping slot to message watermark, or `''` for a badge no watermark guards. Writes are per-slot deltas where the newest parseable timestamp wins, and a `''` arrival never demotes a real watermark. `mc-unread-slots` is a write-only projection of its keys for the hub relay and older tabs. +- `mc-unread-orphaned:` is a tombstone. It holds the exact watermark the gateway proved names no row, which only builds before #15014 could save. Every reader of `mc-unread-shared` treats an entry equal to its slot's tombstone as absent. +- `mc-unread-arrival:` is a per-read marker. `readSlot` writes a fresh one before it asks the gateway for proof, and removes it afterwards if it is still its own. + +The ordering rules: + +1. An arrival (`markSlotUnread`) on a slot that holds a tombstone writes to `mc-unread-shared` first. Any other arrival queues the write (`queueSharedUnread`), and when the queue flushes its merge treats a value equal to a tombstone written in the meantime as absent. Either way the arrival then clears the marker, then the tombstone. +2. `retireSlotUnread` checks that the marker is still the read's own, writes the tombstone, and checks the marker again. If the marker changed, it removes the tombstone it wrote. +3. Closing a slot and `reconcileSlots`' departed branch remove both per-slot keys. + +The marker exists because the shared record cannot tell an orphan from an absorbed arrival. An arrival that publishes `''` or an older timestamp leaves the stored watermark unchanged, so deleting the entry when it still equals the disproved value would also clear a real new message's dot. The marker, not the stored value, records that something arrived during the read. + +Removal condition: the tombstone and marker keys, and the readers' tombstone filter, can go once `mc-unread-shared` is migrated in a way that discards entries saved before #15014, such as a versioned record that drops unversioned entries on first read. Until then a saved orphan can surface on any later read, so the path stays. + ## Plain-text previews The native notification body, feed-row preview and transcript turn minimap share diff --git a/website/src/hooks/useSessionActions.ts b/website/src/hooks/useSessionActions.ts index d65f1f0f19d..9b2696efa3d 100644 --- a/website/src/hooks/useSessionActions.ts +++ b/website/src/hooks/useSessionActions.ts @@ -5,9 +5,8 @@ import { apiErrorCode, isSlotCapError } from '../api/apiError' import { store, useAppDispatch } from '../store' import type { MuteFlag } from '../store/dashboardSlice' import { captureForkOrigin, deleteSlot, forkMayOpen, forkOpenSettled, forkOpenStarted, listForkedSlot, requestSlotFolderOpen, switchSlot } from '../store/chatSlice' -import { updateSlotPin, updateSlotMuteFlag, setSlotMuteFlagError, markSlotRead, markSlotUnread, slotWriteStampOf } from '../store/dashboardSlice' -import { slotLastTsOf } from '../store/slotLastTs' -import { emitSlotRead } from '../lib/slotReadRelay' +import { updateSlotPin, updateSlotMuteFlag, setSlotMuteFlagError, markSlotUnread, slotWriteStampOf } from '../store/dashboardSlice' +import { readSlot } from '../store/dashboard/readSlot' import { copySessionLink } from '../utils/shareUrl' import { useMoveSlotToFolder } from './useMoveSlotToFolder' import { loadChatConfig } from '../pages/chat/ChatSettings' @@ -458,15 +457,12 @@ export function useSessionActions(mode?: string, opts?: { const toggleRead = useCallback((slotKey: string) => { const isUnread = store.getState().dashboard.unreadSlots.includes(slotKey) - dispatch(isUnread ? markSlotRead(slotKey) : markSlotUnread(slotKey)) // Read direction relays to other windows (a deliberate "I've seen this"), // watermarked at the slot's newest known message ts. The unread direction // stays window-local — markSlotUnread's string form records the manual // sentinel, so no other window's relayed read can clear the reminder. - if (isUnread) { - const slotTs = slotLastTsOf(store.getState().dashboard, slotKey) - emitSlotRead(slotKey, slotTs) - } + if (isUnread) void dispatch(readSlot(slotKey)) + else dispatch(markSlotUnread(slotKey)) }, [dispatch]) const togglePin = useCallback((slotKey: string) => { diff --git a/website/src/lib/slotReadRelay.ts b/website/src/lib/slotReadRelay.ts index 49b4cb52809..201e13d606f 100644 --- a/website/src/lib/slotReadRelay.ts +++ b/website/src/lib/slotReadRelay.ts @@ -63,29 +63,26 @@ export const newerTs = (a?: string, b?: string): string | undefined => { return pb > pa ? b : a } -/** Relay "this slot was read here" to every other open dashboard window. - * - * `readTs` is the read WATERMARK: the newest message timestamp this window - * knows for the slot at the moment of the read. Receivers keep any badge - * their window recorded for a NEWER message, so an in-flight relay can never - * erase a message the reader had not seen (the classic race: A reads N and - * switches away, N+1 badges B, A's relay lands after). A coalesced burst - * carries the newest watermark seen during the window. */ -export function emitSlotRead(slot: string, readTs?: string): void { - if (!slot) return +/** The watermark a read made now carries. */ +export function slotReadWatermark(slot: string, readTs?: string): string | undefined { // Every caller reads the slot on screen, so it has also seen any unsaved row // this window received for it; carry that ts so the read covers it. - readTs = newerTs(readTs, unsavedRowTs.get(slot)) + return newerTs(readTs, unsavedRowTs.get(slot)) +} + +/** Relay an already captured watermark without folding in later arrivals. */ +export function relaySlotReadWatermark(slot: string, watermark?: string): void { + if (!slot) return const entry = pending.get(slot) if (entry) { entry.again = true // coalesce into one trailing send at quiet-window end - entry.ts = newerTs(entry.ts, readTs) + entry.ts = newerTs(entry.ts, watermark) return } - sendSlotReadImpl(slot, readTs) + sendSlotReadImpl(slot, watermark) pending.set(slot, { again: false, - ts: readTs, + ts: watermark, timer: setTimeout(() => { const e = pending.get(slot) pending.delete(slot) @@ -96,6 +93,18 @@ export function emitSlotRead(slot: string, readTs?: string): void { }) } +/** Relay "this slot was read here" to every other open dashboard window. + * + * `readTs` is the read WATERMARK: the newest message timestamp this window + * knows for the slot at the moment of the read. Receivers keep any badge + * their window recorded for a NEWER message, so an in-flight relay can never + * erase a message the reader had not seen (the classic race: A reads N and + * switches away, N+1 badges B, A's relay lands after). A coalesced burst + * carries the newest watermark seen during the window. */ +export function emitSlotRead(slot: string, readTs?: string): void { + relaySlotReadWatermark(slot, slotReadWatermark(slot, readTs)) +} + /** Flush a slot's pending trailing relay NOW (every slot when omitted). * * Called when a slot stops being the visible active one (active-slot switch, diff --git a/website/src/pages/members/MembersPage.openThread.test.tsx b/website/src/pages/members/MembersPage.openThread.test.tsx index ee25e6bf270..052ee18d148 100644 --- a/website/src/pages/members/MembersPage.openThread.test.tsx +++ b/website/src/pages/members/MembersPage.openThread.test.tsx @@ -468,6 +468,28 @@ describe('MembersPage unread drain', () => { ) }) + it('viewing a thread never asks the gateway about the dot, even one its row cannot cover', async () => { + const { store } = await renderPage() + fireEvent.click(await rosterRow('oncall')) + await screen.findByTestId('chat-pane-stub', undefined, PANE_READY) + act(() => { + store.dispatch(sseSlots([{ + key: 'member-oncall', + running: false, + messages: 1, + last_ts: '2026-09-10T00:00:01Z', + }])) + store.dispatch(markSlotUnread({ + slot: 'member-oncall', + ts: '2026-09-10T00:00:02Z', + })) + }) + await waitFor(() => + expect(store.getState().dashboard.unreadSlots).not.toContain('member-oncall'), + ) + expect(vi.mocked(api.chatSlotDetail).mock.calls.filter(([key, limit]) => key === 'member-oncall' && limit === 1)).toEqual([]) + }) + it('drains ONLY the mounted thread — other slots keep their unread flags', async () => { const { store } = await renderPage() act(() => { diff --git a/website/src/store/chat/slotSwitch.ts b/website/src/store/chat/slotSwitch.ts index 22e39d2fdeb..c287b10c9e1 100644 --- a/website/src/store/chat/slotSwitch.ts +++ b/website/src/store/chat/slotSwitch.ts @@ -8,7 +8,7 @@ import type { RootState } from '../index' import type { ChatMessage } from '../../types' import { emitSlotRead } from '../../lib/slotReadRelay' import { devLog, inspectorOn } from '../../dev/scrollInspector' -import { armConfirmedCloseHold, markSlotRead, removeSlotOptimistic } from '../dashboardSlice' +import { armConfirmedCloseHold, clearUnreadArrival, holdUnreadArrival, markSlotRead, orphanedWatermark, readUnreadArrival, removeSlotOptimistic, retireSlotUnread, watermarkDisprovedBy } from '../dashboardSlice' import { slotLastTsOf } from '../slotLastTs' import { mergePreservedPastes } from '../../utils/pasteTokens' import { errMessage, isMissingSlotError, type StatusRejection } from '../../utils/thunkError' @@ -196,6 +196,12 @@ export const switchSlot = createAsyncThunk< // Safe unconditionally: this fetch resets the pane's messages and cursor, so // any older page still in flight is superseded even when the key is unchanged. abortActiveOlderFetch() + // Read before markSlotRead clears it, so a saved dot the fetched + // transcript cannot account for can be retired after the fetch. + const heldWatermark = (getState() as RootState).dashboard?.unreadSince?.[key] + const heldArrival = orphanedWatermark(heldWatermark, slotLastTsOf((getState() as RootState).dashboard, key)) === undefined + ? undefined + : holdUnreadArrival(key) dispatch(markSlotRead(key)) // Opening a session is the canonical read gesture: relay it so every // other open dashboard window retires this slot's unread bubble too — @@ -259,10 +265,22 @@ export const switchSlot = createAsyncThunk< // badges for messages nobody displayed. `pending` assigns activeSlot // atomically before this thunk body runs, so a superseded request // observes someone else's key here. - if ((getState() as { chat: ChatState }).chat.activeSlot === key) emitSlotRead(key, _newestSlotTs()) + if ((getState() as { chat: ChatState }).chat.activeSlot === key) { + const orphan = watermarkDisprovedBy(heldWatermark, first.messages) + if (orphan && heldArrival !== undefined) { + dispatch(retireSlotUnread({ slot: key, watermark: orphan, arrival: heldArrival })) + } + emitSlotRead(key, _newestSlotTs()) + } return { ...walked, comparableTotal: first.total } } - if ((getState() as { chat: ChatState }).chat.activeSlot === key) emitSlotRead(key, _newestSlotTs()) + if ((getState() as { chat: ChatState }).chat.activeSlot === key) { + const orphan = watermarkDisprovedBy(heldWatermark, first.messages) + if (orphan && heldArrival !== undefined) { + dispatch(retireSlotUnread({ slot: key, watermark: orphan, arrival: heldArrival })) + } + emitSlotRead(key, _newestSlotTs()) + } return first } catch (e) { // A thrown error crosses the thunk boundary as `miniSerializeError(e)`, @@ -396,6 +414,8 @@ export const switchSlot = createAsyncThunk< })) } throw e + } finally { + if (heldArrival !== undefined && readUnreadArrival(key) === heldArrival) clearUnreadArrival(key) } }, ) diff --git a/website/src/store/dashboard/readSlot.ts b/website/src/store/dashboard/readSlot.ts new file mode 100644 index 00000000000..1b00e633fae --- /dev/null +++ b/website/src/store/dashboard/readSlot.ts @@ -0,0 +1,44 @@ +import { api } from '../../api/client' +import { emitSlotRead, relaySlotReadWatermark, slotReadWatermark } from '../../lib/slotReadRelay' +import { withDeadline } from '../../lib/withDeadline' +import { markSlotRead, retireSlotUnread, type DashboardState } from '../dashboardSlice' +import { slotLastTsOf } from '../slotLastTs' +import { clearUnreadArrival, holdUnreadArrival, readUnreadArrival } from '../unreadArrivalMarker' +import { UNREAD_PROOF_TIMEOUT_MS, orphanedWatermark, watermarkDisprovedBy } from './unread' + +/** A read gesture that shows no transcript (Mark as read): clear this window's + * dot, then relay the read at the click-time last_ts. A watermark that + * last_ts cannot cover needs the gateway's word first: a one-row page fetched + * after `held` was captured includes every row a saved watermark can name, so + * an orphan tombstone is recorded before the relay and its echo cannot light + * the dot again. The relay carries the watermark captured at the click, so a + * row that arrives while the proof is in flight stays unread. A proof that + * outlives UNREAD_PROOF_TIMEOUT_MS counts as no proof, so the relay still goes out. */ +export const readSlot = (key: string) => ( + dispatch: (action: ReturnType | ReturnType) => unknown, + getState: () => unknown, +): Promise | void => { + const dashboard = (getState() as { dashboard?: DashboardState } | undefined)?.dashboard + const held = dashboard?.unreadSince?.[key] + const readTs = slotLastTsOf(dashboard, key) + const orphanSuspected = orphanedWatermark(held, readTs) !== undefined + const heldArrival = orphanSuspected ? holdUnreadArrival(key) : undefined + if (dashboard?.unreadSlots?.includes(key)) dispatch(markSlotRead(key)) + if (!orphanSuspected) { + emitSlotRead(key, readTs) + return + } + const relayWatermark = slotReadWatermark(key, readTs) + return withDeadline(UNREAD_PROOF_TIMEOUT_MS, undefined, (signal) => api.chatSlotDetail(key, 1, undefined, signal)) + .then((page) => { + const orphan = watermarkDisprovedBy(held, page.messages) + if (!orphan || heldArrival === undefined) return + dispatch(retireSlotUnread({ slot: key, watermark: orphan, arrival: heldArrival })) + }) + // No proof, so the saved dot keeps its guard; the read still relays. + .catch(() => undefined) + .finally(() => { + if (heldArrival !== undefined && readUnreadArrival(key) === heldArrival) clearUnreadArrival(key) + }) + .then(() => { relaySlotReadWatermark(key, relayWatermark) }) +} diff --git a/website/src/store/dashboard/selectors.ts b/website/src/store/dashboard/selectors.ts new file mode 100644 index 00000000000..79b776346b7 --- /dev/null +++ b/website/src/store/dashboard/selectors.ts @@ -0,0 +1,89 @@ +import { createSelector } from '@reduxjs/toolkit' +import type { ChatSlot } from '../../types' +import { isChatPageSurface } from '../../utils/channelOrigin' +import type { DashboardState } from '../dashboardSlice' +import { slotSurfaceKey } from '../slotIdentity' +import { heldOrphanTest } from './unread' + +/** Read-side slot selectors: the unread count per surface and the slot-list + * wait. `dashboardSlice` re-exports them under the names callers import + * from it. */ + +/** + * Count unread slots whose surface matches `mode`. Slots present in + * `unreadSlots` but missing from `slots` (e.g. deleted but not yet drained) + * are treated as the default chat surface (`""`) so they keep contributing + * to the Chat badge rather than vanishing silently. + * + * Note — intentional asymmetry with `filterUnreadKeysBySurface` in + * `surfaces/registry.ts`: that helper drops orphan keys (the sidebar can't + * display them regardless), whereas this one keeps them so the badge stays + * stable across the brief race between `removeSlotOptimistic` and + * `fetchSlots.fulfilled`. + * + * The exception is an orphan no reconcile will drain: any orphan while the + * gateway labels its lists partial, or one the gateway holds (`slotsHeldKeys`, or a + * channel key under `slotsHoldChannels`). The premise above does not hold for it. Its unread entry is kept so the dot returns with the tab, but + * it is left out of the badge, which would otherwise count a session the + * sidebar cannot show. + */ +function countUnreadByMode( + slots: ChatSlot[], + unread: string[], + mode: string, + heldKeys: readonly string[] = [], + holdChannels = false, + listPartial = false, +): number { + if (unread.length === 0) return 0 + const surfaceByKey = new Map(slots.map(s => [s.key, slotSurfaceKey(s)])) + const isHeldOrphan = heldOrphanTest(new Set(surfaceByKey.keys()), heldKeys, holdChannels, listPartial) + // The chat surface ('') counts every slot the chat page renders, legacy + // Autopilot slots included (see `isChatPageSurface`). + const isChatSurface = mode === '' + let count = 0 + for (const k of unread) { + if (isHeldOrphan(k)) continue + const sk = surfaceByKey.get(k) ?? '' + if (isChatSurface ? isChatPageSurface(sk) : sk === mode) count++ + } + return count +} + +/** + * Memoized factory for "unread count for slots whose surface === mode". + * One memo cache per `mode` argument so registry surfaces don't trash each + * other's memoization. Built-in nav badges should not call this directly — + * they go through `selectSurfaceBadgeCount(navId)` from `surfaces/registry`, + * which routes to this factory only when a surface declares `slotMode`. + */ +type UnreadByModeSelector = (state: { dashboard: DashboardState }) => number +const _unreadByModeCache = new Map() +export function selectUnreadByMode(mode: string): UnreadByModeSelector { + let sel = _unreadByModeCache.get(mode) + if (!sel) { + sel = createSelector( + (state: { dashboard: DashboardState }) => state.dashboard.slots, + (state: { dashboard: DashboardState }) => state.dashboard.unreadSlots, + (state: { dashboard: DashboardState }) => state.dashboard.slotsHeldKeys, + (state: { dashboard: DashboardState }) => state.dashboard.slotsHoldChannels, + (state: { dashboard: DashboardState }) => state.dashboard.slotsListPartial, + (slots, unread, heldKeys, holdChannels, listPartial) => + countUnreadByMode(slots, unread, mode, heldKeys ?? [], holdChannels === true, listPartial === true), + ) + _unreadByModeCache.set(mode, sel) + } + return sel +} + +/** + * True while the page has never seen a slot list and one may still arrive, so a + * pane with no open session cannot yet tell "no chats" from "chats loading". + * `slotsGeneration`, not `slotsLoaded`: a socket reconnect resets the latter, + * and a gateway whose list already landed is not waited on again. A failed + * fetch with none left in flight ends the wait. + */ +export const selectSlotListPending = (state: { dashboard: DashboardState }): boolean => { + const d = state.dashboard + return !d.slotsLoaded && (d.slotsGeneration ?? 0) === 0 && !d.slotsFetchFailed +} diff --git a/website/src/store/dashboard/unread.ts b/website/src/store/dashboard/unread.ts index b66c5cba896..65b034f7af8 100644 --- a/website/src/store/dashboard/unread.ts +++ b/website/src/store/dashboard/unread.ts @@ -1,7 +1,8 @@ -import { safeSetItem, safeSetSessionItem } from '../../utils/safeStorage' +import { safeRemoveItem, safeSetItem, safeSetSessionItem } from '../../utils/safeStorage' import { newerTs } from '../../lib/slotReadRelay' import { slotListHoldTest } from '../../utils/slotListHold' import type { DashboardState } from '../dashboardSlice' +import { clearUnreadArrival } from '../unreadArrivalMarker' /** The unread record behind the session badges: its Web Storage persistence, * the count relayed to the Instances hub, and the drain and held-orphan @@ -24,6 +25,22 @@ export const readCovers = (read: string | undefined, since: string): boolean => const s = Date.parse(since) return Number.isFinite(r) && Number.isFinite(s) && r >= s } +const UNREAD_ORPHAN_KEY_PREFIX = 'mc-unread-orphaned:' + +/** Read the exact shared watermark the gateway proved names no row. */ +export const readUnreadOrphan = (slot: string): string | undefined => { + try { return localStorage.getItem(UNREAD_ORPHAN_KEY_PREFIX + slot) ?? undefined } catch { return undefined } +} + +/** A tombstone has its own slot key, so windows retiring different slots never + * overwrite each other. Windows retiring the same slot write the same proven + * value. A tombstone whose slot holds another value matches nothing and stays + * as one short key. */ +export const recordUnreadOrphan = (slot: string, watermark: string): boolean => + safeSetItem(UNREAD_ORPHAN_KEY_PREFIX + slot, watermark) + +export const clearUnreadOrphan = (slot: string): void => + safeRemoveItem(UNREAD_ORPHAN_KEY_PREFIX + slot) /** THE shared unread record ('mc-unread-shared' in localStorage): slot -> @@ -43,7 +60,9 @@ export const readCovers = (read: string | undefined, since: string): boolean => * sentinels persist to per-tab sessionStorage via persistManualSentinels. * 'mc-unread-slots' is kept as a write-only PROJECTION of the record's * keys — the pre-existing hub relay (safeSet) and tabs still running - * older code read it; nothing in this file does. + * older code read it; nothing in this file does. A value equal to its + * per-slot tombstone is one the gateway disproved, and every reader here + * treats it as absent. * * Synchronous. Message arrivals go through queueSharedUnread instead; this * call writes any queued arrivals first, so a read or drain that follows an @@ -123,7 +142,7 @@ const writeSharedUnread = (add: Record, remove: readonly string[ } for (const [k, v] of Object.entries(add)) { if (v === MANUAL_UNREAD) continue // sentinels never publish - const prev = stored[k] + const prev = stored[k] === readUnreadOrphan(k) ? undefined : stored[k] if (prev === undefined) { stored[k] = v; changed = true; continue } if (v === '') continue // presence already recorded; never demote a watermark const next = prev === '' ? v : (newerTs(prev, v) ?? prev) @@ -207,7 +226,9 @@ export const restoreUnreadSince = (): Record => { if (legacy.length > 0) safeSetItem('mc-unread-shared', JSON.stringify(record)) } const since: Record = {} - for (const [k, v] of Object.entries(record)) if (v !== '') since[k] = v + for (const [k, v] of Object.entries(record)) { + if (v !== '' && v !== readUnreadOrphan(k)) since[k] = v + } let manual: Record try { manual = JSON.parse(sessionStorage.getItem('mc-unread-since') ?? '{}') as Record } catch { manual = {} } for (const [k, v] of Object.entries(manual)) if (v === MANUAL_UNREAD) since[k] = MANUAL_UNREAD @@ -220,7 +241,10 @@ export const restoreUnreadSince = (): Record => { * without writing shared state. */ export const restoreUnreadBadges = (since: Record): string[] => { let badges: string[] - try { badges = Object.keys(JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}') as Record) } catch { badges = [] } + try { + const record = JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}') as Record + badges = Object.keys(record).filter(k => record[k] !== readUnreadOrphan(k)) + } catch { badges = [] } for (const [k, v] of Object.entries(since)) { if (v === MANUAL_UNREAD && !badges.includes(k)) badges.push(k) } @@ -266,6 +290,14 @@ export const drainUnreadKeys = (state: DashboardState, keys: Set): void _relayUnreadToParent(JSON.stringify(relayedUnreadKeys(state))) } +/** Forget slots that are gone for good: their shared entries, tombstones and + * arrival markers. Slot keys are never reused, so nothing can need them again. */ +export const forgetUnreadSlots = (keys: readonly string[]): void => { + if (keys.length === 0) return + persistSharedUnread({}, keys) + for (const key of keys) { clearUnreadOrphan(key); clearUnreadArrival(key) } +} + /** True for an unread key whose slot is absent and that the next list will not * drain: every orphan while the gateway labels its lists partial, and otherwise * the keys the gateway holds. Counts shown to the user skip these: the entry is @@ -306,3 +338,39 @@ export const relayUnreadIfHolding = ( ): void => { if (holding) _relayUnreadToParent(JSON.stringify(relayedUnreadKeys(state))) } + +/** Newest parseable timestamp in a gateway transcript page. */ +export const newestRowTs = (messages: ReadonlyArray<{ ts?: string }>): string | undefined => { + let newest: string | undefined + for (const message of messages) { + if (!message.ts || !Number.isFinite(Date.parse(message.ts))) continue + newest = newerTs(newest, message.ts) + } + return newest +} + +/** A saved message watermark the gateway response cannot account for. + * The response was built after the gesture captured `held`, so it includes + * every gateway row that watermark can name. Manual and empty marks are not + * message watermarks and are never retired. */ +export const orphanedWatermark = ( + held: string | undefined, + newestTs: string | undefined, +): string | undefined => + held && held !== MANUAL_UNREAD && !readCovers(newestTs, held) ? held : undefined + +/** The saved watermark a gateway page disproves. A page whose rows carry no + * time, such as a turn still streaming, proves nothing, and neither does a + * reply without a rows array. */ +export const watermarkDisprovedBy = ( + held: string | undefined, + rows: ReadonlyArray<{ ts?: string }> | undefined, +): string | undefined => { + if (!Array.isArray(rows)) return undefined + const newest = newestRowTs(rows) + if (newest === undefined && rows.length > 0) return undefined + return orphanedWatermark(held, newest) +} + +/** How long a read waits for the gateway's proof before it relays anyway. */ +export const UNREAD_PROOF_TIMEOUT_MS = 10_000 diff --git a/website/src/store/dashboardSlice.ts b/website/src/store/dashboardSlice.ts index 32f2b21148d..1632e609ed1 100644 --- a/website/src/store/dashboardSlice.ts +++ b/website/src/store/dashboardSlice.ts @@ -1,18 +1,18 @@ import { safeSetItem } from '../utils/safeStorage' +import { clearUnreadArrival, holdUnreadArrival, readUnreadArrival } from './unreadArrivalMarker' import { newerTs } from '../lib/slotReadRelay' import { jsonEqual } from '../utils/structuralEqual' -import { slotSurfaceKey } from './slotIdentity' -import { createSlice, createAsyncThunk, createSelector, type PayloadAction } from '@reduxjs/toolkit' +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit' import { api } from '../api/client' import { streamFlush } from './streamFlush' import { ApiError } from '../api/apiError' import { isUnsafeKey } from '../utils/sanitize' import type { StatusData, ChatSlot, TodoList, McpSessionReport } from '../types' import { bumpLastTsOverlay, dropCaughtUpLastTs, pruneLastTsOverlay, slotLastTsOf } from './slotLastTs' +export { clearUnreadArrival, holdUnreadArrival, readUnreadArrival } import type { SessionColorMode, PaletteName, DefaultColorSetting, IntensityName } from '../utils/sessionColors' -import { isChatPageSurface } from '../utils/channelOrigin' import { slotListHoldTest } from '../utils/slotListHold' -import { MANUAL_UNREAD, _relayUnreadToParent, clearSharedUnreadIfCovered, drainUnreadKeys, dropQueuedSharedUnreadCoveredBy, hasUnreadRelayHold, heldOrphanTest, persistManualSentinels, persistSharedUnread, queueSharedUnread, readCovers, relayUnreadCount, relayUnreadIfHolding, relayedUnreadKeys, restoreUnreadBadges, restoreUnreadSince } from './dashboard/unread' +import { MANUAL_UNREAD, _relayUnreadToParent, clearSharedUnreadIfCovered, clearUnreadOrphan, drainUnreadKeys, dropQueuedSharedUnreadCoveredBy, forgetUnreadSlots, hasUnreadRelayHold, persistManualSentinels, persistSharedUnread, queueSharedUnread, readCovers, readUnreadOrphan, recordUnreadOrphan, relayUnreadCount, relayUnreadIfHolding, relayedUnreadKeys, restoreUnreadBadges, restoreUnreadSince } from './dashboard/unread' /** A close tombstone entry (see `applySlots`). */ interface CloseHold { @@ -216,7 +216,8 @@ export interface DashboardState { } const safeGet = (key: string, fallback: string) => { try { return localStorage.getItem(key) ?? fallback } catch { return fallback } } -export { MANUAL_UNREAD, restoreUnreadBadges, restoreUnreadSince } from './dashboard/unread' +export { MANUAL_UNREAD, orphanedWatermark, restoreUnreadBadges, restoreUnreadSince, watermarkDisprovedBy } from './dashboard/unread' +export { selectSlotListPending, selectUnreadByMode } from './dashboard/selectors' const safeSet = (key: string, value: string) => { try { safeSetItem(key, value) } catch { /* QuotaExceededError / SecurityError */ } if (key === 'mc-unread-slots') _relayUnreadToParent(value) @@ -318,6 +319,7 @@ const reconcileSlots = ( unreadDrainHeld = state.unreadDrainHeld, heldKeys = state.slotsHeldKeys, holdChannels = state.slotsHoldChannels, + previousKeys: Iterable = [], ): void => { // `countUnreadByMode` deliberately keeps orphan unread keys contributing to // the badge, on the premise that a reconcile drains them shortly. Draining on @@ -331,12 +333,26 @@ const reconcileSlots = ( // a GET reply too, since it goes through this same path. Deferring the drain // is safe: the next complete list drains whatever is genuinely gone. const holds = slotListHoldTest({ partial: unreadDrainHeld, heldKeys: heldKeys ?? [], holdChannels }) - drainUnreadKeys(state, new Set((state.unreadSlots ?? []).filter(key => !liveKeys.has(key) && !holds(key)))) + const drained = new Set((state.unreadSlots ?? []).filter(key => !liveKeys.has(key) && !holds(key))) + drainUnreadKeys(state, drained) + // Slot keys are never reused, so a drained slot's per-slot tombstone and + // arrival marker name a row that can never come back: they would sit in + // localStorage for the tab's lifetime. Bounded to the slots this drain + // already names, with no key scan, because this runs on every slots frame. + for (const k of drained) { clearUnreadOrphan(k); clearUnreadArrival(k) } // Eviction is NOT recoverable, so it is skipped when the caller cannot vouch // for the list's freshness: an HTTP reply in flight can be older than the live // frames that arrived while it travelled, and would then delete a slot the // stream has since created. if (!evictStale) return + // A retired slot has already left `unreadSlots`, so the drain above never + // names it; its shared entry and per-slot keys go when a live-stream list + // drops a slot this window had listed. Gated like eviction: a stale reply that + // predates a new slot must not strip the tombstone that slot's retire wrote. + // Held keys are kept because the session may still exist: a list the gateway + // labelled partial names a session it has not restored yet. One deleted while + // the list was partial leaves its entry and tombstone behind, lighting no dot. + forgetUnreadSlots([...previousKeys].filter(k => !liveKeys.has(k) && !holds(k))) // A write stamp for a key the authoritative list has // dropped protects nothing, and keeping it would let the record grow for the // tab's lifetime. Pruned only when the caller vouches for the list, so an HTTP @@ -1083,11 +1099,12 @@ const dashboardSlice = createSlice({ // the sidebar until restoration finishes, and marking it loaded would // claim a snapshot arrived when none has. if (action.payload.length === 0 && !state.slotsLoaded) return + const listedBefore = (state.slots ?? []).map(s => s.key) // row-read: keys this window listed, for per-slot key cleanup const rows = retainUnlistedMembers(state, action.payload) applySlots(state, rows) state.slotsGeneration = (state.slotsGeneration ?? 0) + 1 state.slotsLoaded = true - reconcileSlots(state, new Set(rows.map(s => s.key))) + reconcileSlots(state, new Set(rows.map(s => s.key)), true, state.unreadDrainHeld, state.slotsHeldKeys, state.slotsHoldChannels, listedBefore) }, // Sidebar → shortcut and close-landing order feed (see // DashboardState.sidebarOrder). The dispatch site diff-guards, so every @@ -1182,6 +1199,9 @@ const dashboardSlice = createSlice({ // merely absent from a partial list, it is safe to drain during restart // and before the first complete snapshot. drainUnreadKeys(state, gone) + // A removal is durable, so a retired slot's shared entry and per-slot + // keys go too, even before the first snapshot: holds cover absence, never deletion. + forgetUnreadSlots([...gone]) if (gone.size && state.slotsLoaded) { for (const key of gone) { if (!isUnsafeKey(key) && !state.closingSlots?.[key]) { @@ -1296,6 +1316,12 @@ const dashboardSlice = createSlice({ if (wasManual) persistManualSentinels(state.unreadSince) } persistSharedUnread({}, [action.payload]) + // The slot's own tombstone and arrival marker go with its shared entry. + // Harmless if the delete then fails: the shared entry was dropped here + // too, so the tombstone guards nothing, and a missing marker only makes + // an in-flight read retire nothing. + clearUnreadOrphan(action.payload) + clearUnreadArrival(action.payload) _relayUnreadToParent(JSON.stringify(relayedUnreadKeys(state))) }, updateSlot(state, action: PayloadAction & { key: string }>) { @@ -1487,8 +1513,16 @@ const dashboardSlice = createSlice({ // arrival converges. The write is queued, not done here: arrivals // coalesce into one write per SHARED_UNREAD_FLUSH_MS, and every read // or drain in this window flushes the queue before it touches the - // record. - queueSharedUnread(slot, effectiveTs ?? '') + // record. A slot holding a tombstone writes now instead, so the value + // the gateway disproved leaves the record before its tombstone does. + const published = effectiveTs ?? '' + if (readUnreadOrphan(slot) === undefined) queueSharedUnread(slot, published) + else persistSharedUnread({ [slot]: published }, []) + // A new message voids any marker a read is holding and the slot's tombstone, so + // no waiting read retires and a value the record holds after it shows a dot. + // The marker goes first: a retire racing this either sees it gone or is undone here. + clearUnreadArrival(slot) + clearUnreadOrphan(slot) const prev = state.unreadSince[slot] // `localTs` is the row's own server ts and guards THIS window only. // A row the gateway never saves (a permission row) publishes the @@ -1558,7 +1592,9 @@ const dashboardSlice = createSlice({ // watermark survives, and this window adopts it: badge stays lit for // the message the relay did not cover. const survivor = clearSharedUnreadIfCovered(slot, readTs) - if (survivor !== undefined) { + // A survivor equal to the slot's tombstone is the watermark the gateway + // disproved, so it lights nothing. + if (survivor !== undefined && survivor !== readUnreadOrphan(slot)) { if (!state.unreadSince) state.unreadSince = {} state.unreadSince[slot] = survivor if (!state.unreadSlots.includes(slot)) state.unreadSlots.push(slot) @@ -1574,6 +1610,26 @@ const dashboardSlice = createSlice({ state.unreadSlots = state.unreadSlots.filter(k => k !== slot) _relayUnreadToParent(JSON.stringify(relayedUnreadKeys(state))) }, + /** Retire a saved dot whose watermark the gateway cannot account for (see + * orphanedWatermark). The exact watermark is recorded as a tombstone + * without rewriting the shared record. This window's dot goes only while + * it carries that same watermark, so a manual reminder or a newer local + * watermark stays. */ + retireSlotUnread(state, action: PayloadAction<{ slot: string; watermark: string; arrival: string }>) { + const { slot, watermark, arrival } = action.payload + // Check marker ownership around the tombstone write to close either arrival ordering. + if (readUnreadArrival(slot) !== arrival) return + if (!recordUnreadOrphan(slot, watermark)) return + if (readUnreadArrival(slot) !== arrival) { + clearUnreadOrphan(slot) + return + } + if (state.unreadSince?.[slot] !== watermark) return + delete state.unreadSince[slot] + if (!state.unreadSlots.includes(slot)) return + state.unreadSlots = state.unreadSlots.filter(k => k !== slot) + _relayUnreadToParent(JSON.stringify(relayedUnreadKeys(state))) + }, setUpdateProgress(state, action: PayloadAction<{ step: string; detail: string } | null>) { state.updateProgress = action.payload }, @@ -1843,88 +1899,9 @@ const dashboardSlice = createSlice({ }, }) -export const { sseStatus, sseYolo, setYoloDuration, sseConnected, sseDisconnected, sseSlots, sseSlotsPartial, sseSlotListHolds, setSidebarOrder, sseTodoUpdate, sseMcpReportUpdate, touchSlotActivity, setChannelTrusted, sseSlotTitle, sseSlotPatch, addSlotOptimistic, removeSlotOptimistic, releaseCloseHold, awaitCloseOutcome, expireCloseHold, confirmCloseHold, armConfirmedCloseHold, updateSlot, updateSlotFolder, updateSlotPin, updateSlotMuteFlag, setSlotMuteFlagError, triggerRefresh, markSlotUnread, markSlotRead, remoteSlotRead, setUpdateProgress, +export const { sseStatus, sseYolo, setYoloDuration, sseConnected, sseDisconnected, sseSlots, sseSlotsPartial, sseSlotListHolds, setSidebarOrder, sseTodoUpdate, sseMcpReportUpdate, touchSlotActivity, setChannelTrusted, sseSlotTitle, sseSlotPatch, addSlotOptimistic, removeSlotOptimistic, releaseCloseHold, awaitCloseOutcome, expireCloseHold, confirmCloseHold, armConfirmedCloseHold, updateSlot, updateSlotFolder, updateSlotPin, updateSlotMuteFlag, setSlotMuteFlagError, triggerRefresh, markSlotUnread, markSlotRead, remoteSlotRead, retireSlotUnread, setUpdateProgress, setDesktopUpdateAvailable, sseSlotColor, setSessionDefaultColor, setSessionColorsMode, setSessionColorsPalette, setSessionColorsIntensity, setEnabledAppIds, patchSlotSourceLinks, patchSlotLink, dropSlotLinks, setPinRanks, setPendingPinOrder, setPinOrderError, setManualRanks, setPendingManualOrder, setManualOrderError } = dashboardSlice.actions export { slotSurfaceKey, slotIsRemoteBound } from './slotIdentity' -/** - * Count unread slots whose surface matches `mode`. Slots present in - * `unreadSlots` but missing from `slots` (e.g. deleted but not yet drained) - * are treated as the default chat surface (`""`) so they keep contributing - * to the Chat badge rather than vanishing silently. - * - * Note — intentional asymmetry with `filterUnreadKeysBySurface` in - * `surfaces/registry.ts`: that helper drops orphan keys (the sidebar can't - * display them regardless), whereas this one keeps them so the badge stays - * stable across the brief race between `removeSlotOptimistic` and - * `fetchSlots.fulfilled`. - * - * The exception is an orphan no reconcile will drain: any orphan while the - * gateway labels its lists partial, or one the gateway holds (`slotsHeldKeys`, or a - * channel key under `slotsHoldChannels`). The premise above does not hold for it. Its unread entry is kept so the dot returns with the tab, but - * it is left out of the badge, which would otherwise count a session the - * sidebar cannot show. - */ -function countUnreadByMode( - slots: ChatSlot[], - unread: string[], - mode: string, - heldKeys: readonly string[] = [], - holdChannels = false, - listPartial = false, -): number { - if (unread.length === 0) return 0 - const surfaceByKey = new Map(slots.map(s => [s.key, slotSurfaceKey(s)])) - const isHeldOrphan = heldOrphanTest(new Set(surfaceByKey.keys()), heldKeys, holdChannels, listPartial) - // The chat surface ('') counts every slot the chat page renders, legacy - // Autopilot slots included (see `isChatPageSurface`). - const isChatSurface = mode === '' - let count = 0 - for (const k of unread) { - if (isHeldOrphan(k)) continue - const sk = surfaceByKey.get(k) ?? '' - if (isChatSurface ? isChatPageSurface(sk) : sk === mode) count++ - } - return count -} - -/** - * Memoized factory for "unread count for slots whose surface === mode". - * One memo cache per `mode` argument so registry surfaces don't trash each - * other's memoization. Built-in nav badges should not call this directly — - * they go through `selectSurfaceBadgeCount(navId)` from `surfaces/registry`, - * which routes to this factory only when a surface declares `slotMode`. - */ -type UnreadByModeSelector = (state: { dashboard: DashboardState }) => number -const _unreadByModeCache = new Map() -export function selectUnreadByMode(mode: string): UnreadByModeSelector { - let sel = _unreadByModeCache.get(mode) - if (!sel) { - sel = createSelector( - (state: { dashboard: DashboardState }) => state.dashboard.slots, - (state: { dashboard: DashboardState }) => state.dashboard.unreadSlots, - (state: { dashboard: DashboardState }) => state.dashboard.slotsHeldKeys, - (state: { dashboard: DashboardState }) => state.dashboard.slotsHoldChannels, - (state: { dashboard: DashboardState }) => state.dashboard.slotsListPartial, - (slots, unread, heldKeys, holdChannels, listPartial) => - countUnreadByMode(slots, unread, mode, heldKeys ?? [], holdChannels === true, listPartial === true), - ) - _unreadByModeCache.set(mode, sel) - } - return sel -} - -/** - * True while the page has never seen a slot list and one may still arrive, so a - * pane with no open session cannot yet tell "no chats" from "chats loading". - * `slotsGeneration`, not `slotsLoaded`: a socket reconnect resets the latter, - * and a gateway whose list already landed is not waited on again. A failed - * fetch with none left in flight ends the wait. - */ -export const selectSlotListPending = (state: { dashboard: DashboardState }): boolean => { - const d = state.dashboard - return !d.slotsLoaded && (d.slotsGeneration ?? 0) === 0 && !d.slotsFetchFailed -} - export default dashboardSlice.reducer diff --git a/website/src/store/unreadArrivalMarker.ts b/website/src/store/unreadArrivalMarker.ts new file mode 100644 index 00000000000..e6e9ce5fd96 --- /dev/null +++ b/website/src/store/unreadArrivalMarker.ts @@ -0,0 +1,18 @@ +import { safeRemoveItem, safeSetItem } from '../utils/safeStorage' +import { secureRandomId } from '../utils/secureId' + +const UNREAD_ARRIVAL_KEY_PREFIX = 'mc-unread-arrival:' + +/** Read the marker for an arrival absorbed by a newer shared watermark. */ +export const readUnreadArrival = (slot: string): string | undefined => { + try { return localStorage.getItem(UNREAD_ARRIVAL_KEY_PREFIX + slot) ?? undefined } catch { return undefined } +} + +export const holdUnreadArrival = (slot: string): string | undefined => { + const marker = secureRandomId() + if (!safeSetItem(UNREAD_ARRIVAL_KEY_PREFIX + slot, marker)) return undefined + return readUnreadArrival(slot) === marker ? marker : undefined +} + +export const clearUnreadArrival = (slot: string): void => + safeRemoveItem(UNREAD_ARRIVAL_KEY_PREFIX + slot) diff --git a/website/src/test/slotReadRelay.test.tsx b/website/src/test/slotReadRelay.test.tsx index 88fdbb85b62..73a9c253eea 100644 --- a/website/src/test/slotReadRelay.test.tsx +++ b/website/src/test/slotReadRelay.test.tsx @@ -22,10 +22,12 @@ import { createElement } from 'react' import { Provider } from 'react-redux' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createTestStore } from './helpers' +import { api } from '../api/client' import { useWebSocket } from '../hooks/useWebSocket' +import { unreadWatermarkTs } from '../hooks/unreadOnAttention' import { store as globalStore } from '../store' import chatReducer, { setActiveSlot, clearMessages, switchSlot } from '../store/chatSlice' -import dashboardReducer, { addSlotOptimistic, removeSlotOptimistic, updateSlot, markSlotRead, markSlotUnread, remoteSlotRead, restoreUnreadSince, restoreUnreadBadges, MANUAL_UNREAD } from '../store/dashboardSlice' +import dashboardReducer, { addSlotOptimistic, removeSlotOptimistic, updateSlot, markSlotRead, markSlotUnread, remoteSlotRead, restoreUnreadSince, restoreUnreadBadges, sseSlots, touchSlotActivity, MANUAL_UNREAD } from '../store/dashboardSlice' import type { ChatSlot } from '../types' import { bindSlotReadSender, emitSlotRead, flushSlotRead, _resetSlotReadRelayForTest } from '../lib/slotReadRelay' import { setViewedThreadSlot, _resetViewedThreadForTests } from '../lib/viewedThread' @@ -345,6 +347,307 @@ describe('slot_read over the dashboard socket', () => { expect(frames.some(f => f.slot === BACKGROUND && f.read_ts === undefined)).toBe(true) }) + it('opening a session whose dot last_ts covers writes no arrival marker', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:05Z' })) + vi.mocked(api.chatSlotDetail).mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + original.call(this, key, value) + }) + try { + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(set.mock.calls.some(([key]) => key === 'mc-unread-arrival:' + BACKGROUND)).toBe(false) + } finally { + set.mockRestore() + } + }) + + it('opening a session whose dot a streamed bump held off the row covers writes no arrival marker', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:04Z', last_turn_ts: '2026-01-01T00:00:04Z' }])) }) + testStore.dispatch(touchSlotActivity({ key: BACKGROUND, ts: '2026-01-01T00:00:05Z' })) + expect(dash().slots.find(s => s.key === BACKGROUND)?.last_ts).toBe('2026-01-01T00:00:04Z') + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:05Z' })) + vi.mocked(api.chatSlotDetail).mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + original.call(this, key, value) + }) + try { + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(set.mock.calls.some(([key]) => key === 'mc-unread-arrival:' + BACKGROUND)).toBe(false) + } finally { + set.mockRestore() + } + }) + + it('opening a session whose marker cannot be written retires nothing', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + vi.mocked(api.chatSlotDetail).mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-arrival:' + BACKGROUND) throw new DOMException('quota exceeded', 'QuotaExceededError') + original.call(this, key, value) + }) + try { + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBeNull() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(BACKGROUND) + } finally { + set.mockRestore() + } + }) + + it('opening an idle session keeps a dot cleared whose watermark names a row the gateway lost', async () => { + const { ws } = mount() + await act(async () => {}) // the connect-time slot fetch settles first + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + // A watermark newer than any saved row, as one taken from a turn's + // unsaved `done` row is. + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + const read = sentReadFrames(ws).map(f => JSON.parse(f) as { slot: string; read_ts?: string }).find(f => f.slot === BACKGROUND) + expect(read?.read_ts).toBe('2026-01-01T00:00:05Z') + // The gateway rebroadcasts the read to every owner window, this one too. + act(() => { ws.simulateMessage({ type: 'slot_read', data: { slot: BACKGROUND, read_ts: read?.read_ts } }) }) + expect(dash().unreadSlots).not.toContain(BACKGROUND) + expect(JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}')[BACKGROUND]).toBe('2026-01-01T00:00:06Z') + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBe('2026-01-01T00:00:06Z') + expect(localStorage.getItem('mc-unread-arrival:' + BACKGROUND)).toBeNull() + const since = restoreUnreadSince() + expect(since[BACKGROUND]).toBeUndefined() + expect(restoreUnreadBadges(since)).not.toContain(BACKGROUND) + }) + + it('opening a session through a coverage hole keeps the saved dot when another window records a row during the load', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + const cachedRow = { role: 'assistant', content: 'cached row', cls: '', ts: '2026-01-01T00:00:01Z', meta: { mid: 'cached-row' } } + const newestRow = { role: 'assistant', content: 'newest saved row', cls: '', ts: '2026-01-01T00:00:05Z', meta: { mid: 'newest-row' } } + testStore.dispatch({ + type: 'chat/hydrateSlotMessages', + payload: { slot: BACKGROUND, messages: [cachedRow], hasMore: true, bounded: true, total: 1, running: false }, + }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + expect(dash().unreadSlots).toContain(BACKGROUND) + + let resolveFirstPage!: (value: Awaited>) => void + vi.mocked(api.chatSlotDetail) + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirstPage = resolve })) + .mockResolvedValueOnce({ + messages: [cachedRow, { role: 'user', content: 'middle row', cls: '', ts: '2026-01-01T00:00:03Z', meta: { mid: 'middle-row' } }], + running: false, + has_more: false, + total: 3, + next_before: 0, + queue: [], + }) + .mockResolvedValueOnce({ + messages: [newestRow], + running: false, + has_more: true, + total: 3, + next_before: 2, + queue: [], + }) + + const pending = testStore.dispatch(switchSlot(BACKGROUND) as never) + const secondWindow = createTestStore() + secondWindow.dispatch(markSlotUnread({ + slot: BACKGROUND, + ts: unreadWatermarkTs('permission', '2026-01-01T00:00:07Z'), + localTs: '2026-01-01T00:00:07Z', + })) + resolveFirstPage({ + messages: [newestRow], + running: false, + has_more: true, + total: 3, + next_before: 2, + queue: [], + }) + + await act(async () => { await pending }) + + expect(vi.mocked(api.chatSlotDetail).mock.calls.map(call => call[2])).toEqual([undefined, 2, undefined]) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBeNull() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(BACKGROUND) + }) + + it('opening the session keeps the saved dot when an absorbed arrival lands during the load', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + let resolveDetail!: (value: Awaited>) => void + vi.mocked(api.chatSlotDetail).mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve })) + const pending = testStore.dispatch(switchSlot(BACKGROUND) as never) + const secondWindow = createTestStore() + secondWindow.dispatch(markSlotUnread({ + slot: BACKGROUND, + ts: '2026-01-01T00:00:05Z', + localTs: '2026-01-01T00:00:07Z', + })) + resolveDetail({ + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + await act(async () => { await pending }) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBeNull() + expect(localStorage.getItem('mc-unread-arrival:' + BACKGROUND)).toBeNull() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(BACKGROUND) + }) + + it('opening the session keeps the saved dot when another window records a row during the load', async () => { + const { ws } = mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + let resolveDetail!: (value: Awaited>) => void + vi.mocked(api.chatSlotDetail).mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve })) + const pending = testStore.dispatch(switchSlot(BACKGROUND) as never) + const secondWindow = createTestStore() + secondWindow.dispatch(markSlotUnread({ + slot: BACKGROUND, + ts: unreadWatermarkTs('permission', '2026-01-01T00:00:07Z'), + localTs: '2026-01-01T00:00:07Z', + })) + resolveDetail({ + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + await act(async () => { await pending }) + const read = sentReadFrames(ws).map(f => JSON.parse(f) as { slot: string; read_ts?: string }).find(f => f.slot === BACKGROUND) + expect(read?.read_ts).toBe('2026-01-01T00:00:05Z') + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBeNull() + const since = restoreUnreadSince() + expect(since[BACKGROUND]).toBe('2026-01-01T00:00:06Z') + expect(restoreUnreadBadges(since)).toContain(BACKGROUND) + }) + + it("a switch that overlaps another switch of the session keeps the other switch's retire", async () => { + mount() + await act(async () => {}) + const slot = { key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' } + act(() => { testStore.dispatch(sseSlots([slot])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + const secondDashboard = dashboardReducer(undefined, { type: '@@test/init' }) + const secondWindow = createTestStore({ + dashboard: { + ...secondDashboard, + slots: [slot], + unreadSince: { [BACKGROUND]: '2026-01-01T00:00:06Z' }, + unreadSlots: [BACKGROUND], + }, + }) + let resolveFirstPage!: (value: Awaited>) => void + let resolveSecondPage!: (value: Awaited>) => void + vi.mocked(api.chatSlotDetail) + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirstPage = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecondPage = resolve })) + const firstSwitch = testStore.dispatch(switchSlot(BACKGROUND) as never) + const secondSwitch = secondWindow.dispatch(switchSlot(BACKGROUND) as never) + const transcript = { + messages: [{ role: 'assistant', content: 'saved row', cls: '', ts: '2026-01-01T00:00:05Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + } + + resolveSecondPage(transcript) + await act(async () => { await secondSwitch }) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBe('2026-01-01T00:00:06Z') + + resolveFirstPage(transcript) + await act(async () => { await firstSwitch }) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBe('2026-01-01T00:00:06Z') + expect(restoreUnreadBadges(restoreUnreadSince())).not.toContain(BACKGROUND) + }) + + it('switchSlot keeps a shared dot when its fetched page contains the held watermark', async () => { + const { ws } = mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + vi.mocked(api.chatSlotDetail).mockResolvedValueOnce({ + messages: [{ role: 'assistant', content: 'new row', cls: '', ts: '2026-01-01T00:00:06Z' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}')[BACKGROUND]).toBe('2026-01-01T00:00:06Z') + expect(localStorage.getItem('mc-unread-arrival:' + BACKGROUND)).toBeNull() + const read = sentReadFrames(ws).map(f => JSON.parse(f) as { slot: string; read_ts?: string }).find(f => f.slot === BACKGROUND) + expect(read?.read_ts).toBe('2026-01-01T00:00:05Z') + }) + + it('a failed session load leaves none of its arrival markers behind', async () => { + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + vi.mocked(api.chatSlotDetail).mockRejectedValueOnce(new Error('offline')) + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(localStorage.getItem('mc-unread-arrival:' + BACKGROUND)).toBeNull() + }) + + it('keeps the saved dot when the fetched page has no row time', async () => { + // A bounded read keeps a turn still streaming, collapsed into one row + // with no ts. The page holds a row, so it disproves nothing. + mount() + await act(async () => {}) + act(() => { testStore.dispatch(sseSlots([{ key: BACKGROUND, messages: 3, running: false, last_ts: '2026-01-01T00:00:05Z' }])) }) + testStore.dispatch(markSlotUnread({ slot: BACKGROUND, ts: '2026-01-01T00:00:06Z' })) + vi.mocked(api.chatSlotDetail).mockResolvedValueOnce({ + messages: [{ role: 'streaming', content: 'writing', cls: 'msg msg-a' }], + running: false, + has_more: false, + total: 1, + queue: [], + }) + await act(async () => { await testStore.dispatch(switchSlot(BACKGROUND) as never) }) + expect(localStorage.getItem('mc-unread-orphaned:' + BACKGROUND)).toBeNull() + expect(JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}')[BACKGROUND]).toBe('2026-01-01T00:00:06Z') + const since = restoreUnreadSince() + expect(since[BACKGROUND]).toBe('2026-01-01T00:00:06Z') + expect(restoreUnreadBadges(since)).toContain(BACKGROUND) + }) + it('switching away flushes the outgoing slot\'s pending trailing relay', () => { const { ws } = mount() act(() => { diff --git a/website/src/test/unreadOrphanedWatermark.test.ts b/website/src/test/unreadOrphanedWatermark.test.ts new file mode 100644 index 00000000000..a300a4797ae --- /dev/null +++ b/website/src/test/unreadOrphanedWatermark.test.ts @@ -0,0 +1,843 @@ +/** + * A read must retire a dot whose saved watermark names a row the gateway does + * not have, and must never erase one that names a row it does. + * + * The scenario: the shared unread record holds a watermark taken from a turn's + * `done` row, which is never written to the transcript, so a window that boots + * restores the dot above the restored slot's `last_ts` (the transcript tail). + * No read can cover it: every open clears the dot, the window's own slot_read + * echo lights it again from the shared record, and every reload restores it. + * The rule under test: retire only when a gateway page fetched after the + * gesture captured the watermark has no row as new as it, and only while the + * shared record still holds that same value. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { api } from '../api/client' +import { bindSlotReadSender, noteUnsavedRowTs, _resetSlotReadRelayForTest } from '../lib/slotReadRelay' +import { unreadWatermarkTs } from '../hooks/unreadOnAttention' +import { createTestStore } from './helpers' +import dashboardReducer, { + fetchSlots, + MANUAL_UNREAD, + markSlotRead, + markSlotUnread, + orphanedWatermark, + remoteSlotRead, + removeSlotOptimistic, + restoreUnreadBadges, + restoreUnreadSince, + retireSlotUnread, + sseConnected, + sseSlotPatch, + sseSlots, + sseSlotsPartial, + touchSlotActivity, + updateSlot, + watermarkDisprovedBy, +} from '../store/dashboardSlice' +import { flushSharedUnread, newestRowTs, UNREAD_PROOF_TIMEOUT_MS } from '../store/dashboard/unread' +import { readSlot } from '../store/dashboard/readSlot' +import type { RootState } from '../store' +import type { ChatSlot } from '../types' + +vi.mock('../api/client', () => ({ + api: { chatSlotDetail: vi.fn() }, +})) + +const KEY = 'chat-326-1791528142' +const OTHER_KEY = 'chat-326-1791528143' +const TRANSCRIPT_TAIL = '2026-10-09T22:33:35.542839+00:00' +const ORPHAN = '2026-10-09T22:33:35.611204+00:00' +const NEWER = '2026-10-09T22:33:36.000000+00:00' +const LATEST = '2026-10-09T22:33:37.000000+00:00' + +const row = (lastTs = TRANSCRIPT_TAIL, key = KEY): ChatSlot => ({ + key, + title: 'Unread test', + messages: 2, + running: false, + last_ts: lastTs, +}) +const page = (...timestamps: string[]) => ({ + messages: timestamps.map((ts) => ({ role: 'assistant', content: ts, cls: '', ts })), + running: false, + has_more: false, + total: timestamps.length, + queue: [], +}) +/** A bounded read keeps a turn still streaming, collapsed into one row with no ts. */ +const streamingRows = [{ role: 'streaming', content: 'writing', cls: 'msg msg-a' }] +const shared = () => JSON.parse(localStorage.getItem('mc-unread-shared') ?? '{}') as Record +const orphan = (key = KEY) => localStorage.getItem('mc-unread-orphaned:' + key) ?? undefined +const arrival = (key = KEY) => localStorage.getItem('mc-unread-arrival:' + key) ?? undefined +const retireArrival = (key = KEY) => { + const marker = 'held-arrival:' + key + localStorage.setItem('mc-unread-arrival:' + key, marker) + return marker +} +const boot = (watermark = ORPHAN, lastTs = TRANSCRIPT_TAIL, key = KEY) => { + localStorage.setItem('mc-unread-shared', JSON.stringify({ ...shared(), [key]: watermark })) + const since = restoreUnreadSince() + const initial = dashboardReducer(undefined, { type: '@@test/init' }) + return createTestStore({ + dashboard: { + ...initial, + slots: [row(lastTs, key)], + unreadSince: since, + unreadSlots: restoreUnreadBadges(since), + } as RootState['dashboard'], + }) +} + +describe('orphaned unread watermark retirement', () => { + let relayed: Array<{ slot: string; readTs?: string }> + const detail = vi.mocked(api.chatSlotDetail) + + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + relayed = [] + detail.mockReset() + detail.mockResolvedValue(page(TRANSCRIPT_TAIL)) + _resetSlotReadRelayForTest() + bindSlotReadSender((slot, readTs) => { relayed.push({ slot, readTs }) }) + }) + + afterEach(() => { vi.useRealTimers(); _resetSlotReadRelayForTest() }) + + it('arms the arrival marker before clearing the local unread badge', async () => { + const events: string[] = [] + const store = boot() + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-arrival:' + KEY) events.push('arrival') + original.call(this, key, value) + }) + try { + await readSlot(KEY)( + (action) => { + if (action.type === markSlotRead.type) events.push('read') + return store.dispatch(action) + }, + store.getState, + ) + } finally { + set.mockRestore() + } + expect(events.slice(0, 2)).toEqual(['arrival', 'read']) + }) + + it('a row that arrives while the proof is in flight stays out of the delayed relay', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + noteUnsavedRowTs(KEY, LATEST) + store.dispatch(markSlotUnread({ + slot: KEY, + ts: unreadWatermarkTs('permission', LATEST), + localTs: LATEST, + })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + store.dispatch(remoteSlotRead({ slot: KEY, readTs: relayed[0].readTs })) + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + }) + + it('a row that arrives while the proof is in flight keeps its dot across a reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + noteUnsavedRowTs(KEY, LATEST) + store.dispatch(markSlotUnread({ + slot: KEY, + ts: unreadWatermarkTs('permission', LATEST), + localTs: LATEST, + })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a row another window records while the proof is in flight keeps its dot across a reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + const secondWindow = createTestStore() + secondWindow.dispatch(markSlotUnread({ + slot: KEY, + ts: unreadWatermarkTs('permission', LATEST), + localTs: LATEST, + })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it("a read that overlaps another read of the session keeps the other read's retire", async () => { + let resolveFirstPage!: (value: ReturnType) => void + let resolveSecondPage!: (value: ReturnType) => void + detail + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirstPage = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecondPage = resolve })) + const firstWindow = boot() + const firstRead = firstWindow.dispatch(readSlot(KEY)) + const secondWindow = boot() + const secondRead = secondWindow.dispatch(readSlot(KEY)) + + resolveSecondPage(page(TRANSCRIPT_TAIL)) + await secondRead + expect(orphan()).toBe(ORPHAN) + + resolveFirstPage(page(TRANSCRIPT_TAIL)) + await firstRead + expect(orphan()).toBe(ORPHAN) + expect(restoreUnreadBadges(restoreUnreadSince())).not.toContain(KEY) + }) + + it('an arrival that lands while the retire writes its tombstone keeps its dot across a reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + const secondWindow = createTestStore() + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + original.call(this, key, value) + if (key === 'mc-unread-orphaned:' + KEY) { + secondWindow.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL, localTs: LATEST })) + } + }) + try { + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + } finally { + set.mockRestore() + } + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('an arrival that finishes before the retire writes its tombstone keeps its dot across a reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + const secondWindow = createTestStore() + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-orphaned:' + KEY) { + secondWindow.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL, localTs: LATEST })) + } + original.call(this, key, value) + }) + try { + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + } finally { + set.mockRestore() + } + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('an arrival clears a tombstone written after its marker is removed', () => { + const store = boot() + localStorage.setItem('mc-unread-arrival:' + KEY, 'held-arrival') + const originalRemove = Storage.prototype.removeItem + const originalSet = Storage.prototype.setItem + let tombstoneWritten = false + const remove = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(function (this: Storage, key: string) { + originalRemove.call(this, key) + if (!tombstoneWritten && key === 'mc-unread-arrival:' + KEY) { + tombstoneWritten = true + originalSet.call(this, 'mc-unread-orphaned:' + KEY, ORPHAN) + } + }) + try { + store.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL, localTs: LATEST })) + } finally { + remove.mockRestore() + } + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('an absorbed arrival during a read keeps its dot across a reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + store.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL, localTs: LATEST })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(orphan()).toBeUndefined() + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a read whose marker cannot be written retires nothing and keeps its dot', async () => { + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-arrival:' + KEY) throw new DOMException('quota exceeded', 'QuotaExceededError') + original.call(this, key, value) + }) + try { + const store = boot() + await store.dispatch(readSlot(KEY)) + } finally { + set.mockRestore() + } + expect(orphan()).toBeUndefined() + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a read cannot reuse an earlier arrival marker when its own marker write fails', async () => { + const store = boot() + localStorage.setItem('mc-unread-arrival:' + KEY, 'earlier-arrival') + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-arrival:' + KEY) throw new DOMException('quota exceeded', 'QuotaExceededError') + original.call(this, key, value) + }) + try { + await store.dispatch(readSlot(KEY)) + } finally { + set.mockRestore() + } + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a finished read leaves none of its arrival markers behind', async () => { + const retired = boot() + await retired.dispatch(readSlot(KEY)) + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + + detail.mockResolvedValue(page(NEWER)) + const kept = boot(ORPHAN, TRANSCRIPT_TAIL, OTHER_KEY) + await kept.dispatch(readSlot(OTHER_KEY)) + expect(localStorage.getItem('mc-unread-arrival:' + OTHER_KEY)).toBeNull() + }) + + it('a failed proof request leaves none of its arrival markers behind', async () => { + detail.mockRejectedValue(new Error('offline')) + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + }) + + it('a proof request that never settles still relays the read after the deadline', async () => { + vi.useFakeTimers() + let aborted = false + // Like fetch, the request settles only when its signal aborts. + detail.mockImplementation((_slot, _limit, _before, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { aborted = true; reject(signal.reason) }) + })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + await vi.advanceTimersByTimeAsync(UNREAD_PROOF_TIMEOUT_MS - 1) + expect(relayed).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + await pending + expect(aborted).toBe(true) + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + expect(localStorage.getItem('mc-unread-orphaned:' + KEY)).toBeNull() + }) + + it('an arrival the shared record does not outrank leaves no arrival marker', () => { + const store = createTestStore() + store.dispatch(markSlotUnread({ slot: KEY, ts: LATEST, localTs: LATEST })) + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + }) + + it('the delayed relay still covers an unsaved row received before the click', async () => { + noteUnsavedRowTs(KEY, NEWER) + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(detail).toHaveBeenCalledWith(KEY, 1, undefined, expect.any(AbortSignal)) + expect(relayed).toEqual([{ slot: KEY, readTs: NEWER }]) + }) + + it('retires when a post-gesture gateway page is older and relays the click-time last_ts', async () => { + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + store.dispatch(updateSlot({ key: KEY, last_ts: LATEST })) + expect(relayed).toEqual([]) // nothing goes out before the gateway answers + await pending + expect(detail).toHaveBeenCalledWith(KEY, 1, undefined, expect.any(AbortSignal)) + expect(shared()[KEY]).toBe(ORPHAN) + expect(orphan()).toBe(ORPHAN) + expect(store.getState().dashboard.unreadSlots).not.toContain(KEY) + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + }) + + it('keeps the guard when the gateway page covers a held watermark despite a stale slot row', async () => { + detail.mockResolvedValue(page(TRANSCRIPT_TAIL, NEWER)) + const store = boot(NEWER, TRANSCRIPT_TAIL) + await store.dispatch(readSlot(KEY)) + expect(shared()[KEY]).toBe(NEWER) + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + }) + + it('keeps the guard when the gateway proof request rejects', async () => { + detail.mockRejectedValue(new Error('offline')) + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(shared()[KEY]).toBe(ORPHAN) + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + }) + + it('keeps the guard when the gateway page holds only a streaming turn', async () => { + detail.mockResolvedValue({ messages: streamingRows, queue: [] } as never) + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(orphan()).toBeUndefined() + expect(shared()[KEY]).toBe(ORPHAN) + const since = restoreUnreadSince() + expect(since[KEY]).toBe(ORPHAN) + expect(restoreUnreadBadges(since)).toContain(KEY) + expect(relayed).toEqual([{ slot: KEY, readTs: TRANSCRIPT_TAIL }]) + }) + + it('fetches nothing when this window last_ts already covers the held watermark', () => { + const store = boot(ORPHAN, NEWER) + const result = store.dispatch(readSlot(KEY)) + expect(result).toBeUndefined() + expect(detail).not.toHaveBeenCalled() + expect(shared()[KEY]).toBeUndefined() + expect(localStorage.getItem('mc-unread-arrival:' + KEY)).toBeNull() + expect(relayed).toEqual([{ slot: KEY, readTs: NEWER }]) + }) + + it('fetches nothing when a streamed bump held off the row covers the held watermark', () => { + const store = boot() + store.dispatch(updateSlot({ key: KEY, last_turn_ts: TRANSCRIPT_TAIL })) + store.dispatch(touchSlotActivity({ key: KEY, ts: NEWER })) + expect(store.getState().dashboard.slots[0]?.last_ts).toBe(TRANSCRIPT_TAIL) + const result = store.dispatch(readSlot(KEY)) + expect(result).toBeUndefined() + expect(detail).not.toHaveBeenCalled() + expect(relayed).toEqual([{ slot: KEY, readTs: NEWER }]) + }) + + it('keeps a sibling watermark published after capture and restores its dot on reload', async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: NEWER })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(shared()[KEY]).toBe(NEWER) + expect(restoreUnreadSince()[KEY]).toBe(NEWER) + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('restores a newer watermark written after the retire', () => { + const store = boot() + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: NEWER })) + expect(shared()[KEY]).toBe(NEWER) + expect(restoreUnreadSince()[KEY]).toBe(NEWER) + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a window that loaded the dot before the proof keeps it through a relay that cannot cover it', () => { + const sibling = boot() + boot().dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + boot().dispatch(markSlotUnread({ slot: KEY, ts: NEWER })) + sibling.dispatch(remoteSlotRead({ slot: KEY, readTs: TRANSCRIPT_TAIL })) + expect(sibling.getState().dashboard.unreadSlots).toContain(KEY) + expect(shared()[KEY]).toBe(NEWER) + const restored = restoreUnreadSince() + expect(restored[KEY]).toBe(NEWER) + expect(restoreUnreadBadges(restored)).toContain(KEY) + }) + + it('an arrival whose write fails over a tombstoned value keeps a dot across a reload', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(shared()[KEY]).toBe(ORPHAN) + expect(orphan()).toBe(ORPHAN) + + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-shared') throw new DOMException('quota exceeded', 'QuotaExceededError') + original.call(this, key, value) + }) + try { + store.dispatch(markSlotUnread({ slot: KEY, ts: LATEST, localTs: LATEST })) + } finally { + set.mockRestore() + } + + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a stale write that restores a retired value after a new message still shows a dot', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(orphan()).toBe(ORPHAN) + store.dispatch(markSlotUnread({ slot: KEY, ts: LATEST, localTs: LATEST })) + expect(orphan()).toBeUndefined() + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: ORPHAN })) + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it("a stale write that restores the retired value while a read waits still shows the new message's dot", async () => { + let resolvePage!: (value: ReturnType) => void + detail.mockImplementation(() => new Promise((resolve) => { resolvePage = resolve })) + const store = boot() + const pending = store.dispatch(readSlot(KEY)) + store.dispatch(markSlotUnread({ slot: KEY, ts: LATEST, localTs: LATEST })) + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: ORPHAN })) + resolvePage(page(TRANSCRIPT_TAIL)) + await pending + expect(orphan()).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('a new older arrival replaces a tombstoned shared value and restores its dot', () => { + const store = boot() + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + store.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL })) + expect(shared()[KEY]).toBe(TRANSCRIPT_TAIL) + sessionStorage.clear() + const since = restoreUnreadSince() + expect(since[KEY]).toBe(TRANSCRIPT_TAIL) + expect(restoreUnreadBadges(since)).toContain(KEY) + }) + + it('an arrival queued before another window retires the slot replaces the tombstoned value when it lands', () => { + const store = boot() + store.dispatch(markSlotUnread({ slot: KEY, ts: TRANSCRIPT_TAIL })) + localStorage.setItem('mc-unread-orphaned:' + KEY, ORPHAN) + flushSharedUnread() + expect(shared()[KEY]).toBe(TRANSCRIPT_TAIL) + sessionStorage.clear() + const since = restoreUnreadSince() + expect(since[KEY]).toBe(TRANSCRIPT_TAIL) + expect(restoreUnreadBadges(since)).toContain(KEY) + }) + + it('two windows retiring different slots keep independent tombstones across reload', () => { + const firstWindow = boot(ORPHAN, TRANSCRIPT_TAIL, KEY) + const secondWindow = boot(NEWER, TRANSCRIPT_TAIL, OTHER_KEY) + firstWindow.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + secondWindow.dispatch(retireSlotUnread({ slot: OTHER_KEY, watermark: NEWER, arrival: retireArrival(OTHER_KEY) })) + expect(orphan(KEY)).toBe(ORPHAN) + expect(orphan(OTHER_KEY)).toBe(NEWER) + sessionStorage.clear() + const since = restoreUnreadSince() + expect(since[KEY]).toBeUndefined() + expect(since[OTHER_KEY]).toBeUndefined() + expect(restoreUnreadBadges(since)).not.toContain(KEY) + expect(restoreUnreadBadges(since)).not.toContain(OTHER_KEY) + }) + + it('keeps an adopted sibling watermark when the gateway page contains that row', async () => { + const store = boot(ORPHAN, TRANSCRIPT_TAIL) + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: NEWER })) + store.dispatch(remoteSlotRead({ slot: KEY, readTs: ORPHAN })) + expect(store.getState().dashboard.unreadSince[KEY]).toBe(NEWER) + detail.mockResolvedValue(page(TRANSCRIPT_TAIL, NEWER)) + await store.dispatch(readSlot(KEY)) + expect(shared()[KEY]).toBe(NEWER) + }) + + it('leaves a changed shared record alone when retiring the captured value', () => { + const store = boot() + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: NEWER })) + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + expect(shared()[KEY]).toBe(NEWER) + expect(store.getState().dashboard.unreadSince[KEY]).toBeUndefined() + expect(store.getState().dashboard.unreadSlots).not.toContain(KEY) + expect(restoreUnreadSince()[KEY]).toBe(NEWER) + expect(restoreUnreadBadges(restoreUnreadSince())).toContain(KEY) + }) + + it('records a tombstone without writing the shared unread record', () => { + const store = boot() + const set = vi.spyOn(Storage.prototype, 'setItem') + set.mockClear() + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + expect(orphan()).toBe(ORPHAN) + expect(shared()[KEY]).toBe(ORPHAN) + expect(set.mock.calls.filter(([key]) => key === 'mc-unread-shared')).toHaveLength(0) + set.mockRestore() + }) + + it('keeps the local dot lit when the tombstone write fails', () => { + const store = boot() + const original = Storage.prototype.setItem + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key === 'mc-unread-orphaned:' + KEY) throw new DOMException('denied', 'SecurityError') + original.call(this, key, value) + }) + try { + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + expect(orphan()).toBeUndefined() + expect(store.getState().dashboard.unreadSince[KEY]).toBe(ORPHAN) + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + expect(shared()[KEY]).toBe(ORPHAN) + expect(set.mock.calls.filter(([key]) => key === 'mc-unread-shared')).toHaveLength(0) + } finally { + set.mockRestore() + } + }) + + it('a window that loaded the dot before the proof drops it on reload and on its own read', async () => { + const sibling = boot() + boot().dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + sibling.dispatch(remoteSlotRead({ slot: KEY, readTs: TRANSCRIPT_TAIL })) + expect(sibling.getState().dashboard.unreadSlots).toContain(KEY) + const restored = restoreUnreadSince() + expect(restoreUnreadBadges(restored)).not.toContain(KEY) + await sibling.dispatch(readSlot(KEY)) + expect(sibling.getState().dashboard.unreadSlots).not.toContain(KEY) + expect(shared()[KEY]).toBe(ORPHAN) + }) + + it('keeps the local dot cleared through its own older slot_read echo', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + store.dispatch(remoteSlotRead({ slot: KEY, readTs: TRANSCRIPT_TAIL })) + expect(store.getState().dashboard.unreadSlots).not.toContain(KEY) + }) + + it('reload restores neither watermark nor badge while the shared orphan remains', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(shared()[KEY]).toBe(ORPHAN) + expect(orphan()).toBe(ORPHAN) + const since = restoreUnreadSince() + expect(since[KEY]).toBeUndefined() + expect(restoreUnreadBadges(since)).not.toContain(KEY) + }) + + it('posts the lowered unread count to the embedding hub', async () => { + const store = boot() + const posted: number[] = [] + const parent = vi.spyOn(window, 'parent', 'get').mockReturnValue({ + postMessage: (message: unknown) => { posted.push((message as { count: number }).count) }, + } as unknown as Window) + try { + await store.dispatch(readSlot(KEY)) + } finally { + parent.mockRestore() + } + expect(posted).toContain(0) + }) + + it('posts a hub count that leaves out a held orphan unread key', () => { + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: ORPHAN, [OTHER_KEY]: NEWER })) + const since = restoreUnreadSince() + const initial = dashboardReducer(undefined, { type: '@@test/init' }) + const store = createTestStore({ + dashboard: { + ...initial, + slots: [row(TRANSCRIPT_TAIL, KEY)], + slotsHeldKeys: [OTHER_KEY], + unreadSince: since, + unreadSlots: restoreUnreadBadges(since), + } as RootState['dashboard'], + }) + const posted: number[] = [] + const parent = vi.spyOn(window, 'parent', 'get').mockReturnValue({ + postMessage: (message: unknown) => { posted.push((message as { count: number }).count) }, + } as unknown as Window) + try { + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + } finally { + parent.mockRestore() + } + expect(store.getState().dashboard.unreadSlots).toEqual([OTHER_KEY]) + expect(posted).toEqual([0]) + }) + + it('a sibling keeps a newer dot through an older relay until its own covering read', () => { + const sibling = boot(NEWER, NEWER) + sibling.dispatch(remoteSlotRead({ slot: KEY, readTs: ORPHAN })) + expect(sibling.getState().dashboard.unreadSlots).toContain(KEY) + sibling.dispatch(readSlot(KEY)) + expect(sibling.getState().dashboard.unreadSlots).not.toContain(KEY) + }) + + it('a read of a manual reminder clears it and leaves the shared record alone', () => { + const store = boot() + store.dispatch(markSlotUnread(KEY)) + store.dispatch(readSlot(KEY)) + expect(detail).not.toHaveBeenCalled() + expect(store.getState().dashboard.unreadSince[KEY]).toBeUndefined() + expect(shared()[KEY]).toBe(ORPHAN) + }) + + it('retiring shared state keeps a manual reminder this window set', () => { + const store = boot() + store.dispatch(markSlotUnread(KEY)) + store.dispatch(retireSlotUnread({ slot: KEY, watermark: ORPHAN, arrival: retireArrival(KEY) })) + expect(shared()[KEY]).toBe(ORPHAN) + expect(orphan()).toBe(ORPHAN) + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + expect(store.getState().dashboard.unreadSince[KEY]).toBe(MANUAL_UNREAD) + }) + + it('excludes manual and empty values from orphan classification', () => { + expect(orphanedWatermark(undefined, TRANSCRIPT_TAIL)).toBeUndefined() + expect(orphanedWatermark('', TRANSCRIPT_TAIL)).toBeUndefined() + expect(orphanedWatermark(MANUAL_UNREAD, TRANSCRIPT_TAIL)).toBeUndefined() + }) + + it('classifies only uncovered message watermarks as orphaned', () => { + expect(orphanedWatermark(ORPHAN, TRANSCRIPT_TAIL)).toBe(ORPHAN) + expect(orphanedWatermark(ORPHAN, NEWER)).toBeUndefined() + }) + + it('a page whose rows carry no time, or no rows array, disproves nothing', () => { + expect(watermarkDisprovedBy(ORPHAN, streamingRows)).toBeUndefined() + expect(watermarkDisprovedBy(ORPHAN, undefined)).toBeUndefined() + }) + + it('an empty page, or one with no row as new as the watermark, disproves it', () => { + expect(watermarkDisprovedBy(ORPHAN, [])).toBe(ORPHAN) + expect(watermarkDisprovedBy(ORPHAN, [{ ts: TRANSCRIPT_TAIL }])).toBe(ORPHAN) + expect(watermarkDisprovedBy(ORPHAN, [{ ts: ORPHAN }])).toBeUndefined() + expect(watermarkDisprovedBy(ORPHAN, [{ ts: NEWER }])).toBeUndefined() + }) + + it('selects the newest parseable row timestamp regardless of row order', () => { + expect(newestRowTs([ + { ts: NEWER }, + { ts: 'not-a-time' }, + { ts: TRANSCRIPT_TAIL }, + { ts: LATEST }, + ])).toBe(LATEST) + expect(newestRowTs([{ ts: 'invalid' }, {}])).toBeUndefined() + }) + + it('removing a session drops its tombstone and arrival marker', () => { + const store = boot() + localStorage.setItem('mc-unread-orphaned:' + KEY, ORPHAN) + localStorage.setItem('mc-unread-arrival:' + KEY, 'read-in-flight') + localStorage.setItem('mc-unread-orphaned:' + OTHER_KEY, NEWER) + localStorage.setItem('mc-unread-arrival:' + OTHER_KEY, 'sibling-read') + store.dispatch(removeSlotOptimistic(KEY)) + expect(orphan(KEY)).toBeUndefined() + expect(arrival(KEY)).toBeUndefined() + expect(orphan(OTHER_KEY)).toBe(NEWER) + expect(arrival(OTHER_KEY)).toBe('sibling-read') + }) + + it('a slot list without the session drops its tombstone and arrival marker', () => { + const store = boot() + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + localStorage.setItem('mc-unread-orphaned:' + KEY, ORPHAN) + localStorage.setItem('mc-unread-arrival:' + KEY, 'read-in-flight') + localStorage.setItem('mc-unread-orphaned:' + OTHER_KEY, NEWER) + localStorage.setItem('mc-unread-arrival:' + OTHER_KEY, 'sibling-read') + store.dispatch(sseSlotsPartial(false)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, OTHER_KEY)])) + expect(orphan(KEY)).toBeUndefined() + expect(arrival(KEY)).toBeUndefined() + expect(orphan(OTHER_KEY)).toBe(NEWER) + expect(arrival(OTHER_KEY)).toBe('sibling-read') + }) + + it('a retired session another window deletes leaves no saved dot, and a stale reply removes nothing', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + expect(orphan()).toBe(ORPHAN) + expect(store.getState().dashboard.unreadSlots).not.toContain(KEY) + retireArrival(KEY) + store.dispatch(sseSlotsPartial(false)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, KEY), row(TRANSCRIPT_TAIL, OTHER_KEY)])) + store.dispatch(fetchSlots.fulfilled([row(TRANSCRIPT_TAIL, OTHER_KEY)], 'req-stale', undefined as never)) + expect(orphan()).toBe(ORPHAN) + expect(arrival()).toBeDefined() + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, KEY), row(TRANSCRIPT_TAIL, OTHER_KEY)])) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, OTHER_KEY)])) + expect(orphan()).toBeUndefined() + expect(arrival()).toBeUndefined() + expect(shared()[KEY]).toBeUndefined() + expect(restoreUnreadBadges(restoreUnreadSince())).not.toContain(KEY) + }) + + it('a removed frame for a retired session leaves no saved dot', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + retireArrival(KEY) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, KEY), row(TRANSCRIPT_TAIL, OTHER_KEY)])) + store.dispatch(sseSlotPatch({ slots: [], removed: [KEY] })) + expect(orphan()).toBeUndefined() + expect(arrival()).toBeUndefined() + expect(shared()[KEY]).toBeUndefined() + }) + + it('a first slot list without an unread session drops its tombstone and arrival marker', () => { + localStorage.setItem('mc-unread-shared', JSON.stringify({ [KEY]: NEWER })) + localStorage.setItem('mc-unread-orphaned:' + KEY, ORPHAN) + localStorage.setItem('mc-unread-arrival:' + KEY, 'read-in-flight') + const since = restoreUnreadSince() + const initial = dashboardReducer(undefined, { type: '@@test/init' }) + const store = createTestStore({ + dashboard: { ...initial, unreadSince: since, unreadSlots: restoreUnreadBadges(since) } as RootState['dashboard'], + }) + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + store.dispatch(sseSlotsPartial(false)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, OTHER_KEY)])) + expect(orphan()).toBeUndefined() + expect(arrival()).toBeUndefined() + }) + + it('a partial slot list keeps an absent session\'s unread dot, tombstone and arrival marker', () => { + const store = boot() + localStorage.setItem('mc-unread-orphaned:' + KEY, ORPHAN) + localStorage.setItem('mc-unread-arrival:' + KEY, 'read-in-flight') + store.dispatch(sseSlotsPartial(true)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, OTHER_KEY)])) + expect(store.getState().dashboard.unreadSlots).toContain(KEY) + expect(orphan()).toBe(ORPHAN) + expect(arrival()).toBe('read-in-flight') + }) + + it('a removal before the first snapshot clears a retired session\'s keys', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + retireArrival(KEY) + expect(orphan()).toBe(ORPHAN) + expect(arrival()).toBeDefined() + expect(shared()[KEY]).toBe(ORPHAN) + store.dispatch(sseConnected()) + expect(store.getState().dashboard.slotsLoaded).toBe(false) + store.dispatch(sseSlotPatch({ slots: [], removed: [KEY] })) + expect(orphan()).toBeUndefined() + expect(arrival()).toBeUndefined() + expect(shared()[KEY]).toBeUndefined() + }) + + it('a partial slot list keeps a retired session\'s keys, and a removal still clears them', async () => { + const store = boot() + await store.dispatch(readSlot(KEY)) + retireArrival(KEY) + store.dispatch(sseSlotsPartial(false)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, KEY), row(TRANSCRIPT_TAIL, OTHER_KEY)])) + store.dispatch(sseSlotsPartial(true)) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, OTHER_KEY)])) + expect(orphan()).toBe(ORPHAN) + expect(arrival()).toBeDefined() + expect(shared()[KEY]).toBe(ORPHAN) + store.dispatch(sseSlots([row(TRANSCRIPT_TAIL, KEY), row(TRANSCRIPT_TAIL, OTHER_KEY)])) + store.dispatch(sseSlotPatch({ slots: [], removed: [KEY] })) + expect(orphan()).toBeUndefined() + expect(arrival()).toBeUndefined() + expect(shared()[KEY]).toBeUndefined() + }) +})