Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/system-specs/modules/app-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slot>` 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:<slot>` 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
Expand Down
12 changes: 4 additions & 8 deletions website/src/hooks/useSessionActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down
37 changes: 23 additions & 14 deletions website/src/lib/slotReadRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions website/src/pages/members/MembersPage.openThread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
26 changes: 23 additions & 3 deletions website/src/store/chat/slotSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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)`,
Expand Down Expand Up @@ -396,6 +414,8 @@ export const switchSlot = createAsyncThunk<
}))
}
throw e
} finally {
if (heldArrival !== undefined && readUnreadArrival(key) === heldArrival) clearUnreadArrival(key)
}
},
)
Expand Down
44 changes: 44 additions & 0 deletions website/src/store/dashboard/readSlot.ts
Original file line number Diff line number Diff line change
@@ -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<typeof markSlotRead> | ReturnType<typeof retireSlotUnread>) => unknown,
getState: () => unknown,
): Promise<void> | 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) })
}
89 changes: 89 additions & 0 deletions website/src/store/dashboard/selectors.ts
Original file line number Diff line number Diff line change
@@ -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<string, UnreadByModeSelector>()
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
}
Loading
Loading