From 5ef4304a4aba74c2b7b9df3440ce7411d3fc8012 Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 1 Nov 2026 08:48:53 +0800 Subject: [PATCH] fix: synchronize read state across web and native clients Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices. Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures. --- android/README.md | 4 + .../kotlin/app/hapi/companion/di/AppGraph.kt | 6 +- .../kotlin/app/hapi/companion/di/HubGraph.kt | 21 +- .../hapi/companion/feature/chat/ChatScreen.kt | 12 + .../companion/feature/chat/ChatTranscript.kt | 21 + .../companion/feature/chat/ChatViewModel.kt | 34 +- .../feature/sessions/SessionListViewModel.kt | 24 +- .../app/src/main/res/values-ru/strings.xml | 1 + .../src/main/res/values-zh-rCN/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../feature/chat/ChatViewModelTest.kt | 26 +- .../sessions/SessionListViewModelTest.kt | 12 +- .../main/kotlin/app/hapi/data/api/HapiApi.kt | 5 + .../app/hapi/data/store/LastSeenStore.kt | 218 +++++--- .../app/hapi/data/store/SessionStore.kt | 27 +- .../app/hapi/data/store/LastSeenStoreTest.kt | 100 ++-- .../hapi/data/store/PaginationFixtureTest.kt | 2 + .../hapi/protocol/patch/SessionPatching.kt | 5 + .../protocol/window/MessageWindowLogic.kt | 7 +- .../protocol/window/MessageWindowState.kt | 1 + .../app/hapi/protocol/wire/ApiResponses.kt | 1 + .../hapi/protocol/wire/DecryptedMessage.kt | 3 + .../kotlin/app/hapi/protocol/wire/Session.kt | 2 + .../app/hapi/protocol/wire/SessionPatch.kt | 5 + .../hapi/protocol/wire/SessionReadState.kt | 29 ++ .../app/hapi/protocol/wire/SessionSummary.kt | 2 + .../app/hapi/protocol/wire/SummaryPatching.kt | 7 +- docs/api/client-contract/index.md | 1 + docs/api/client-contract/read-state.md | 88 ++++ docs/api/client-contract/rest.md | 3 +- .../socket/handlers/cli/sessionHandlers.ts | 3 +- hub/src/store/index.ts | 20 +- hub/src/store/messages.ts | 36 +- hub/src/store/migration-v13.test.ts | 6 +- hub/src/store/migration-v15.test.ts | 6 +- hub/src/store/migration-v18.test.ts | 2 +- hub/src/store/migration-v19.test.ts | 2 +- hub/src/store/migration-v20.test.ts | 2 +- hub/src/store/migration-v23.test.ts | 2 +- hub/src/store/migration-v24.test.ts | 4 +- hub/src/store/migration-v25.test.ts | 2 +- hub/src/store/migration-v26.test.ts | 2 +- hub/src/store/migration-v28.test.ts | 2 +- hub/src/store/migration-v30.test.ts | 34 ++ hub/src/store/sessionReadState.test.ts | 96 ++++ hub/src/store/sessionReadState.ts | 30 ++ hub/src/store/sessionStore.ts | 6 + hub/src/store/sessions.ts | 6 + hub/src/store/types.ts | 5 + hub/src/sync/messageService.test.ts | 27 + hub/src/sync/messageService.ts | 6 + hub/src/sync/sessionCache.ts | 19 + hub/src/sync/syncEngine.ts | 9 +- hub/src/web/routes/codexDesktop.ts | 4 +- hub/src/web/routes/sessionReadState.test.ts | 41 ++ hub/src/web/routes/sessions.ts | 16 + ios/Hapi/Features/Chat/ChatModel.swift | 31 +- .../Features/Chat/ChatTranscriptView.swift | 6 + ios/Hapi/Features/Chat/ChatView.swift | 9 + ios/Hapi/Features/Home/HomeView.swift | 1 - .../Features/Sessions/SessionFilterMenu.swift | 2 +- .../Features/Sessions/SessionListModel.swift | 46 +- .../Features/Sessions/SessionListView.swift | 1 - ios/Hapi/Models/AppModel.swift | 21 + ios/Hapi/Models/HubSession.swift | 32 +- ios/Hapi/Resources/Localizable.xcstrings | 6 +- ios/HapiTests/SessionListFilterTests.swift | 13 +- ios/HapiTests/SessionRowStatusTests.swift | 8 +- .../Endpoints/SessionEndpoints.swift | 4 + .../HapiClient/Stores/LastSeenStore.swift | 213 +++++--- .../HapiClient/Stores/SessionListStore.swift | 19 +- .../Models/DecryptedMessage.swift | 4 + .../Models/MessagesResponse.swift | 3 + .../Sources/HapiProtocol/Models/Session.swift | 8 + .../HapiProtocol/Models/SessionPatch.swift | 11 + .../Models/SessionReadState.swift | 38 ++ .../HapiProtocol/Models/SessionSummary.swift | 6 + .../HapiProtocol/Models/SummaryPatching.swift | 5 + .../HapiProtocol/Patch/SessionPatching.swift | 4 + .../Window/MessageWindowLogic.swift | 11 +- .../Window/MessageWindowState.swift | 1 + .../HapiProtocol/Window/WindowMessage.swift | 10 + .../HapiUI/Chat/AnchoredTranscriptList.swift | 7 + .../PaginationFixtureTests.swift | 2 + .../Stores/LastSeenStoreTests.swift | 127 +++-- ios/README.md | 6 +- .../cancel-too-late-ingests-invoked-row.json | 1 + .../pagination/fetch-older-before-cursor.json | 1 + .../latest-page-then-sse-ingest.json | 1 + .../message-cancelled-removes-queued-row.json | 1 + .../messages-consumed-stamps-invoked-at.json | 1 + .../older-page-epoch-mismatch-resets.json | 1 + ...optimistic-echo-reconciles-by-localid.json | 1 + ...ed-state-reconciliation-drops-deleted.json | 1 + ...read-receipt-history-does-not-advance.json | 145 ++++++ .../read-receipt-latest-and-live-output.json | 109 ++++ ...ad-receipt-requires-initial-tail-sync.json | 50 ++ ...easoning-snapshots-collapse-to-newest.json | 1 + .../reset-latest-preserves-optimistic.json | 1 + .../sse-hidden-rows-advance-cursor-only.json | 1 + ...reserves-queued-and-recomputes-cursor.json | 1 + shared/src/apiTypes.ts | 2 + shared/src/index.ts | 1 + shared/src/messages.ts | 30 ++ shared/src/schemas.ts | 6 + shared/src/sessionReadState.test.ts | 40 ++ shared/src/sessionReadState.ts | 36 ++ shared/src/sessionSummary.ts | 5 + web/scripts/fixtures/pagination/build.ts | 5 +- web/scripts/fixtures/pagination/cases.ts | 33 +- web/scripts/fixtures/pagination/runner.ts | 2 + web/scripts/fixtures/pagination/types.ts | 2 + web/src/api/client.ts | 17 +- .../components/AssistantChat/HappyThread.tsx | 5 + web/src/components/SessionChat.tsx | 2 + web/src/components/SessionHeader.tsx | 2 +- .../SessionList.mark-all-read.test.tsx | 36 +- web/src/components/SessionList.test.ts | 16 +- web/src/components/SessionList.tsx | 8 +- web/src/components/SessionRowSummary.test.tsx | 14 +- web/src/components/SessionRowSummary.tsx | 2 +- web/src/hooks/queries/useMessages.ts | 2 + web/src/hooks/queries/useSession.ts | 11 +- web/src/hooks/queries/useSessions.ts | 14 +- web/src/hooks/useAppBadge.test.ts | 21 +- web/src/hooks/useAppBadge.ts | 13 +- web/src/hooks/useSSE.ts | 16 +- web/src/hooks/useSelectedSessionSeen.test.ts | 98 ++-- web/src/hooks/useSelectedSessionSeen.ts | 53 +- web/src/lib/locales/en.ts | 2 +- web/src/lib/locales/ru.ts | 2 +- web/src/lib/locales/zh-CN.ts | 2 +- web/src/lib/message-window-store.ts | 10 +- web/src/lib/sessionAttention.test.ts | 53 +- web/src/lib/sessionAttention.ts | 21 +- web/src/lib/sessionLastSeen.test.ts | 310 +++--------- web/src/lib/sessionLastSeen.ts | 465 ++++++------------ web/src/lib/sessionPatch.ts | 3 + web/src/router.tsx | 17 +- 139 files changed, 2352 insertions(+), 1053 deletions(-) create mode 100644 android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionReadState.kt create mode 100644 docs/api/client-contract/read-state.md create mode 100644 hub/src/store/migration-v30.test.ts create mode 100644 hub/src/store/sessionReadState.test.ts create mode 100644 hub/src/store/sessionReadState.ts create mode 100644 hub/src/web/routes/sessionReadState.test.ts create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionReadState.swift create mode 100644 shared/fixtures/pagination/read-receipt-history-does-not-advance.json create mode 100644 shared/fixtures/pagination/read-receipt-latest-and-live-output.json create mode 100644 shared/fixtures/pagination/read-receipt-requires-initial-tail-sync.json create mode 100644 shared/src/sessionReadState.test.ts create mode 100644 shared/src/sessionReadState.ts diff --git a/android/README.md b/android/README.md index e2474693..ccc50e6a 100644 --- a/android/README.md +++ b/android/README.md @@ -416,3 +416,7 @@ dependency calling the removed `InputManager.getInstance()` method. Earlier API 29 checks passed 19 chat/reader regressions. No 60/120 Hz device frame-time, memory, or predictive-back measurements are claimed by these checks. + +Unread state uses the hub’s [revision read-state contract](../docs/api/client-contract/read-state.md). +List selection and metadata updates never acknowledge reading. The resumed chat +acknowledges only its rendered tail, with a durable outbox scoped by hub and namespace. diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt index a695bd9e..65aeece3 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt @@ -278,8 +278,10 @@ class AppGraph(context: Context) { } /** Sequential by construction: only the [start] collector calls this. */ - private fun swapActiveHub(activeHubUrl: String?) { - mutableActiveHubGraph.value?.close() + private suspend fun swapActiveHub(activeHubUrl: String?) { + val previous = mutableActiveHubGraph.value + previous?.close() + previous?.lastSeenStore?.flushPersistence() mutableActiveHubGraph.value = activeHubUrl?.let { hubUrl -> HubGraph( hubUrl = hubUrl, diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt index d533976c..1329e14c 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt @@ -5,6 +5,7 @@ import app.hapi.companion.feature.chat.composer.ChatDrafts import app.hapi.data.HubSession import app.hapi.data.auth.AuthEvents import app.hapi.data.auth.CredentialStore +import app.hapi.data.auth.JwtPeek import app.hapi.data.sse.GlobalSsePipe import app.hapi.data.sse.AndroidNetworkMonitor import app.hapi.data.sse.OkHttpSseTransport @@ -28,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -92,7 +94,21 @@ class HubGraph( val machineStore: MachineStore = MachineStore(session.api, scope, snapshotDir) - val lastSeenStore: LastSeenStore = LastSeenStore(scope, snapshotDir) + private val readNamespace = credentialStore.get(session.hubUrl)?.jwt?.let { JwtPeek.peek(it)?.ns } + val lastSeenStore: LastSeenStore = LastSeenStore(scope, snapshotDir, namespace = readNamespace) + + private fun configureReadState() { + scope.launch { + val namespace = readNamespace ?: runCatching { session.ensureFreshToken()?.let { JwtPeek.peek(it)?.ns } }.getOrNull() + ?: return@launch + lastSeenStore.configure(namespace) { id, state -> + check(session.ensureFreshToken()?.let { JwtPeek.peek(it)?.ns } == namespace) { "Read state identity changed" } + session.api.updateSessionReadState(id, state).readState + } + } + } + + init { configureReadState() } val messageWindows: MessageWindowStores = MessageWindowStores( api = session.api, @@ -136,6 +152,8 @@ class HubGraph( fun setLifecycleForeground(foreground: Boolean) { sseEngine.setLifecycleForeground(foreground) visibilityReporter.setForeground(foreground) + lastSeenStore.setForeground(foreground) + if (foreground) configureReadState() } /** @@ -160,6 +178,7 @@ class HubGraph( override fun close() { networkMonitor.close() + lastSeenStore.shutdown() scope.cancel() imageLoader.shutdown() session.close() diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt index 612ffd75..a18353b2 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -112,6 +114,8 @@ internal fun ChatScreen( readingState: TranscriptReadingState = rememberTranscriptReadingState(viewModel.sessionId), ) { val state by viewModel.uiState.collectAsState() + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + val foreground = lifecycleState.isAtLeast(Lifecycle.State.RESUMED) val reconnecting by viewModel.reconnecting.collectAsState() val historyPaging by viewModel.historyPaging.collectAsState() val jumpToken by viewModel.jumpToken.collectAsState() @@ -130,6 +134,12 @@ internal fun ChatScreen( onDispose { viewModel.setTranscriptVisible(false) } } + LaunchedEffect(foreground, state.readThroughRevision, state.isSyncingTail, state.warning) { + if (foreground && state.blocks.isEmpty() && !state.hasMore) { + viewModel.markDisplayed(state.readThroughRevision, state.renderedReadState) + } + } + val context = LocalContext.current // ------------------------------------------------------------ dictation -- val dictationState = dictation?.state?.collectAsState()?.value ?: DictationState.Idle @@ -379,6 +389,7 @@ internal fun ChatScreen( jumpingLatest = jumpingLatest, onViewport = viewModel::readingViewportChanged, onLayout = viewModel::historyLaidOut, + foreground = foreground, onTailDisplayed = viewModel::markDisplayed, onRetryHistory = viewModel::loadOlder, onJumpToLatest = viewModel::jumpToLatest, listState = transcriptList, readingState = readingState, @@ -666,5 +677,6 @@ internal fun chatNoticeText(context: Context, notice: ChatNotice): String = when is ChatNotice.SteerFailed -> notice.detail ?: context.getString(R.string.chat_notice_steer_failed) is ChatNotice.PermissionRequestFailed -> notice.detail ?: context.getString(R.string.chat_notice_request_failed) is ChatNotice.ModelsLoadFailed -> notice.detail ?: context.getString(R.string.chat_notice_models_failed) + is ChatNotice.ReadStateFailed -> notice.detail ?: context.getString(R.string.chat_notice_read_state_failed) is ChatNotice.ConfigUpdateFailed -> notice.detail ?: context.getString(R.string.chat_notice_config_failed) } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatTranscript.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatTranscript.kt index bc97ece5..69bb6719 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatTranscript.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatTranscript.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import app.hapi.companion.R import app.hapi.data.store.ChatHistoryPagingState import app.hapi.protocol.chat.VisibleChatBlock +import app.hapi.protocol.wire.SessionReadState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext @@ -63,6 +64,8 @@ internal fun ChatTranscript( onLayout: (Long, Boolean) -> Unit, onRetryHistory: () -> Unit, onJumpToLatest: () -> Unit, + foreground: Boolean = true, + onTailDisplayed: (Long?, SessionReadState?) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), readingState: TranscriptReadingState = rememberTranscriptReadingState(state.sessionId), @@ -82,6 +85,7 @@ internal fun ChatTranscript( var consumedJumpToken by rememberSaveable(state.sessionId) { mutableLongStateOf(0) } var correctingTail by remember(state.sessionId) { mutableStateOf(false) } var renderedHistoryVersion by remember(state.sessionId) { mutableLongStateOf(-1) } + var renderedMessagesVersion by remember(state.sessionId) { mutableLongStateOf(-1) } val currentRows by rememberUpdatedState(rows) val rowIDs = remember(rows) { rows.mapTo(mutableSetOf()) { it.id } } val currentIDs by rememberUpdatedState(rowIDs) @@ -89,9 +93,12 @@ internal fun ChatTranscript( val currentJumpToken by rememberUpdatedState(jumpToken) val reportViewport by rememberUpdatedState(onViewport) val reportLayout by rememberUpdatedState(onLayout) + val reportTail by rememberUpdatedState(onTailDisplayed) + val isForeground by rememberUpdatedState(foreground) LaunchedEffect(state.sessionId, listState) { var lastVersion = -1L + var lastDisplayedRevision: Long? = null var readerWasScrolling = false var previousIDs = emptySet() var previousVisibleHeight = 0 @@ -116,7 +123,10 @@ internal fun ChatTranscript( val short = !listState.canScrollBackward && !listState.canScrollForward LayoutReport( version = currentState.historyVersion, + readRevision = currentState.readThroughRevision.takeIf { isForeground && + !currentState.isSyncingTail && currentState.warning == null && !currentState.requiresLatestReset }, matches = matches && renderedHistoryVersion == currentState.historyVersion && + renderedMessagesVersion == currentState.messagesVersion && height > 0 && info.visibleItemsInfo.isNotEmpty(), short = short, nearTop = !listState.canScrollBackward || distance <= height, @@ -128,6 +138,9 @@ internal fun ChatTranscript( visibleHeight = info.visibleItemsInfo.sumOf { it.size }, ) }.collect { report -> + if (report.readRevision == null || !report.following || !report.atBottom || report.scrolling) { + lastDisplayedRevision = null + } if (!report.matches) return@collect if (report.scrolling && !report.ownScroll) { readerWasScrolling = true @@ -141,6 +154,11 @@ internal fun ChatTranscript( lastReadingDemand = demand reportViewport(demand.first, demand.second) } + if (report.readRevision != null && followsTail && report.atBottom && !report.scrolling && + report.readRevision != lastDisplayedRevision) { + lastDisplayedRevision = report.readRevision + reportTail(report.readRevision, currentState.renderedReadState) + } if (report.version != lastVersion) { val progress = report.ids.any { it !in previousIDs } || (report.ids == previousIDs && report.visibleHeight > previousVisibleHeight + 1) @@ -224,10 +242,12 @@ internal fun ChatTranscript( // here forces a measure/placement pass even for a hidden-only // page; acknowledging composition alone is too early. val version = state.historyVersion + val messagesVersion = state.messagesVersion val placeable = measurable.measure(constraints) layout(placeable.width, placeable.height) { placeable.place(0, 0) renderedHistoryVersion = version + renderedMessagesVersion = messagesVersion } }, contentPadding = PaddingValues(vertical = 10.dp), @@ -267,6 +287,7 @@ internal fun ChatTranscript( private data class LayoutReport( val version: Long, + val readRevision: Long?, val matches: Boolean, val short: Boolean, val nearTop: Boolean, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index a07b9f13..ff60a12a 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -58,6 +58,7 @@ import app.hapi.protocol.wire.HapiJson import app.hapi.protocol.wire.Machine import app.hapi.protocol.wire.SendMessageRequest import app.hapi.protocol.wire.Session +import app.hapi.protocol.wire.SessionReadState import app.hapi.protocol.wire.SessionSummary import app.hapi.protocol.wire.SlashCommand import app.hapi.protocol.wire.arrayOrNull @@ -155,6 +156,8 @@ data class ChatUiState( val messagesVersion: Long = 0, val requiresLatestReset: Boolean = false, val processSteps: Map = emptyMap(), + val readThroughRevision: Long? = null, + val renderedReadState: SessionReadState? = null, ) /** Composer bar state (M3a). */ @@ -249,6 +252,7 @@ sealed interface ChatNotice { data class SteerFailed(val detail: String?) : ChatNotice data class PermissionRequestFailed(val detail: String?) : ChatNotice data class ModelsLoadFailed(val detail: String?) : ChatNotice + data class ReadStateFailed(val detail: String?) : ChatNotice data class ConfigUpdateFailed(val detail: String?) : ChatNotice } @@ -364,7 +368,6 @@ class ChatViewModel( private var sseJob: Job? = null private var initJob: Job? = null - private var seenJob: Job? = null private var olderJob: Job? = null private var jumpJob: Job? = null private val mutableHistoryPaging = MutableStateFlow(ChatHistoryPagingState()) @@ -395,6 +398,20 @@ class ChatViewModel( } } + /** The UI calls only after laying out the observed tail in a resumed screen. */ + @MainThread + internal fun markDisplayed(revision: Long?, serverState: SessionReadState?) { + val rendered = uiState.value + if (!started || !transcriptVisible || !readerFollowsTail || revision == null || + rendered.isSyncingTail || rendered.isInitialLoading || rendered.loadFailed || + rendered.warning != null || rendered.requiresLatestReset) return + scope.launch { + try { lastSeenStore.markSeen(sessionId, revision, serverState) } + catch (cancelled: CancellationException) { throw cancelled } + catch (error: Exception) { _events.tryEmit(ChatEvent.Notice(ChatNotice.ReadStateFailed(error.message))) } + } + } + @MainThread internal fun beginInspection() { jumpJob?.cancel() @@ -633,18 +650,6 @@ class ChatViewModel( // Badge count + SSE-triggered refetches while this chat is on screen. scratchlist?.open(sessionId) - seenJob = scope.launch { - // Watermark = updatedAt currently on screen, from whichever cache - // is fresher (summary via global events, detail via this pipe). - merge( - sessionStore.sessions - .map { list -> list.firstOrNull { it.id == sessionId }?.updatedAt }, - sessionStore.sessionDetail(sessionId).map { it?.updatedAt }, - ) - .filterNotNull() - .distinctUntilChanged() - .collect { updatedAt -> lastSeenStore.markSeen(sessionId, updatedAt) } - } } /** Tears the session pipe down (engine keeps the resume cursor). */ @@ -656,7 +661,6 @@ class ChatViewModel( sseJob?.cancel() sseJob = null initJob?.cancel() - seenJob?.cancel() cancelHistory() jumpJob?.cancel() jumpJob = null @@ -1906,6 +1910,8 @@ class ChatViewModel( historyVersion = window.historyVersion, messagesVersion = window.messagesVersion, requiresLatestReset = window.requiresLatestReset, + readThroughRevision = window.readThroughRevision, + renderedReadState = SessionReadState.merge(inputs.detail?.readState, inputs.summary?.readState), ) } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt index 1eaf6fc4..290465fd 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt @@ -1,6 +1,7 @@ package app.hapi.companion.feature.sessions import app.hapi.data.api.ApiError +import app.hapi.data.store.LastSeenState import app.hapi.data.store.LastSeenStore import app.hapi.data.store.MachineListStore import app.hapi.data.store.SessionListStore @@ -148,20 +149,14 @@ class SessionListViewModel( scope.launch { sessionStore.sessions.drop(1).collect { sessions -> if (isOffline.value) isOffline.value = false - // Seed the unread baseline from SSE data too — when REST - // refresh fails but the stream works, unseeded watermarks - // would light every row's unread dot (once-per-scope inside - // the store, so repeated calls are no-ops). - if (sessions.isNotEmpty()) { - runCatching { lastSeenStore.initializeBaseline(hubKey, sessions) } - } + } } } val uiState: StateFlow = combine( combine(sessionStore.sessions, machineStore.machines, lastSeenStore.state) { sessions, machines, lastSeen -> - Triple(sessions, machines, lastSeen.lastSeen) + Triple(sessions, machines, lastSeen) }, combine(machineFilter, searchQuery, selectedSessionId) { filter, query, selected -> Triple(filter, query, selected) @@ -218,16 +213,13 @@ class SessionListViewModel( isRefreshing.value = true try { // Only a failed *sessions* fetch means "offline". Machines and - // the unread baseline are secondary: their failures must not + // other stores are secondary: their failures must not // pin the offline banner over a perfectly live list (this // exact cascade shipped once — a machines decode error kept // the banner up while SSE streamed active sessions). sessionStore.refresh() isOffline.value = false hasRefreshedOnce.value = true - // First successful list for this hub seeds the unread baseline - // so historical sessions do not all light up as unread. - runCatching { lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value) } try { machineStore.refresh() } catch (cancellation: CancellationException) { @@ -283,11 +275,9 @@ class SessionListViewModel( searchQuery.value = query } - /** Call when navigating into a session: stamps the last-seen watermark. */ + /** Keep the selected row visible under list filters; reading is acknowledged by the transcript. */ fun onSessionOpened(sessionId: String) { selectedSessionId.value = sessionId - val summary = sessionStore.sessions.value.firstOrNull { it.id == sessionId } ?: return - lastSeenStore.markSeen(sessionId, summary.updatedAt) } /** `PUT /sessions/:id/pin` with optimistic re-sort; failures surface on [errors]. */ @@ -373,7 +363,7 @@ class SessionListViewModel( private fun buildUiState( sessions: List, machines: List, - lastSeen: Map, + lastSeen: LastSeenState, filter: String?, searchQuery: String, selectedSessionId: String?, @@ -475,7 +465,7 @@ class SessionListViewModel( summary.metadata?.worktree?.let { add(it.name.ifBlank { it.branch }) } }.takeIf { it.isNotEmpty() }?.joinToString(" · "), flavor = summary.metadata?.flavor, - unread = LastSeenStore.isUnread(summary, lastSeen[summary.id] ?: 0), + unread = LastSeenStore.isUnread(summary, lastSeen), machine = if (showMachine) filters.find { it.id == (summary.metadata?.machineId ?: UNKNOWN_MACHINE_ID) } else null, ) } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 8cd5f5bf..30c3d068 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -627,4 +627,5 @@ Не удалось поделиться ОК Подключите хаб, чтобы выбрать сессию для отправки. + Не удалось сохранить статус прочтения diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 3887a5a3..4499ed8f 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -634,4 +634,5 @@ 分享暂不可用 确定 请先配对 Hub,再选择接收分享内容的会话。 + 无法保存已读状态 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 03cd91e0..869bb693 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -627,4 +627,5 @@ Share unavailable OK Pair a hub to choose a session for the shared content. + Couldn’t save read status diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt index 9e1fa4ea..b44b5578 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt @@ -675,15 +675,27 @@ class ChatViewModelTest { } @Test - fun `marks the session seen on entry and on updates`() = runTest { + fun `reads only rendered tail and keeps newer summary output unread`() = runTest { val harness = Harness(this, ScriptedTransport(listOf("ok"))) - harness.sessionStore.summaries.value = listOf(summary(updatedAt = 500)) - + harness.sessionStore.summaries.value = listOf(summary(updatedAt = 500).copy(contentRevision = 4)) + harness.api.tailResponses.add(MessagesResponse( + messages = listOf(agentMessage("a", 1, 100, "answer")), + page = page("latest", hasMore = false).copy(readThroughRevision = 3), + )) harness.viewModel.start() - harness.lastSeenStore.state.first { it.lastSeen[SESSION_ID] == 500L } - - harness.sessionStore.summaries.value = listOf(summary(updatedAt = 900)) - harness.lastSeenStore.state.first { it.lastSeen[SESSION_ID] == 900L } + val rendered = harness.viewModel.uiState.first { !it.isSyncingTail && it.readThroughRevision == 3L } + assertEquals(0, harness.lastSeenStore.lastSeenAt(SESSION_ID)) + harness.viewModel.readingViewportChanged(followsTail = false, needsOlder = false) + harness.viewModel.markDisplayed(rendered.readThroughRevision, rendered.renderedReadState) + assertEquals(0, harness.lastSeenStore.lastSeenAt(SESSION_ID)) + harness.viewModel.readingViewportChanged(followsTail = true, needsOlder = false) + harness.viewModel.setTranscriptVisible(false) + harness.viewModel.markDisplayed(rendered.readThroughRevision, rendered.renderedReadState) + assertEquals(0, harness.lastSeenStore.lastSeenAt(SESSION_ID)) + harness.viewModel.setTranscriptVisible(true) + harness.viewModel.markDisplayed(rendered.readThroughRevision, rendered.renderedReadState) + harness.lastSeenStore.state.first { it.lastSeen[SESSION_ID] == 3L } + assertTrue(LastSeenStore.isUnread(harness.sessionStore.summaries.value.single(), harness.lastSeenStore.state.value)) harness.viewModel.stop() } diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt index 796b5889..25e9438e 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt @@ -243,9 +243,9 @@ class SessionListViewModelTest { val (viewModel, sessions, machines) = buildViewModel() machines.backing.value = listOf(machine("m1", host = "devbox.local", displayName = "Devbox")) sessions.set( - summary("s1", updatedAt = 900, name = "Named session", machineId = "m1", summaryText = "working on it"), - summary("s2", updatedAt = 500, summaryText = "summary title", machineId = "offline-machine-id"), - summary("s3", updatedAt = 100, path = "/repo/tail-name"), + summary("s1", updatedAt = 900, name = "Named session", machineId = "m1", summaryText = "working on it").copy(contentRevision = 1), + summary("s2", updatedAt = 500, summaryText = "summary title", machineId = "offline-machine-id").copy(contentRevision = 1), + summary("s3", updatedAt = 100, path = "/repo/tail-name").copy(contentRevision = 1), ) val state = viewModel.uiState.first { it.rows.size == 3 } @@ -391,13 +391,13 @@ class SessionListViewModelTest { } @Test - fun `onSessionOpened stamps the last-seen watermark and clears unread`() = runTest { + fun `onSessionOpened preserves unread until transcript renders`() = runTest { val (viewModel, sessions, _) = buildViewModel() - sessions.set(summary("s1", updatedAt = 900)) + sessions.set(summary("s1", updatedAt = 900).copy(contentRevision = 1)) assertTrue(viewModel.uiState.first { it.rows.size == 1 }.rows.single().unread) viewModel.onSessionOpened("s1") - assertFalse(viewModel.uiState.first { !it.rows.single().unread }.rows.single().unread) + assertTrue(viewModel.uiState.value.rows.single().unread) } // -------------------------------------------- rename / delete / reopen -- diff --git a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt index 250ead59..bd5ef6ce 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt @@ -1,5 +1,7 @@ package app.hapi.data.api +import app.hapi.protocol.wire.SessionReadStateRequest +import app.hapi.protocol.wire.SessionReadStateResponse import app.hapi.data.auth.HubUrls import app.hapi.protocol.wire.AgentAvailabilityResponse import app.hapi.protocol.wire.ApprovePermissionRequest @@ -199,6 +201,9 @@ class HapiApi internal constructor( } /** `GET /api/sessions/:id`. */ + suspend fun updateSessionReadState(sessionId: String, state: SessionReadStateRequest): SessionReadStateResponse = + request("PUT", url("api", "sessions", sessionId, "read-state").build(), state.toJsonBody()) + suspend fun getSession(sessionId: String): SessionResponse = request("GET", url("api", "sessions", sessionId).build()) diff --git a/android/core/data/src/main/kotlin/app/hapi/data/store/LastSeenStore.kt b/android/core/data/src/main/kotlin/app/hapi/data/store/LastSeenStore.kt index 3fdeb0be..0a859ad7 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/store/LastSeenStore.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/store/LastSeenStore.kt @@ -1,97 +1,185 @@ package app.hapi.data.store +import app.hapi.data.api.ApiError +import app.hapi.protocol.wire.HapiJson +import app.hapi.protocol.wire.SessionReadState +import app.hapi.protocol.wire.SessionReadStateRequest +import app.hapi.protocol.wire.SessionReadStateResponse import app.hapi.protocol.wire.SessionSummary import java.io.File +import java.io.FileOutputStream +import java.net.URLEncoder +import java.util.UUID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -/** Snapshot payload: watermarks + which scopes already got their baseline. */ +@Serializable +data class PendingRead(val id: String, val request: SessionReadStateRequest) + +/** Revisions and the durable outbox share one atomic file, scoped by hub/namespace. */ @Serializable data class LastSeenState( val lastSeen: Map = emptyMap(), - val baselines: Set = emptySet(), + val server: Map = emptyMap(), + val pending: Map = emptyMap(), ) -/** - * Per-session last-seen watermarks — port of `web/src/lib/sessionLastSeen.ts` - * (localStorage → per-hub JSON snapshot) plus the unread derivation from - * `web/src/lib/sessionAttention.ts`. - * - * The watermark is the session `updatedAt` the operator last had on screen; - * a session is **unread** when its current `updatedAt` moved past it - * ([isUnread] — the reference compares `updatedAt` only; message `seq` never - * reaches the summary). [initializeBaseline] seeds missing watermarks from - * the first session list so a fresh install does not mark every historical - * session unread — once per [LastSeenState.baselines] scope, exactly like the - * web's per-scope baseline flag. - */ class LastSeenStore( - scope: CoroutineScope, - snapshotDir: File? = null, + private val scope: CoroutineScope, + private val snapshotDir: File? = null, + namespace: String? = "default", ) { - private val snapshot: JsonSnapshotStore? = snapshotDir?.let { dir -> - JsonSnapshotStore( - file = File(dir, "last-seen.json"), - serializer = LastSeenState.serializer(), - scope = scope, - ) + @Volatile private var namespace: String? = namespace + private val mutex = Mutex() + private val syncLock = Any() + @Volatile private var send: (suspend (String, SessionReadStateRequest) -> SessionReadState)? = null + private var syncJob: Job? = null + @Volatile private var foreground = true + @Volatile private var closed = false + private val _state = MutableStateFlow(load(namespace)) + val state: StateFlow = _state.asStateFlow() + + private fun file(namespace: String?): File? = namespace?.let { ns -> snapshotDir?.let { + File(it, "read-state-${URLEncoder.encode(ns, "UTF-8")}.json") + } } + + private fun load(namespace: String?): LastSeenState = runCatching { + file(namespace)?.takeIf { it.isFile }?.readText()?.let { HapiJson.decodeFromString(LastSeenState.serializer(), it) } + }.getOrNull() ?: LastSeenState() + + suspend fun configure(namespace: String, sender: suspend (String, SessionReadStateRequest) -> SessionReadState) { + if (closed) return + mutex.withLock { + if (this.namespace != namespace) { + synchronized(syncLock) { + val previous = syncJob + syncJob = null + this.namespace = namespace + previous?.cancel() + } + _state.value = withContext(Dispatchers.IO) { load(namespace) } + } + send = sender + } + sync() } - private val _state = MutableStateFlow(snapshot?.load() ?: LastSeenState()) - val state: StateFlow = _state.asStateFlow() + fun setForeground(value: Boolean) { + foreground = value + if (value) sync() + } fun lastSeenAt(sessionId: String): Long = _state.value.lastSeen[sessionId] ?: 0 - /** Forces the debounced snapshot to disk (app background / tests). */ - suspend fun flushPersistence() { - snapshot?.flush() + fun shutdown() = synchronized(syncLock) { + closed = true + foreground = false + syncJob?.cancel() + syncJob = null } - /** `markSessionSeen`: monotonic max — a stale screen never rewinds the watermark. */ - fun markSeen(sessionId: String, seenAt: Long) { - if (sessionId.isEmpty()) return - updateState { state -> - val current = state.lastSeen[sessionId] ?: 0 - val next = maxOf(current, seenAt) - if (next == current && state.lastSeen.containsKey(sessionId)) state - else state.copy(lastSeen = state.lastSeen + (sessionId to next)) - } - } + /** A successful mark is already durable; background/close waits for active writes. */ + suspend fun flushPersistence() { mutex.withLock { } } - /** - * `initializeSessionLastSeen`: on the first list load for [scopeKey] - * (e.g. the hub id), seed every session without a watermark at its - * current `updatedAt`, then never again for that scope. - */ - fun initializeBaseline(scopeKey: String, sessions: Iterable) { - updateState { state -> - if (scopeKey in state.baselines) return@updateState state - val seeded = state.lastSeen.toMutableMap() - for (session in sessions) { - seeded.getOrPut(session.id) { session.updatedAt } - } - state.copy(lastSeen = seeded, baselines = state.baselines + scopeKey) - } - } - - private inline fun updateState(transform: (LastSeenState) -> LastSeenState) { - while (true) { - val previous = _state.value - val next = transform(previous) - if (next === previous) return - if (_state.compareAndSet(previous, next)) { - snapshot?.scheduleWrite(next) - return + private suspend fun commit(identity: String? = namespace, transform: (LastSeenState) -> LastSeenState) { + mutex.withLock { + if (namespace != identity) return + val next = transform(_state.value) + if (next == _state.value) return + val target = file(namespace) + // Cancellation on hub switch cannot tear a write or publish an unpersisted mark. + withContext(NonCancellable + Dispatchers.IO) { + if (target != null) { + target.parentFile?.mkdirs() + val temporary = File(target.parentFile, target.name + ".tmp") + FileOutputStream(temporary).use { output -> + output.write(HapiJson.encodeToString(LastSeenState.serializer(), next).toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + check(temporary.renameTo(target)) { "Cannot persist read state" } + } + _state.value = next } } } + suspend fun markSeen(sessionId: String, revision: Long, serverState: SessionReadState? = null) { + if (closed || namespace == null || sessionId.isEmpty() || revision < 0) return + commit { current -> + val server = SessionReadState.merge(current.server[sessionId], serverState) ?: SessionReadState() + val seen = maxOf(current.lastSeen[sessionId] ?: 0, server.readRevision) + val pending = current.pending[sessionId] + val manualUnread = server.manualUnread && pending?.request?.expectedVersion != server.version + if (seen >= revision && !manualUnread) return@commit current + val request = SessionReadStateRequest(type = "read", readRevision = maxOf(seen, revision), expectedVersion = serverState?.version ?: server.version) + current.copy( + lastSeen = current.lastSeen + (sessionId to maxOf(seen, revision)), + server = current.server + (sessionId to server), + pending = current.pending + (sessionId to PendingRead(UUID.randomUUID().toString(), request)), + ) + } + sync() + } + + private fun sync(): Unit = synchronized(syncLock) { + if (closed || !scope.isActive || !foreground || send == null || syncJob != null || state.value.pending.isEmpty()) return + val sender = send ?: return + val identity = namespace + val job = scope.launch(start = CoroutineStart.LAZY) { + while (isActive && foreground && namespace == identity) { + val (id, pending) = state.value.pending.entries.firstOrNull()?.let { it.key to it.value } ?: break + try { + val response = sender(id, pending.request) + commit(identity) { current -> + current.copy( + server = current.server + (id to (SessionReadState.merge(current.server[id], response) ?: response)), + pending = if (current.pending[id]?.id == pending.id) current.pending - id else current.pending, + ) + } + } catch (cancelled: CancellationException) { throw cancelled + } catch (error: Exception) { + if (error is ApiError && error.status in setOf(400, 403, 404, 409)) { + val authoritative = runCatching { HapiJson.decodeFromString(SessionReadStateResponse.serializer(), error.body ?: "").readState }.getOrNull() + try { commit(identity) { current -> current.copy( + server = if (authoritative == null) current.server else current.server + (id to (SessionReadState.merge(current.server[id], authoritative) ?: authoritative)), + lastSeen = if (current.pending[id]?.id == pending.id) current.lastSeen + (id to (authoritative?.readRevision ?: current.server[id]?.readRevision ?: 0)) else current.lastSeen, + pending = if (current.pending[id]?.id == pending.id) current.pending - id else current.pending, + ) } } catch (cancelled: CancellationException) { throw cancelled + } catch (_: Exception) { delay(5_000) } + } else delay(5_000) + } + } + } + syncJob = job + job.invokeOnCompletion { + synchronized(syncLock) { + if (syncJob === job) { syncJob = null; sync() } + } + } + job.start() + } + companion object { - /** `sessionIsUnread`: activity newer than the operator's watermark. */ - fun isUnread(summary: SessionSummary, lastSeenAt: Long): Boolean = - summary.updatedAt > lastSeenAt + fun isUnread(summary: SessionSummary, state: LastSeenState): Boolean { + val server = SessionReadState.merge(state.server[summary.id], summary.readState) ?: SessionReadState() + val pending = state.pending[summary.id] + val manual = server.manualUnread && pending?.request?.expectedVersion != server.version + return manual || (!(summary.active && summary.thinking) + && (summary.contentRevision ?: 0) > maxOf(server.readRevision, state.lastSeen[summary.id] ?: 0)) + } } } diff --git a/android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt b/android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt index 9bbfc6da..c45f9136 100644 --- a/android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt +++ b/android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt @@ -1,5 +1,6 @@ package app.hapi.data.store +import app.hapi.protocol.wire.SessionReadState import app.hapi.data.api.HapiApi import app.hapi.data.sse.SseSubscriptionKey import app.hapi.protocol.patch.applySessionDetailPatch @@ -167,8 +168,11 @@ class SessionStore( override suspend fun loadSessionDetail(sessionId: String): Session { val session = api.getSession(sessionId).session - _details.update { it + (sessionId to session) } - return session + _details.update { current -> current + (sessionId to session.copy( + contentRevision = listOfNotNull(current[sessionId]?.contentRevision, session.contentRevision).maxOrNull(), + readState = SessionReadState.merge(current[sessionId]?.readState, session.readState), + )) } + return _details.value[sessionId] ?: session } override fun releaseDetail(sessionId: String) { @@ -192,12 +196,14 @@ class SessionStore( val response = api.getSessions() updateSummaries { current -> // A user-input SSE patch can overtake an in-flight REST snapshot. - val clocks = current.associate { it.id to it.lastUserMessageAt } + val cachedRows = current.associateBy { it.id } sortSessionSummaries(response.sessions.map { row -> - val cached = clocks[row.id] - if (cached != null && cached > (row.lastUserMessageAt ?: Long.MIN_VALUE)) { - row.copy(lastUserMessageAt = cached) - } else row + val cached = cachedRows[row.id] + row.copy( + contentRevision = listOfNotNull(cached?.contentRevision, row.contentRevision).maxOrNull(), + readState = SessionReadState.merge(cached?.readState, row.readState), + lastUserMessageAt = maxOf(cached?.lastUserMessageAt ?: 0, row.lastUserMessageAt ?: 0).takeIf { it > 0 }, + ) }) } } @@ -371,7 +377,10 @@ class SessionStore( // parse, then the REST fallback. val full = parseFullSession(data)?.takeIf { it.id == sessionId } if (full != null) { - _details.update { it + (sessionId to full) } + _details.update { current -> current + (sessionId to full.copy( + contentRevision = listOfNotNull(current[sessionId]?.contentRevision, full.contentRevision).maxOrNull(), + readState = SessionReadState.merge(current[sessionId]?.readState, full.readState), + )) } upsertSummary(full) return } @@ -422,6 +431,8 @@ class SessionStore( // fields — carry them over from the previous row (web // `upsertSessionSummary`). val summary = SummaryPatching.toSessionSummary(session).copy( + contentRevision = listOfNotNull(existing?.contentRevision, session.contentRevision).maxOrNull(), + readState = SessionReadState.merge(existing?.readState, session.readState), lastUserMessageAt = session.lastUserMessageAt?.let { maxOf(existing?.lastUserMessageAt ?: 0, it) } ?: existing?.lastUserMessageAt, futureScheduledMessageCount = existing?.futureScheduledMessageCount ?: 0, nextScheduledAt = existing?.nextScheduledAt, diff --git a/android/core/data/src/test/kotlin/app/hapi/data/store/LastSeenStoreTest.kt b/android/core/data/src/test/kotlin/app/hapi/data/store/LastSeenStoreTest.kt index e9a13f88..64c4df49 100644 --- a/android/core/data/src/test/kotlin/app/hapi/data/store/LastSeenStoreTest.kt +++ b/android/core/data/src/test/kotlin/app/hapi/data/store/LastSeenStoreTest.kt @@ -1,66 +1,80 @@ package app.hapi.data.store +import app.hapi.protocol.wire.SessionReadState import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest class LastSeenStoreTest { + @Test + fun `observed revisions and outbox persist together and are scoped`() = runTest { + val dir = Files.createTempDirectory("read-state").toFile() + val store = LastSeenStore(backgroundScope, dir, "alice") + store.markSeen("s1", 7) + store.markSeen("s1", 3) + val cold = LastSeenStore(backgroundScope, dir, "alice") + assertEquals(7, cold.lastSeenAt("s1")) + assertEquals(7, cold.state.value.pending["s1"]?.request?.readRevision) + assertEquals(0, LastSeenStore(backgroundScope, dir, "bob").lastSeenAt("s1")) + assertEquals(0, LastSeenStore(backgroundScope, Files.createTempDirectory("hub-b").toFile(), "alice").lastSeenAt("s1")) + } @Test - fun `markSeen is monotonic max`() = runTest { + fun `unread ignores metadata and keeps running output pending`() = runTest { val store = LastSeenStore(backgroundScope) - store.markSeen("s1", 1_000) - assertEquals(1_000, store.lastSeenAt("s1")) - store.markSeen("s1", 500) // stale screen must not rewind - assertEquals(1_000, store.lastSeenAt("s1")) - store.markSeen("s1", 2_000) - assertEquals(2_000, store.lastSeenAt("s1")) - assertEquals(0, store.lastSeenAt("unknown")) + val row = summary("s1", updatedAt = 999_999) + assertFalse(LastSeenStore.isUnread(row, store.state.value)) + assertTrue(LastSeenStore.isUnread(row.copy(contentRevision = 2), store.state.value)) + assertFalse(LastSeenStore.isUnread(row.copy(contentRevision = 2, active = true, thinking = true), store.state.value)) + assertTrue(LastSeenStore.isUnread(row.copy(contentRevision = 2, active = false, thinking = true), store.state.value)) + store.markSeen("s1", 1) + assertTrue(LastSeenStore.isUnread(row.copy(contentRevision = 2), store.state.value)) + assertFalse(LastSeenStore.isUnread(row.copy(contentRevision = 2, readState = SessionReadState(2, false, 1)), store.state.value)) + assertTrue(LastSeenStore.isUnread(row.copy(contentRevision = 2, readState = SessionReadState(2, true, 2)), store.state.value)) } @Test - fun `unread compares updatedAt against the watermark`() = runTest { - val row = summary("s1", updatedAt = 1_000) - assertTrue(LastSeenStore.isUnread(row, lastSeenAt = 0)) - assertTrue(LastSeenStore.isUnread(row, lastSeenAt = 999)) - assertFalse(LastSeenStore.isUnread(row, lastSeenAt = 1_000)) - assertFalse(LastSeenStore.isUnread(row, lastSeenAt = 2_000)) - } - - @Test - fun `baseline seeds missing watermarks only once per scope`() = runTest { + fun `stale read cannot erase newer manual unread`() = runTest { val store = LastSeenStore(backgroundScope) - store.markSeen("seen", 50) - store.initializeBaseline( - "hub-a", - listOf(summary("seen", updatedAt = 900), summary("fresh", updatedAt = 700)), - ) - // Existing watermarks are never overwritten; missing ones seed at updatedAt. - assertEquals(50, store.lastSeenAt("seen")) - assertEquals(700, store.lastSeenAt("fresh")) - assertFalse(LastSeenStore.isUnread(summary("fresh", updatedAt = 700), store.lastSeenAt("fresh"))) - - // Second call for the same scope is a no-op — later sessions stay unread. - store.initializeBaseline("hub-a", listOf(summary("later", updatedAt = 999))) - assertEquals(0, store.lastSeenAt("later")) - assertTrue(LastSeenStore.isUnread(summary("later", updatedAt = 999), store.lastSeenAt("later"))) + store.markSeen("s1", 4, SessionReadState(version = 1)) + val row = summary("s1").copy(contentRevision = 4, readState = SessionReadState(4, true, 2)) + assertTrue(LastSeenStore.isUnread(row, store.state.value)) + store.markSeen("s1", 4, row.readState) + assertFalse(LastSeenStore.isUnread(row, store.state.value)) } @Test - fun `state round-trips through the snapshot`() = runTest { - val dir = Files.createTempDirectory("last-seen").toFile() - val store = LastSeenStore(backgroundScope, dir) - store.markSeen("s1", 1_234) - store.initializeBaseline("hub-a", emptyList()) - store.flushPersistence() + fun `old acknowledgement cannot delete a newer queued read`() = runTest { + val store = LastSeenStore(backgroundScope) + val revisions = Channel(Channel.UNLIMITED) + val first = CompletableDeferred() + store.configure("default") { _, request -> + val revision = request.readRevision!! + revisions.send(revision) + if (request.readRevision == 3L) first.await() + SessionReadState(revision, false, revision) + } + store.markSeen("s1", 3) + assertEquals(3, revisions.receive()) + store.markSeen("s1", 4) + first.complete(Unit) + assertEquals(4, revisions.receive()) + store.state.first { it.pending.isEmpty() } + assertEquals(4, store.state.value.server["s1"]?.readRevision) + } - val cold = LastSeenStore(backgroundScope, dir) - assertEquals(1_234, cold.lastSeenAt("s1")) - // Baseline flag persists too — no re-seeding on cold start. - cold.initializeBaseline("hub-a", listOf(summary("s2", updatedAt = 700))) - assertEquals(0, cold.lastSeenAt("s2")) + @Test + fun `persistence failure does not claim read`() = runTest { + val file = Files.createTempFile("read-state", ".json").toFile() + val store = LastSeenStore(backgroundScope, file) + assertTrue(runCatching { store.markSeen("s1", 3) }.isFailure) + assertEquals(0, store.lastSeenAt("s1")) + assertTrue(store.state.value.pending.isEmpty()) } } diff --git a/android/core/data/src/test/kotlin/app/hapi/data/store/PaginationFixtureTest.kt b/android/core/data/src/test/kotlin/app/hapi/data/store/PaginationFixtureTest.kt index f0f7e84b..9d85a89c 100644 --- a/android/core/data/src/test/kotlin/app/hapi/data/store/PaginationFixtureTest.kt +++ b/android/core/data/src/test/kotlin/app/hapi/data/store/PaginationFixtureTest.kt @@ -231,6 +231,7 @@ class PaginationFixtureTest(private val fixtureName: String) { private fun projectWindowState(state: MessageWindowState): JsonObject = buildJsonObject { put("messages", JsonArray(state.messages.map(::projectMessage))) + put("readThroughRevision", state.readThroughRevision?.let(::JsonPrimitive) ?: JsonNull) put("hasMore", state.hasMore) put("epoch", state.epoch?.let(::JsonPrimitive) ?: JsonNull) put("viewMode", state.viewMode.wire) @@ -239,6 +240,7 @@ class PaginationFixtureTest(private val fixtureName: String) { } private fun projectMessage(message: WindowMessage): JsonObject = buildJsonObject { + message.wire.contentRevision?.let { put("contentRevision", it) } put("id", message.id) put("localId", message.localId?.let(::JsonPrimitive) ?: JsonNull) put("seq", message.seq?.let(::JsonPrimitive) ?: JsonNull) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt index d4f3cd21..505660cb 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/patch/SessionPatching.kt @@ -1,5 +1,6 @@ package app.hapi.protocol.patch +import app.hapi.protocol.wire.SessionReadState import app.hapi.protocol.wire.OptionalField import app.hapi.protocol.wire.Session import app.hapi.protocol.wire.SessionPatch @@ -48,6 +49,8 @@ fun isRenderIrrelevantSessionPatch(session: Session, patch: SessionPatch): Boole return false } patch.activeAt?.let { if (abs(it - session.activeAt) >= 60_000) return false } + patch.contentRevision?.let { if (session.contentRevision != it) return false } + patch.readState?.let { if (session.readState != it) return false } patch.updatedAt?.let { if (session.updatedAt != it) return false } patch.lastUserMessageAt?.let { if (session.lastUserMessageAt != it) return false } if (patch.metadata != null) return false @@ -112,6 +115,8 @@ fun applySessionDetailPatch(session: Session, patch: SessionPatch): Session? { val at = max(next.lastUserMessageAt ?: 0, it) if (next.lastUserMessageAt != at) set(next.copy(lastUserMessageAt = at)) } + patch.contentRevision?.let { set(next.copy(contentRevision = max(next.contentRevision ?: 0, it))) } + patch.readState?.let { set(next.copy(readState = SessionReadState.merge(next.readState, it))) } patch.updatedAt?.let { val nextUpdatedAt = max(next.updatedAt, it) if (next.updatedAt != nextUpdatedAt) set(next.copy(updatedAt = nextUpdatedAt)) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt index 447545ac..8f804ed5 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt @@ -294,6 +294,7 @@ object MessageWindowLogic { else -> responseOldest ?: previous.oldestPosition } return previous.withMessages(kept).copy( + readThroughRevision = maxOf(previous.readThroughRevision ?: 0, page.readThroughRevision ?: 0, kept.maxOfOrNull { it.wire.contentRevision ?: 0 } ?: 0), hasMore = page.hasMore || (!replaceServerRows && previous.hasMore) || dropped.isNotEmpty(), epoch = page.epoch, oldestPosition = oldest, @@ -347,6 +348,7 @@ object MessageWindowLogic { } val newest = maxPosition(nextAfter, merged.newestPosition) return merged.copy( + readThroughRevision = maxOf(merged.readThroughRevision ?: 0, page.readThroughRevision ?: 0, merged.messages.maxOfOrNull { it.wire.contentRevision ?: 0 } ?: 0), epoch = page.epoch, newestPosition = newest, warning = null, @@ -535,7 +537,8 @@ object MessageWindowLogic { } else { currentNewest } - return merged.copy(newestPosition = newest) + return merged.copy(newestPosition = newest, + readThroughRevision = merged.readThroughRevision?.let { revision -> maxOf(revision, incoming.maxOfOrNull { it.wire.contentRevision ?: 0 } ?: 0) }) } /** Optimistic append (web `appendOptimisticMessage`): never advances the newest cursor. */ @@ -730,7 +733,7 @@ object MessageWindowLogic { */ fun seededState(source: MessageWindowState, target: MessageWindowState): MessageWindowState = createState(target.sessionId) - .withMessages(source.messages.toList()) + .withMessages(source.messages.map { it.copy(wire = it.wire.copy(contentRevision = null)) }) .copy( hasMore = source.hasMore, tailRevision = source.tailRevision, diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowState.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowState.kt index fd63ef1d..c07b0d86 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowState.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowState.kt @@ -66,6 +66,7 @@ sealed interface OlderLoadOutcome { */ data class MessageWindowState( val sessionId: String, + val readThroughRevision: Long? = null, /** Window rows in position order (queued rows re-merged after each trim). */ val messages: List = emptyList(), /** Older history exists (server flag, or rows were trimmed away). */ diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt index 0cb3cbe0..70ad4527 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt @@ -24,6 +24,7 @@ data class MessagesPage( val snapshotHeadSeq: Long? = null, val snapshotHeadAt: Long? = null, val hasMore: Boolean, + val readThroughRevision: Long? = null, ) /** `GET /api/sessions/:id/messages` — messages in ascending display order. */ diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt index a721d62b..c1909759 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/DecryptedMessage.kt @@ -40,6 +40,7 @@ data class DecryptedMessage( val invokedAt: OptionalField = OptionalField.Absent, val scheduledAt: Long? = null, val deliveryState: String? = null, + val contentRevision: Long? = null, ) { /** `invokedAt` collapsed to a plain value (both absent and `null` → null). */ val invokedAtOrNull: Long? get() = invokedAt.valueOrNull() @@ -58,6 +59,7 @@ internal object DecryptedMessageSerializer : KSerializer { val obj = input.decodeJsonElement() as? JsonObject ?: throw SerializationException("DecryptedMessage must be a JSON object") return DecryptedMessage( + contentRevision = obj["contentRevision"]?.longOrNull, id = obj["id"].stringOrNull ?: throw SerializationException("DecryptedMessage.id must be a string"), seq = obj["seq"].longOrNull, @@ -79,6 +81,7 @@ internal object DecryptedMessageSerializer : KSerializer { val output = encoder as? JsonEncoder ?: throw SerializationException("DecryptedMessage supports JSON only") output.encodeJsonElement(buildJsonObject { + value.contentRevision?.let { put("contentRevision", JsonPrimitive(it)) } put("id", JsonPrimitive(value.id)) put("seq", value.seq?.let(::JsonPrimitive) ?: JsonNull) put("localId", value.localId?.let(::JsonPrimitive) ?: JsonNull) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt index 1e9ecb13..c6c38958 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Session.kt @@ -59,6 +59,8 @@ data class Session( val permissionMode: String? = null, val collaborationMode: String? = null, val copilotAgentMode: String? = null, + val contentRevision: Long? = null, + val readState: SessionReadState? = null, ) /** diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt index a011561a..6f599cee 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt @@ -59,10 +59,13 @@ data class SessionPatch( val backgroundTaskCount: Int? = null, /** Bare refetch trigger for `GET /sessions/:id/scratchlist`; carries no data. */ val scratchlistUpdatedAt: Long? = null, + val contentRevision: Long? = null, + val readState: SessionReadState? = null, ) object SessionPatches { private val KNOWN_KEYS = setOf( + "contentRevision", "readState", "active", "thinking", "activeTurnStartedAt", "activeAt", "updatedAt", "lastUserMessageAt", "metadata", "agentState", "todos", "teamState", "model", "modelReasoningEffort", "effort", "serviceTier", @@ -93,6 +96,8 @@ object SessionPatches { if (obj.isEmpty() || obj.keys.any { it !in KNOWN_KEYS }) return null return try { SessionPatch( + contentRevision = obj["contentRevision"]?.let { it.longOrNull?.takeIf { n -> n >= 0 } ?: return null }, + readState = obj["readState"]?.let { HapiJson.decodeFromJsonElement(SessionReadState.serializer(), it) }, active = obj["active"]?.let { it.boolOrNull ?: return null }, thinking = obj["thinking"]?.let { it.boolOrNull ?: return null }, activeTurnStartedAt = optionalNullableLong(obj, "activeTurnStartedAt") ?: return null, diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionReadState.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionReadState.kt new file mode 100644 index 00000000..f2aa2893 --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionReadState.kt @@ -0,0 +1,29 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionReadState( + val readRevision: Long = 0, + val manualUnread: Boolean = false, + val version: Long = 0, +) { + companion object { + fun merge(current: SessionReadState?, incoming: SessionReadState?): SessionReadState? { + if (incoming == null) return current + if (current == null) return incoming + val newest = if (incoming.version > current.version) incoming else current + return newest.copy(readRevision = maxOf(current.readRevision, incoming.readRevision)) + } + } +} + +@Serializable +data class SessionReadStateRequest( + val type: String, + val readRevision: Long? = null, + val expectedVersion: Long, +) + +@Serializable +data class SessionReadStateResponse(val readState: SessionReadState, val contentRevision: Long) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt index e0e8ed0a..3aa22843 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSummary.kt @@ -39,6 +39,8 @@ data class SessionSummary( val model: String? = null, val modelReasoningEffort: String? = null, val effort: String? = null, + val contentRevision: Long? = null, + val readState: SessionReadState? = null, ) /** `SessionSummaryMetadata` — list-sized metadata projection. */ diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SummaryPatching.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SummaryPatching.kt index 47a6b45a..77c4f8cc 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SummaryPatching.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SummaryPatching.kt @@ -167,6 +167,8 @@ object SummaryPatching { thinking = session.thinking, activeAt = session.activeAt, updatedAt = session.updatedAt, + contentRevision = session.contentRevision, + readState = session.readState, createdAt = session.createdAt, lastUserMessageAt = session.lastUserMessageAt, hasConversationContent = session.hasConversationContent, @@ -213,6 +215,8 @@ object SummaryPatching { */ fun applySessionSummaryPatch(current: SessionSummary, patch: SessionPatch): SessionSummary { var next = current.copy( + contentRevision = patch.contentRevision?.let { max(current.contentRevision ?: 0, it) } ?: current.contentRevision, + readState = SessionReadState.merge(current.readState, patch.readState), active = patch.active ?: current.active, thinking = patch.thinking ?: current.thinking, activeAt = patch.activeAt ?: current.activeAt, @@ -309,7 +313,8 @@ object SummaryPatching { * effect is suppressing a re-render that stores an equal-rendering value. */ fun isRenderIrrelevantPatch(current: SessionSummary, next: SessionSummary): Boolean { - return current.active == next.active + return current.contentRevision == next.contentRevision && current.readState == next.readState && + current.active == next.active && current.thinking == next.thinking && current.updatedAt == next.updatedAt && current.lastUserMessageAt == next.lastUserMessageAt diff --git a/docs/api/client-contract/index.md b/docs/api/client-contract/index.md index 5a0ace70..0afc82ec 100644 --- a/docs/api/client-contract/index.md +++ b/docs/api/client-contract/index.md @@ -15,6 +15,7 @@ does not imply that both apps expose a corresponding UI. | [Auth](./auth.md) | Pairing deeplink, access-token grammar, JWT exchange, silent re-auth, namespaces, credential storage | | [REST](./rest.md) | Interactive client APIs, other hub surfaces, request/response shapes, gzip negotiation | | [SSE](./sse.md) | `GET /api/events` stream: subscription modes, resume handshake, event ids, reconnect policy | +| [Read state](./read-state.md) | Shared unread revisions, rendered-tail receipts, offline retry and migration | | [Pagination](./pagination.md) | Message window: composite cursors, epoch reset, optimistic-send reconciliation | | [Messages](./messages.md) | `DecryptedMessage.content` decoding tree (`codex` / `output` / `event` families) | | [Errors](./errors.md) | `{status, code}` table, error body shapes, RPC-wrapped failure modes | diff --git a/docs/api/client-contract/read-state.md b/docs/api/client-contract/read-state.md new file mode 100644 index 00000000..22e8cb1b --- /dev/null +++ b/docs/api/client-contract/read-state.md @@ -0,0 +1,88 @@ +# Session read state + +Sources: `shared/src/sessionReadState.ts`, `shared/src/messages.ts`, +`hub/src/store/sessionReadState.ts`, `hub/src/sync/messageService.ts`. + +Read state is shared by Web, iOS and Android within one Hub and JWT namespace. +CLI terminal activity does not acknowledge reading. `updatedAt`, message `seq`, +permission state and transport heartbeat are independent of unread status. + +## Revisions and unread + +Session, SessionSummary and session SSE patches carry: + +```ts +contentRevision: number +readState: { readRevision: number; manualUnread: boolean; version: number } +``` + +The hub increments `contentRevision` atomically with a new readable assistant +message: answer text, rendered media or an error. User prompts, reasoning, tool +traffic, usage, metadata and heartbeats do not advance it. Imported/copied +history starts at zero; live delayed output counts. A replay with the same +`localId` keeps its original revision. Meaningful message rows carry their +`contentRevision` in REST and SSE. + +Unread is `manualUnread || (!(active && thinking) && contentRevision > readRevision)`. +Running suppresses the ordinary dot without discarding unseen content. Input and +permission attention remain independent. Selecting a session does not make it read. +Clients merge revisions monotonically and use the highest read-state `version` +for the manual flag, including when REST and SSE arrive out of order. + +## Read acknowledgement + +`PUT /api/sessions/:id/read-state` accepts one of: + +```json +{"type":"read","readRevision":12,"expectedVersion":4} +``` + +```json +{"type":"unread","expectedVersion":4} +``` + +The response includes `{status, contentRevision, readState}`. Counters must be +nonnegative safe integers. An invalid/future revision returns 400. Manual unread +uses compare-and-set: a mismatched version returns 409 with authoritative state. +Read requests advance by maximum; they clear manual unread only when +`expectedVersion` matches. Thus an older automatic read cannot erase a newer +manual choice, and an acknowledgement of revision 12 cannot consume revision 13. +An old session ID resolving to a replacement returns 409: revisions cannot be +replayed against a different session. Namespace access is checked before mutation. +Read writes emit `session-updated` patches without advancing `updatedAt`. + +Automatic reads require a visible foreground chat, successful message loading, +and the latest tail laid out on screen. Background, history browsing, hidden +inspectors/terminals, failed loads and list navigation cannot acknowledge reading. +A read-state-only update is not a new viewing action. Explicit “mark all read” +uses the content revisions observed in that list action. + +Latest message pages carry `page.readThroughRevision`. A completed forward +catch-up carries it only if its snapshot still reaches the current head. Before +pages and partial/bounded catch-ups cannot certify a later head. Clients retain +the receipt with the corresponding message window; they can advance it with +rendered incoming message revisions after initial tail synchronization. Never +substitute the summary’s head revision for a rendered window receipt. + +## Offline and lifecycle + +Persist the exact observed revision and expected version in a durable outbox +before presenting a successful local read. Retry transient failures, including +after restart/foregrounding. Each operation has an ID so an old response cannot +remove a newer queued operation. Authoritative 400/403/404/409 rejection rolls +back the optimistic mark; do not blindly rebase onto a newer manual choice. +Storage keys include Hub origin and JWT namespace; never derive namespace by +splitting the pairing access token. Switching Hub must finish pending disk writes. + +## Upgrade and history changes + +Database v30 adds persistent read fields with zero defaults. Existing history +becomes a read baseline once; old client timestamp/manual-marker files are ignored. +This clears old unread dots on upgrade, while pending approvals/input remain. +Update Hub and clients together; clients do not fall back to timestamp-based reads. + +Fork/import copies are baseline history. A session merge translates unread source +revisions above the destination head, transfers manual unread, and retires the +source state. Client windows seeded across IDs discard old message revisions and +fetch a fresh tail. Rewind never reuses revisions; if all unread output is removed, +it retires that output while preserving an explicit manual-unread choice. diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md index 683f16d9..f17dff0e 100644 --- a/docs/api/client-contract/rest.md +++ b/docs/api/client-contract/rest.md @@ -32,6 +32,7 @@ Source: `hub/src/web/routes/sessions.ts`; shapes `SessionSchema` (`shared/src/sc | Method & path | Request | Response | |---|---|---| | `GET /api/sessions` | Query: `limit?` (1–500), `order?=updatedAt` | `{sessions: (SessionSummary & {futureScheduledMessageCount, nextScheduledAt})[]}` | +| `PUT /api/sessions/:id/read-state` | `{type: "read", readRevision, expectedVersion}` or `{type: "unread", expectedVersion}` | `{status, contentRevision, readState}`; see [Read state](./read-state.md) | | `GET /api/sessions/:id` | — | `{session: Session}` (full record incl. `metadata`, `agentState`, `todos`, versions) | Default list order is `globalPinned` → `pinned` → @@ -43,7 +44,7 @@ Agent output, prompt echoes, delivery acknowledgements, liveness and pending request counts do not change the default order. User-message deletion does not rewind the timestamp. `order=updatedAt` explicitly requests pure activity recency (e.g. peer discovery). -`updatedAt` retains its activity/unread/date-filter semantics; `activeAt` +`updatedAt` retains its activity/date-filter semantics; `activeAt` remains transport keep-alive state. The Web session list may apply client-side grouping and search ranking after receiving this response; `GET /api/sessions` itself has no text-search parameter. List badges come from diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 680272dd..6859b41b 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -270,7 +270,8 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session localId: msg.localId, content: msg.content, createdAt: msg.createdAt, - invokedAt: msg.invokedAt + invokedAt: msg.invokedAt, + ...(msg.contentRevision ? { contentRevision: msg.contentRevision } : {}) } }) }) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index efa6f14f..9a48f633 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -47,7 +47,7 @@ export { WorkGraphValidationError } from './workGraph' -const SCHEMA_VERSION: number = 29 +const SCHEMA_VERSION: number = 30 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -364,6 +364,7 @@ export class Store { 26: () => this.migrateFromV26ToV27(), 27: () => this.migrateFromV27ToV28(), 28: () => this.migrateFromV28ToV29(), + 29: () => this.migrateFromV29ToV30(), }) if (currentVersion === 0) { @@ -419,6 +420,10 @@ export class Store { created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_user_message_at INTEGER, + content_revision INTEGER NOT NULL DEFAULT 0, + read_revision INTEGER NOT NULL DEFAULT 0, + read_state_version INTEGER NOT NULL DEFAULT 0, + manual_unread INTEGER NOT NULL DEFAULT 0, metadata TEXT, metadata_version INTEGER DEFAULT 1, agent_state TEXT, @@ -456,6 +461,7 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); CREATE TABLE IF NOT EXISTS messages ( + content_revision INTEGER NOT NULL DEFAULT 0, id TEXT PRIMARY KEY, session_id TEXT NOT NULL, content TEXT NOT NULL, @@ -1180,6 +1186,18 @@ export class Store { return new Set(rows.map((row) => row.name)) } + /** Historical rows start at zero: upgrading never marks the backlog unread. */ + private migrateFromV29ToV30(): void { + const columns = this.getSessionColumnNames() + for (const name of ['content_revision', 'read_revision', 'read_state_version', 'manual_unread']) { + if (columns.size > 0 && !columns.has(name)) this.db.exec(`ALTER TABLE sessions ADD COLUMN ${name} INTEGER NOT NULL DEFAULT 0`) + } + const messageColumns = this.getMessageColumnNames() + if (messageColumns.size > 0 && !messageColumns.has('content_revision')) { + this.db.exec('ALTER TABLE messages ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 0') + } + } + private getUserVersion(): number { const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number } | undefined return row?.user_version ?? 0 diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 81288fa6..e2304ba7 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -5,12 +5,13 @@ import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { z } from 'zod' -import { getLiveReasoningStreamId } from '@hapi/protocol/messages' +import { hasUnreadMessageContent, getLiveReasoningStreamId } from '@hapi/protocol/messages' import type { StoredMessage } from './types' import { decodeMessageContent, encodeMessageContent, truncateOversizedMessageContent } from './contentCodec' type DbMessageRow = { + content_revision: number id: string session_id: string // TEXT (plaintext JSON, legacy rows) or BLOB (zstd-compressed JSON) — see contentCodec.ts @@ -89,6 +90,7 @@ function toStoredMessage(row: DbMessageRow): StoredMessage { return { id: row.id, sessionId: row.session_id, + ...(row.content_revision > 0 ? { contentRevision: row.content_revision } : {}), content: decodeMessageContent(row.content), createdAt: row.created_at, seq: row.seq, @@ -173,6 +175,10 @@ export function addMessage( scheduled_at: scheduledAt ?? null }) + if (hasUnreadMessageContent(content)) { + prepareCached(db, 'UPDATE sessions SET content_revision = content_revision + 1 WHERE id = ?').run(sessionId) + prepareCached(db, 'UPDATE messages SET content_revision = (SELECT content_revision FROM sessions WHERE id = ?) WHERE id = ?').run(sessionId, id) + } recordUserMessage(db, sessionId, content, stampedAt) const positionAt = invokedAt ?? stampedAt if (previousHead && positionAt < previousHead.at) bumpMessageEpoch(db, sessionId) @@ -1117,9 +1123,29 @@ export function mergeSessionMessages( ).run(fromSessionId, ...localIds) } - const result = prepareCached(db, + // Revisions belong to a session, unlike stable message IDs. Translate + // only unread source outputs above the destination head; already-read + // history is a baseline and must not light up the destination. + const source = prepareCached(db, 'SELECT content_revision, read_revision, manual_unread FROM sessions WHERE id = ?') + .get(fromSessionId) as { content_revision: number; read_revision: number; manual_unread: number } + const target = prepareCached(db, 'SELECT content_revision FROM sessions WHERE id = ?') + .get(toSessionId) as { content_revision: number } + prepareCached(db, `UPDATE messages SET content_revision = CASE WHEN content_revision > ? + THEN ? + content_revision - ? ELSE 0 END WHERE session_id = ?`) + .run(source.read_revision, target.content_revision, source.read_revision, fromSessionId) + const additional = Math.max(0, source.content_revision - source.read_revision) + prepareCached(db, `UPDATE sessions SET content_revision = content_revision + ?, + manual_unread = MAX(manual_unread, ?), + read_state_version = read_state_version + CASE WHEN manual_unread < ? THEN 1 ELSE 0 END WHERE id = ?`) + .run(additional, source.manual_unread, source.manual_unread, toSessionId) + const result = prepareCached(db, 'UPDATE messages SET session_id = ? WHERE session_id = ?' ).run(toSessionId, fromSessionId) + // History moves can keep the source session alive. Retire its old + // unread state as well, so a second merge cannot count it again. + prepareCached(db, `UPDATE sessions SET read_revision = content_revision, manual_unread = 0, + read_state_version = read_state_version + 1 WHERE id = ? + AND (read_revision < content_revision OR manual_unread = 1)`).run(fromSessionId) if (result.changes > 0) { prepareCached(db, ` @@ -1202,6 +1228,12 @@ export function truncateMessagesFromLocalId( inserted += 1 } + // If rewind removed all unread output, retire that activity without + // reusing its revision numbers. Explicit manual-unread survives. + prepareCached(db, `UPDATE sessions SET read_revision = content_revision, read_state_version = read_state_version + 1 + WHERE id = ? AND read_revision < content_revision AND NOT EXISTS ( + SELECT 1 FROM messages WHERE session_id = sessions.id AND content_revision > sessions.read_revision + )`).run(sessionId) const epoch = bumpMessageEpoch(db, sessionId) return { deleted: deleted.changes, inserted, epoch } })() diff --git a/hub/src/store/migration-v13.test.ts b/hub/src/store/migration-v13.test.ts index d36aee8b..b8c6c838 100644 --- a/hub/src/store/migration-v13.test.ts +++ b/hub/src/store/migration-v13.test.ts @@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { const store = new Store(':memory:') expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(29) + expect(getUserVersion(store)).toBe(30) store.close() }) @@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(29) + expect(getUserVersion(store)).toBe(30) expect(store.messages.getMessageEpoch('session-1')).toBe(0) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { @@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(29) + expect(getUserVersion(store)).toBe(30) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { store?.close() diff --git a/hub/src/store/migration-v15.test.ts b/hub/src/store/migration-v15.test.ts index c3d78e1c..42f64ea8 100644 --- a/hub/src/store/migration-v15.test.ts +++ b/hub/src/store/migration-v15.test.ts @@ -19,7 +19,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(29) + expect(getUserVersion(store)).toBe(30) store.close() }) @@ -40,7 +40,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(29) + expect(getUserVersion(store)).toBe(30) } finally { store?.close() rmSync(dir, { recursive: true, force: true }) @@ -60,7 +60,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { store2 = new Store(dbPath) const cols2 = getColumns(store2, 'session_scratchlist') expect(cols2).toEqual(cols1) - expect(getUserVersion(store2)).toBe(29) + expect(getUserVersion(store2)).toBe(30) } finally { store2?.close() store1?.close() diff --git a/hub/src/store/migration-v18.test.ts b/hub/src/store/migration-v18.test.ts index 439ff63b..5584ed30 100644 --- a/hub/src/store/migration-v18.test.ts +++ b/hub/src/store/migration-v18.test.ts @@ -38,7 +38,7 @@ describe('Store V18->V19 migration: usage scan state', () => { const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number } expect(table?.name).toBe('usage_scan_state') - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(usageRows.count).toBe(0) } finally { store?.close() diff --git a/hub/src/store/migration-v19.test.ts b/hub/src/store/migration-v19.test.ts index 6ec9b97c..cf920011 100644 --- a/hub/src/store/migration-v19.test.ts +++ b/hub/src/store/migration-v19.test.ts @@ -37,7 +37,7 @@ describe('Store V19->current migration: usage re-index', () => { const scanRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_scan_state').get() as { count: number } const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number } - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(scanRows.count).toBe(0) expect(usageRows.count).toBe(0) } finally { diff --git a/hub/src/store/migration-v20.test.ts b/hub/src/store/migration-v20.test.ts index 01506017..106472dc 100644 --- a/hub/src/store/migration-v20.test.ts +++ b/hub/src/store/migration-v20.test.ts @@ -43,7 +43,7 @@ describe('Store V20->V21 migration: usage semantics re-index', () => { store = new Store(dbPath) const internalDb = (store as unknown as { db: Database }).db const count = (table: string): number => (internalDb.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count - expect((internalDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(29) + expect((internalDb.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(30) expect(count('usage_events')).toBe(0) expect(count('usage_scan_state')).toBe(0) expect(store.messages.getMessages(session.id)).toHaveLength(1) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index cfaa1ecd..6ee41c8e 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -42,7 +42,7 @@ describe('schema migration v22 to v26', () => { expect(links?.name).toBe('event_links') const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> expect(columns.map((column) => column.name)).toContain('delivery_state') - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) migrated.close() }) }) diff --git a/hub/src/store/migration-v24.test.ts b/hub/src/store/migration-v24.test.ts index f9e780f2..be739557 100644 --- a/hub/src/store/migration-v24.test.ts +++ b/hub/src/store/migration-v24.test.ts @@ -41,7 +41,7 @@ describe('schema migration v23 to v26', () => { expect(columns.some((col) => col.name === 'push_key')).toBe(true) const messageColumns = messageColumnsStatement.all() as Array<{ name: string }> expect(messageColumns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) // Existing Android rows survive with a NULL push key. const devices = migrated.fcm.getDevicesByNamespace('default') @@ -91,7 +91,7 @@ describe('schema migration v23 to v26', () => { const version = versionStatement.get() as { user_version: number } const indexes = indexesStatement.all() as Array<{ name: string }> expect(columns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(indexes.map((index) => index.name)).toEqual(expect.arrayContaining([ 'idx_messages_immediate_queued', 'idx_messages_future_scheduled_queued' diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 22ec5f41..07caa68f 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -41,7 +41,7 @@ describe('schema migration v25 to v26', () => { ORDER BY seq ASC `).all('session-id') as Array<{ detail: string }> - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(plan.some((row) => row.detail.includes('idx_messages_immediate_queued'))).toBe(true) migrated.close() }) diff --git a/hub/src/store/migration-v26.test.ts b/hub/src/store/migration-v26.test.ts index c2286661..abd79e76 100644 --- a/hub/src/store/migration-v26.test.ts +++ b/hub/src/store/migration-v26.test.ts @@ -59,7 +59,7 @@ describe('schema migration v26 to v27', () => { const minPlan = minPlanStatement.all('a', 'b', 123) as Array<{ detail: string }> minPlanStatement.finalize() - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(countPlan.some((row) => row.detail.includes('idx_messages_future_scheduled_queued'))).toBe(true) expect(minPlan.some((row) => row.detail.includes('idx_messages_future_scheduled_queued'))).toBe(true) migrated.close() diff --git a/hub/src/store/migration-v28.test.ts b/hub/src/store/migration-v28.test.ts index 8bda4b3d..e1b779dc 100644 --- a/hub/src/store/migration-v28.test.ts +++ b/hub/src/store/migration-v28.test.ts @@ -32,7 +32,7 @@ describe('schema migration v27 to v28', () => { const migrated = new Store(dbPath) const internalDb = (migrated as unknown as { db: Database }).db const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } - expect(version.user_version).toBe(29) + expect(version.user_version).toBe(30) expect(migrated.turns.open(session.id, 'default', 1_000, 'agent')).toBe(true) expect(migrated.turns.open(session.id, 'default', 2_000, 'agent')).toBe(false) diff --git a/hub/src/store/migration-v30.test.ts b/hub/src/store/migration-v30.test.ts new file mode 100644 index 00000000..27c19de5 --- /dev/null +++ b/hub/src/store/migration-v30.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Store } from '.' + +it('upgrades timestamp-era history to a read baseline exactly once', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-read-migration-')) + try { + const path = join(dir, 'hub.db') + const before = new Store(path) + const id = before.sessions.getOrCreateSession('s', {}, null, 'default').id + const reply = { role: 'agent', content: { type: 'codex', data: { type: 'message', message: 'history' } } } + before.messages.addMessage(id, reply, 'old') + before.close() + const legacy = new Database(path) + for (const column of ['content_revision', 'read_revision', 'read_state_version', 'manual_unread']) { + legacy.exec(`ALTER TABLE sessions DROP COLUMN ${column}`) + } + legacy.exec('ALTER TABLE messages DROP COLUMN content_revision; PRAGMA user_version = 29;') + legacy.close() + const migrated = new Store(path) + expect(migrated.sessions.getSession(id)?.readState).toEqual({ readRevision: 0, manualUnread: false, version: 0 }) + expect(migrated.sessions.getSession(id)?.contentRevision).toBe(0) + expect(migrated.messages.getMessages(id)).toHaveLength(1) + migrated.messages.addMessage(id, reply, 'new') + migrated.close() + const restarted = new Store(path) + expect(restarted.sessions.getSession(id)?.contentRevision).toBe(1) + expect(restarted.sessions.getSession(id)?.readState?.readRevision).toBe(0) + restarted.close() + } finally { rmSync(dir, { recursive: true, force: true }) } +}) diff --git a/hub/src/store/sessionReadState.test.ts b/hub/src/store/sessionReadState.test.ts new file mode 100644 index 00000000..3937b66d --- /dev/null +++ b/hub/src/store/sessionReadState.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '.' + +const reply = (text: string) => ({ role: 'agent', content: { type: 'codex', data: { type: 'message', message: text } } }) + +describe('session read state', () => { + it('counts readable output atomically, deduplicates replay and excludes metadata and history imports', () => { + const store = new Store(':memory:') + try { + const session = store.sessions.getOrCreateSession('s', {}, null, 'default') + const id = session.id + expect(session.contentRevision).toBe(0) + store.messages.addImportedMessage(id, reply('old history'), 'import', 1) + store.messages.addMessage(id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'user') + store.messages.addMessage(id, { role: 'agent', content: { type: 'codex', data: { type: 'reasoning', message: 'thinking' } } }, 'reason') + store.sessions.updateSessionMetadata(id, { name: 'Changed' }, session.metadataVersion, 'default') + expect(store.sessions.getSession(id)?.contentRevision).toBe(0) + const first = store.messages.addMessage(id, reply('answer'), 'reply') + expect(first.contentRevision).toBe(1) + expect(store.messages.addMessage(id, reply('answer'), 'reply').contentRevision).toBe(1) + expect(store.sessions.getSession(id)?.contentRevision).toBe(1) + expect(store.messages.getMessages(id).find(m => m.id === first.id)?.contentRevision).toBe(1) + } finally { store.close() } + }) + + it('a delayed read never consumes a newer reply or another namespace', () => { + const store = new Store(':memory:') + try { + const { id } = store.sessions.getOrCreateSession('s', {}, null, 'one') + store.messages.addMessage(id, reply('first'), 'one') + store.messages.addMessage(id, reply('second'), 'two') + expect(store.sessions.updateReadState(id, 'other', { type: 'read', readRevision: 2, expectedVersion: 0 })).toBeNull() + const result = store.sessions.updateReadState(id, 'one', { type: 'read', readRevision: 1, expectedVersion: 0 })! + expect(result.readState.readRevision).toBe(1) + expect(result.contentRevision).toBe(2) + expect(store.sessions.updateReadState(id, 'one', { type: 'read', readRevision: 99, expectedVersion: 0 })?.status).toBe('invalid') + expect(store.sessions.updateReadState(id, 'one', { type: 'read', readRevision: 0, expectedVersion: 0 })?.readState).toEqual(result.readState) + } finally { store.close() } + }) + + it('stale automatic reads cannot erase a newer manual unread operation', () => { + const store = new Store(':memory:') + try { + const { id } = store.sessions.getOrCreateSession('s', {}, null, 'default') + store.messages.addMessage(id, reply('answer'), 'reply') + const unread = store.sessions.updateReadState(id, 'default', { type: 'unread', expectedVersion: 0 })! + expect(unread.readState.manualUnread).toBe(true) + const staleRead = store.sessions.updateReadState(id, 'default', { type: 'read', readRevision: 1, expectedVersion: 0 })! + expect(staleRead.readState.manualUnread).toBe(true) + expect(staleRead.readState.readRevision).toBe(1) + expect(store.sessions.updateReadState(id, 'default', { type: 'unread', expectedVersion: 0 })?.status).toBe('conflict') + const read = store.sessions.updateReadState(id, 'default', { type: 'read', readRevision: 1, expectedVersion: staleRead.readState.version })! + expect(read.readState.manualUnread).toBe(false) + expect(store.sessions.getSession(id)?.updatedAt).toBe(store.sessions.getSession(id)?.createdAt) + } finally { store.close() } + }) +}) + +describe('read revisions across history changes', () => { + it('translates unread source output without comparing two session revision domains', () => { + const store = new Store(':memory:') + try { + const source = store.sessions.getOrCreateSession('source', {}, null, 'default').id + const target = store.sessions.getOrCreateSession('target', {}, null, 'default').id + store.messages.addMessage(source, reply('read history'), 'a') + store.sessions.updateReadState(source, 'default', { type: 'read', readRevision: 1, expectedVersion: 0 }) + store.messages.addMessage(source, reply('unread history'), 'b') + store.sessions.updateReadState(source, 'default', { type: 'unread', expectedVersion: 1 }) + store.messages.addMessage(target, reply('target output'), 'c') + store.sessions.updateReadState(target, 'default', { type: 'read', readRevision: 1, expectedVersion: 0 }) + store.messages.mergeSessionMessages(source, target) + const state = store.sessions.getSession(target)! + expect(state.contentRevision).toBe(2) + expect(state.readState).toEqual({ readRevision: 1, manualUnread: true, version: 2 }) + expect(store.messages.getMessages(target).find(m => m.localId === 'a')?.contentRevision).toBeUndefined() + expect(store.messages.getMessages(target).find(m => m.localId === 'b')?.contentRevision).toBe(2) + expect(store.sessions.getSession(source)?.readState).toEqual({ readRevision: 2, manualUnread: false, version: 3 }) + store.messages.mergeSessionMessages(source, target) + expect(store.sessions.getSession(target)?.contentRevision).toBe(2) + } finally { store.close() } + }) + + it('rewind retires removed unread output without reusing its revisions', () => { + const store = new Store(':memory:') + try { + const id = store.sessions.getOrCreateSession('s', {}, null, 'default').id + store.messages.addMessage(id, { role: 'user', content: { type: 'text', text: 'point' } }, 'point', null, 1) + store.messages.addMessage(id, reply('removed output'), 'a', null, 2) + store.sessions.updateReadState(id, 'default', { type: 'unread', expectedVersion: 0 }) + store.messages.truncateMessagesFromLocalId(id, 'point') + expect(store.sessions.getSession(id)?.contentRevision).toBe(1) + expect(store.sessions.getSession(id)?.readState).toEqual({ readRevision: 1, manualUnread: true, version: 2 }) + expect(store.messages.addMessage(id, reply('new output'), 'b').contentRevision).toBe(2) + } finally { store.close() } + }) +}) diff --git a/hub/src/store/sessionReadState.ts b/hub/src/store/sessionReadState.ts new file mode 100644 index 00000000..2147fac3 --- /dev/null +++ b/hub/src/store/sessionReadState.ts @@ -0,0 +1,30 @@ +import type { Database } from 'bun:sqlite' +import type { SessionReadState, SessionReadStateRequest } from '@hapi/protocol' +import { prepareCached } from './statementCache' + +type ReadRow = { content_revision: number; read_revision: number; read_state_version: number; manual_unread: number } +export type ReadStateResult = { status: 'ok' | 'conflict' | 'invalid'; readState: SessionReadState; contentRevision: number } + +export function updateSessionReadState(db: Database, sessionId: string, namespace: string, request: SessionReadStateRequest): ReadStateResult | null { + return db.transaction(() => { + const row = prepareCached(db, `SELECT content_revision, read_revision, read_state_version, manual_unread + FROM sessions WHERE id = ? AND namespace = ?`).get(sessionId, namespace) as ReadRow | undefined + if (!row) return null + const state: SessionReadState = { readRevision: row.read_revision, version: row.read_state_version, manualUnread: row.manual_unread === 1 } + const result = (status: ReadStateResult['status']): ReadStateResult => ({ status, readState: state, contentRevision: row.content_revision }) + if (request.type === 'read' && request.readRevision > row.content_revision) return result('invalid') + if (request.type === 'unread' && request.expectedVersion !== state.version) return result('conflict') + + const readRevision = request.type === 'read' ? Math.max(state.readRevision, request.readRevision) : state.readRevision + // An older read can advance the watermark, but cannot erase a newer + // explicit unread operation from a different client. + const manualUnread = request.type === 'unread' || (state.manualUnread && request.expectedVersion !== state.version) + if (readRevision === state.readRevision && manualUnread === state.manualUnread) return result('ok') + prepareCached(db, `UPDATE sessions SET read_revision = ?, manual_unread = ?, read_state_version = read_state_version + 1 + WHERE id = ? AND namespace = ?`).run(readRevision, manualUnread ? 1 : 0, sessionId, namespace) + state.readRevision = readRevision + state.manualUnread = manualUnread + state.version += 1 + return result('ok') + })() +} diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index c2abf817..7685684b 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -1,4 +1,6 @@ import type { Database } from 'bun:sqlite' +import type { SessionReadStateRequest } from '@hapi/protocol' +import { updateSessionReadState } from './sessionReadState' import type { StoredSession, VersionedUpdateResult } from './types' import { @@ -32,6 +34,10 @@ export class SessionStore { this.db = db } + updateReadState(id: string, namespace: string, request: SessionReadStateRequest) { + return updateSessionReadState(this.db, id, namespace, request) + } + getOrCreateSession( tag: string, metadata: unknown, diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 97f54db2..8ef0b8cc 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -144,6 +144,10 @@ export function mergeSessionMetadata(prior: unknown, next: unknown): unknown { } type DbSessionRow = { + content_revision: number + read_revision: number + read_state_version: number + manual_unread: number id: string tag: string | null namespace: string @@ -179,6 +183,8 @@ function toStoredSession(row: DbSessionRow): StoredSession { createdAt: row.created_at, updatedAt: row.updated_at, lastUserMessageAt: row.last_user_message_at ?? undefined, + contentRevision: row.content_revision, + readState: { readRevision: row.read_revision, version: row.read_state_version, manualUnread: row.manual_unread === 1 }, pinned: row.pinned === 1, globalPinned: row.global_pinned === 1, metadata: safeJsonParse(row.metadata), diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index e6effd06..43dc5b48 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -1,4 +1,8 @@ +import type { SessionReadState } from '@hapi/protocol' + export type StoredSession = { + contentRevision?: number + readState?: SessionReadState id: string tag: string | null namespace: string @@ -42,6 +46,7 @@ export type StoredMachine = { export type MessageDeliveryState = 'indeterminate' export type StoredMessage = { + contentRevision?: number id: string sessionId: string content: unknown diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 206daa62..b90c5bc9 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -1913,3 +1913,30 @@ describe('MessageService.sweepImmediateQueuedOnSessionEnd — scheduled rows are expect(stillQueued.find((m) => m.localId === 'local-future')?.invokedAt).toBeNull() }) }) + +describe('observed read revision receipts', () => { + it('latest and completed catch-up receipt cover only their observed head', () => { + const store = makeStore() + try { + const { id } = makeSession(store, 'read-receipt') + const reply = (text: string) => ({ role: 'agent', content: { type: 'codex', data: { type: 'message', message: text } } }) + store.messages.addMessage(id, reply('one'), 'one', null, 1) + const service = new MessageService(store, makeIo(() => {}), makePublisher() as unknown as EventPublisher) + const first = service.getMessagesPage(id, { limit: 1 }) + expect(first.page.readThroughRevision).toBe(1) + const after = { at: first.page.snapshotHeadAt!, seq: first.page.snapshotHeadSeq! } + store.messages.addMessage(id, reply('two'), 'two', null, 2) + store.messages.addMessage(id, reply('three'), 'three', null, 3) + const partial = service.getMessagesPage(id, { limit: 1, after }) + expect(partial.page.hasMore).toBe(true) + expect(partial.page.readThroughRevision).toBeUndefined() + const until = { at: partial.page.snapshotHeadAt!, seq: partial.page.snapshotHeadSeq! } + store.messages.addMessage(id, reply('concurrent'), 'four', null, 4) + const bounded = service.getMessagesPage(id, { limit: 20, after, until }) + expect(bounded.page.readThroughRevision).toBeUndefined() + expect(service.getMessagesPage(id, { limit: 20, after }).page.readThroughRevision).toBe(4) + expect(service.getMessagesPage(id, { limit: 20, before: until }).page.readThroughRevision).toBeUndefined() + expect(first.page.readThroughRevision).toBe(1) + } finally { store.close() } + }) +}) diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 10348ac3..c877cb27 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -48,6 +48,7 @@ function toDecryptedMessage(message: StoredMessageForDelivery): DecryptedMessage return { id: message.id, seq: message.seq, + ...(message.contentRevision ? { contentRevision: message.contentRevision } : {}), localId: message.localId, content: message.content, createdAt: message.createdAt, @@ -386,6 +387,7 @@ export class MessageService { return { messages, page: { + ...(direction === 'latest' ? { readThroughRevision: this.store.sessions.getSession(sessionId)?.contentRevision ?? 0 } : {}), direction, limit, epoch, @@ -413,10 +415,13 @@ export class MessageService { ? (comparePosition(requestedUntil, currentHead) <= 0 ? requestedUntil : currentHead) : requestedUntil ?? currentHead + const atCurrentHead = !currentHead || (snapshotHead !== null && comparePosition(snapshotHead, currentHead) === 0) + const readThroughRevision = atCurrentHead ? this.store.sessions.getSession(sessionId)?.contentRevision ?? 0 : undefined if (!snapshotHead || comparePosition(snapshotHead, after) <= 0) { return { messages: [], page: { + readThroughRevision, direction: 'after', limit, epoch, @@ -445,6 +450,7 @@ export class MessageService { return { messages: toVisibleDecryptedMessages(pageRows), page: { + ...(!hasMore && readThroughRevision !== undefined ? { readThroughRevision } : {}), direction: 'after', limit, epoch, diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 3d303908..6d243ccd 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,3 +1,4 @@ +import { mergeReadState } from '@hapi/protocol' import { SESSION_LIFECYCLE_IDLE, SESSION_LIFECYCLE_RUNNING } from '@hapi/protocol' import { AgentStateSchema, MetadataSchema, SessionPatchSchema, TeamStateSchema } from '@hapi/protocol/schemas' import type { BackgroundTask, CodexCollaborationMode, CopilotAgentMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' @@ -245,6 +246,8 @@ export class SessionCache { seq: stored.seq, createdAt: stored.createdAt, updatedAt: stored.updatedAt, + contentRevision: stored.contentRevision, + readState: stored.readState, lastUserMessageAt: stored.lastUserMessageAt, pinned: stored.pinned, globalPinned: stored.globalPinned, @@ -386,6 +389,8 @@ export class SessionCache { if (patch.active !== undefined) session.active = patch.active if (patch.thinking !== undefined) session.thinking = patch.thinking if (patch.activeAt !== undefined) session.activeAt = patch.activeAt + if (patch.contentRevision !== undefined) session.contentRevision = Math.max(session.contentRevision ?? 0, patch.contentRevision) + if (patch.readState) session.readState = mergeReadState(session.readState, patch.readState) if (patch.updatedAt !== undefined) session.updatedAt = Math.max(session.updatedAt, patch.updatedAt) if (patch.lastUserMessageAt !== undefined) { session.lastUserMessageAt = Math.max(session.lastUserMessageAt ?? 0, patch.lastUserMessageAt) @@ -685,6 +690,18 @@ export class SessionCache { }) } + syncReadState(sessionId: string): void { + const stored = this.store.sessions.getSession(sessionId) + const session = this.sessions.get(sessionId) + if (!stored || !session) return + if (session.contentRevision === stored.contentRevision + && session.readState?.version === stored.readState?.version) return + session.contentRevision = stored.contentRevision + session.readState = stored.readState + this.publisher.emit({ type: 'session-updated', sessionId, namespace: session.namespace, + data: { contentRevision: session.contentRevision, readState: session.readState } }) + } + recordSessionActivity(sessionId: string, updatedAt: number): void { if (!Number.isFinite(updatedAt)) { return @@ -1504,6 +1521,8 @@ export class SessionCache { if (!metadataTransferred) throw new Error('Failed to preserve session metadata during merge') const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + this.syncReadState(newSessionId) + if (!options.deleteOldSession) this.syncReadState(oldSessionId) // mergeSessions deletes the source. mergeSessionHistory keeps it alive // with the original socket, so its notify chain must stay on that id. if (options.deleteOldSession) { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f31eaa0a..3c4b351b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import { isKnownFlavor, isLiveLifecycleState, isSteeringSupportedForSession, MACHINE_CAPABILITIES, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' +import { type SessionReadStateRequest, isKnownFlavor, isLiveLifecycleState, isSteeringSupportedForSession, MACHINE_CAPABILITIES, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, @@ -598,6 +598,7 @@ export class SyncEngine { if (!this.getSession(event.sessionId)) { this.sessionCache.refreshSession(event.sessionId) } + this.sessionCache.syncReadState(event.sessionId) } // Emit chat updates before ledger capture so SSE is not blocked on @@ -738,6 +739,12 @@ export class SyncEngine { this.sessionCache.setBackgroundTasks(sessionId, tasks) } + updateReadState(sessionId: string, namespace: string, request: SessionReadStateRequest) { + const result = this.store.sessions.updateReadState(sessionId, namespace, request) + if (result?.status === 'ok') this.sessionCache.syncReadState(sessionId) + return result + } + recordSessionActivity(sessionId: string, updatedAt: number): void { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index 05913d41..8e6d9aaa 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -2085,7 +2085,9 @@ function importSingleCodexSession(options: { if (targetIsActive && messagesToAppend.length > 0) { throw new Error('当前会话正在运行且 Codex transcript 有新消息,停止或归档后再同步,避免消息顺序错乱') } - const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) + const appendedMessages = messagesToAppend.map((message, index) => options.store.messages.addImportedMessage( + sessionId!, message, `codex-desktop:${options.codexSessionId}:${comparablePrefixCount + index}`, Date.now() + ).message) // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() diff --git a/hub/src/web/routes/sessionReadState.test.ts b/hub/src/web/routes/sessionReadState.test.ts new file mode 100644 index 00000000..78022a4d --- /dev/null +++ b/hub/src/web/routes/sessionReadState.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { SessionReadState, SyncEvent } from '@hapi/protocol' +import { Store } from '../../store' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +it('shares monotonic read state over REST and namespace-scoped SSE without touching activity', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast: (event: SyncEvent) => events.push(event) } as never) + try { + const session = engine.getOrCreateSession('tag', { path: '/tmp', host: 'mac' }, null, 'alice') + const message = store.messages.addMessage(session.id, { role: 'agent', content: { type: 'codex', data: { type: 'message', message: 'answer' } } }, 'one') + engine.handleRealtimeEvent({ type: 'message-received', sessionId: session.id, message }) + expect(engine.getSession(session.id)?.contentRevision).toBe(1) + expect(events.some(e => e.type === 'session-updated' && e.namespace === 'alice' && e.data && 'contentRevision' in e.data && e.data.contentRevision === 1)).toBe(true) + const app = new Hono() + app.use('*', async (c, next) => { c.set('namespace', c.req.header('test-namespace') ?? 'alice'); await next() }) + app.route('/api', createSessionsRoutes(() => engine)) + const send = (body: unknown, namespace = 'alice') => app.request(`/api/sessions/${session.id}/read-state`, { + method: 'PUT', headers: { 'content-type': 'application/json', 'test-namespace': namespace }, body: JSON.stringify(body) + }) + const at = store.sessions.getSession(session.id)!.updatedAt + expect((await send({ type: 'read', readRevision: 1, expectedVersion: 0 }, 'bob')).status).toBe(403) + expect((await send({ type: 'read', readRevision: -1, expectedVersion: 0 })).status).toBe(400) + expect((await send({ type: 'read', readRevision: 9, expectedVersion: 0 })).status).toBe(400) + const read = await send({ type: 'read', readRevision: 1, expectedVersion: 0 }) + expect(read.status).toBe(200) + expect(((await read.json()) as { readState: SessionReadState }).readState).toEqual({ readRevision: 1, manualUnread: false, version: 1 }) + expect(engine.getSession(session.id)?.readState?.readRevision).toBe(1) + expect(events.some(e => e.type === 'session-updated' && e.namespace === 'alice' && e.data && 'readState' in e.data && e.data.readState?.version === 1)).toBe(true) + expect((await send({ type: 'unread', expectedVersion: 1 })).status).toBe(200) + const stale = await send({ type: 'read', readRevision: 1, expectedVersion: 0 }) + expect(((await stale.json()) as { readState: SessionReadState }).readState.manualUnread).toBe(true) + expect((await send({ type: 'unread', expectedVersion: 0 })).status).toBe(409) + expect(store.sessions.getSession(session.id)?.updatedAt).toBe(at) + } finally { engine.stop(); store.close() } +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 7c91fdb7..5b2d4e23 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,5 +1,6 @@ import { compareSessionSummaries } from '@hapi/protocol' import { + SessionReadStateRequestSchema, CursorMigrateToAcpRequestSchema, DeleteUploadRequestSchema, ForkConversationRequestSchema, @@ -113,6 +114,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ sessions }) }) + app.put('/sessions/:id/read-state', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const access = requireSessionFromParam(c, engine) + if (access instanceof Response) return access + // A revision belongs to one session ID. Never apply an old ID's + // pending operation to a replacement session via alias resolution. + if (access.sessionId !== c.req.param('id')) return c.json({ error: 'Session replaced' }, 409) + const body = SessionReadStateRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'Invalid read state' }, 400) + const result = engine.updateReadState(access.sessionId, c.get('namespace'), body.data) + if (!result) return c.json({ error: 'Session not found' }, 404) + return c.json(result, result.status === 'invalid' ? 400 : result.status === 'conflict' ? 409 : 200) + }) + app.get('/sessions/:id/export', (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/ios/Hapi/Features/Chat/ChatModel.swift b/ios/Hapi/Features/Chat/ChatModel.swift index 269d107b..49606c2e 100644 --- a/ios/Hapi/Features/Chat/ChatModel.swift +++ b/ios/Hapi/Features/Chat/ChatModel.swift @@ -55,6 +55,9 @@ final class ChatModel { private(set) var warning: String? /// Bumps on tail-side content changes. private(set) var tailRevision = 0 + private(set) var readThroughRevision: Int? + private(set) var renderedReadState: SessionReadState? + private var displayedReadRevision: Int? /// Bumps when an older page was prepended (drives scroll re-anchoring). private(set) var historyVersion = 0 private(set) var historyPaging = ChatHistoryPagingState() @@ -317,6 +320,7 @@ final class ChatModel { func stop() { guard isActive else { return } isActive = false + displayedReadRevision = nil statesTask?.cancel() statesTask = nil pipelineTask?.cancel() @@ -351,11 +355,29 @@ final class ChatModel { // MARK: - Actions + func resetDisplayedReadReceipt() { displayedReadRevision = nil } + + /// Called by the laid-out transcript, with the revision belonging to its rows. + func markDisplayed(revision: Int?, serverState: SessionReadState?) { + guard isActive, followsTail, !isInspectingContent, !isSyncingTail, + !isInitialLoading, !loadFailed, warning == nil, !hasTrimmedTail, + let revision, revision != displayedReadRevision else { return } + displayedReadRevision = revision + Task { + await hub.lastSeenStore.markSeen(sessionId: sessionId, revision: revision, serverState: serverState) + if let error = hub.lastSeenStore.persistenceError { + displayedReadRevision = nil + showNotice(error) + } + } + } + func readingViewportChanged(followsTail: Bool, needsOlder: Bool, isAwayFromBottom: Bool) { let followsTail = isInspectingContent ? false : followsTail let needsOlder = isInspectingContent ? false : needsOlder let changedMode = self.followsTail != followsTail self.followsTail = followsTail + if !followsTail { displayedReadRevision = nil } self.isAwayFromBottom = !followsTail && isAwayFromBottom viewportNeedsOlder = needsOlder if changedMode, !isJumpingToLatest, let controller = chat.windowController { @@ -625,13 +647,8 @@ final class ChatModel { isInitialLoading = isEmpty && !syncSettled && window.warning == nil loadFailed = isEmpty && syncSettled && (window.warning != nil || detailLoadFailed) - // Watermark = the updatedAt currently on screen, from whichever cache - // is fresher (summary via the global pipe, detail via this one). - // markSeen is monotonic, so stale inputs cannot rewind it. - let updatedAt = max(detail?.updatedAt ?? 0, summary?.updatedAt ?? 0) - if updatedAt > 0 && !isInspectingContent { - hub.lastSeenStore.markSeen(sessionId: sessionId, seenAt: updatedAt) - } + readThroughRevision = window.readThroughRevision + renderedReadState = SessionReadState.merge(detail?.readState, summary?.readState) } // MARK: - Header derivation (Android `buildHeader` port) diff --git a/ios/Hapi/Features/Chat/ChatTranscriptView.swift b/ios/Hapi/Features/Chat/ChatTranscriptView.swift index ffe07d3e..91fed72d 100644 --- a/ios/Hapi/Features/Chat/ChatTranscriptView.swift +++ b/ios/Hapi/Features/Chat/ChatTranscriptView.swift @@ -34,6 +34,7 @@ enum TranscriptRow: Identifiable, Equatable { } struct ChatTranscriptView: View { + @Environment(\.scenePhase) private var scenePhase let model: ChatModel private var rows: [TranscriptRow] { @@ -49,6 +50,8 @@ struct ChatTranscriptView: View { } var body: some View { + let revision = model.readThroughRevision + let readState = model.renderedReadState AnchoredTranscriptList( items: rows, historyVersion: model.historyVersion, @@ -62,6 +65,9 @@ struct ChatTranscriptView: View { ) }, onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) }, + onTailDisplayed: { + if scenePhase == .active { model.markDisplayed(revision: revision, serverState: readState) } + }, spacingBefore: { previous, row in row.spacing(after: previous) } ) { row in // The list bridges the current environment into its hosting roots. diff --git a/ios/Hapi/Features/Chat/ChatView.swift b/ios/Hapi/Features/Chat/ChatView.swift index 7bce8110..80c444ad 100644 --- a/ios/Hapi/Features/Chat/ChatView.swift +++ b/ios/Hapi/Features/Chat/ChatView.swift @@ -13,6 +13,7 @@ import UniformTypeIdentifiers /// Transcript layout and scroll intent live in `ChatTranscriptView`. struct ChatView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.scenePhase) private var scenePhase @State private var model: ChatModel /// Adaptive session settings (toolbar gear). @State private var configOpen = false @@ -67,6 +68,11 @@ struct ChatView: View { loadFailedState } else if model.blocks.isEmpty && !model.hasMore { emptyState + .task(id: scenePhase == .active ? model.readThroughRevision : nil) { + if scenePhase == .active { + model.markDisplayed(revision: model.readThroughRevision, serverState: model.renderedReadState) + } + } } else { ChatTranscriptView(model: model) } @@ -307,6 +313,9 @@ struct ChatView: View { onNavigateToSession?(superseding) } } + .onChange(of: scenePhase) { + if scenePhase != .active { model.resetDisplayedReadReceipt() } + } } private var exportPromptTitle: Text { diff --git a/ios/Hapi/Features/Home/HomeView.swift b/ios/Hapi/Features/Home/HomeView.swift index ff6603e6..87aa30b9 100644 --- a/ios/Hapi/Features/Home/HomeView.swift +++ b/ios/Hapi/Features/Home/HomeView.swift @@ -163,7 +163,6 @@ struct HomeView: View { private func openSession(_ sessionId: String) { if usesSplitNavigation { - listModel.onSessionOpened(sessionId) tabletNavigation.open(sessionId) } else if path.last != sessionId { path.append(sessionId) diff --git a/ios/Hapi/Features/Sessions/SessionFilterMenu.swift b/ios/Hapi/Features/Sessions/SessionFilterMenu.swift index 4cee893d..8b8b2303 100644 --- a/ios/Hapi/Features/Sessions/SessionFilterMenu.swift +++ b/ios/Hapi/Features/Sessions/SessionFilterMenu.swift @@ -46,7 +46,7 @@ struct SessionFilterMenu: View { Button("Cancel", role: .cancel) {} } message: { Text(String( - format: String(localized: "Marks %lld unread sessions as read on this device."), + format: String(localized: "Marks %lld unread sessions as read across your devices."), Int64(model.unreadCount) )) } diff --git a/ios/Hapi/Features/Sessions/SessionListModel.swift b/ios/Hapi/Features/Sessions/SessionListModel.swift index 92a73d08..741fea8e 100644 --- a/ios/Hapi/Features/Sessions/SessionListModel.swift +++ b/ios/Hapi/Features/Sessions/SessionListModel.swift @@ -87,7 +87,7 @@ struct MachineFilterUI: Identifiable, Equatable { /// Transient, per-home filters. Add future dimensions here, not to navigation. struct SessionListFilters: Equatable, Hashable { var machineId: String? - /// Web "unread only" lens: hide sessions already seen on this device. + /// Web "unread only" lens: hide sessions already read in this namespace. var unreadOnly = false var isActive: Bool { machineId != nil || unreadOnly } @@ -241,20 +241,23 @@ final class SessionListModel { filters.unreadOnly = enabled } - /// Unread sessions this device has not opened since their last update. + /// Content not yet read by a client in this namespace. var unreadCount: Int { - let lastSeen = lastSeenStore.state.lastSeen + let readState = lastSeenStore.state return sessionStore.sessions.filter { - LastSeenStore.isUnread($0, lastSeenAt: lastSeen[$0.id] ?? 0) + LastSeenStore.isUnread($0, state: readState) }.count } - /// Web `markAllSessionsSeen`: local watermarks only, nothing hub-side. + /// Capture the observed heads; concurrent output must remain unread. func markAllRead() { - let lastSeen = lastSeenStore.state.lastSeen - for summary in sessionStore.sessions - where LastSeenStore.isUnread(summary, lastSeenAt: lastSeen[summary.id] ?? 0) { - lastSeenStore.markSeen(sessionId: summary.id, seenAt: summary.updatedAt) + let observed = sessionStore.sessions + Task { + for summary in observed { + await lastSeenStore.markSeen(sessionId: summary.id, + revision: summary.contentRevision ?? 0, serverState: summary.readState) + } + if let error = lastSeenStore.persistenceError { actionError = error } } } @@ -277,7 +280,7 @@ final class SessionListModel { /// Machine filter → unread lens → text search (relevance order, pin /// sections kept so the Pinned header still applies). var rows: [SessionRowUI] { - let lastSeen = lastSeenStore.state.lastSeen + let readState = lastSeenStore.state let activeFilter = activeMachineFilter var visible = sessionStore.sessions.filter { summary in guard let activeFilter else { return true } @@ -286,7 +289,7 @@ final class SessionListModel { if filters.unreadOnly { visible = visible.filter { summary in summary.id == selectedSessionId - || LastSeenStore.isUnread(summary, lastSeenAt: lastSeen[summary.id] ?? 0) + || LastSeenStore.isUnread(summary, state: readState) } } if isSearching { @@ -303,7 +306,7 @@ final class SessionListModel { title: Self.sessionTitle(summary), project: Self.projectLabel(summary), flavor: summary.metadata?.flavor, - unread: LastSeenStore.isUnread(summary, lastSeenAt: lastSeen[summary.id] ?? 0) + unread: LastSeenStore.isUnread(summary, state: readState) ) } } @@ -334,9 +337,7 @@ final class SessionListModel { await Task { await self.refresh() }.value } - /// Pull-to-refresh / initial load. Coalesces concurrent calls; the first - /// successful list seeds the unread baseline so historical sessions do - /// not all light up as unread. + /// Pull-to-refresh / initial load. Coalesces concurrent calls. func refresh() async { guard !isRefreshing else { return } hasAttemptedRefresh = true @@ -345,26 +346,15 @@ final class SessionListModel { do { try await sessionStore.refresh() reconcileFilters() - try await machineStore.refresh() isOffline = false hasRefreshedOnce = true - lastSeenStore.initializeBaseline( - scopeKey: hubUrl, - sessions: sessionStore.sessions - ) + do { try await machineStore.refresh() } + catch { actionError = error.localizedDescription } } catch { isOffline = true } } - /// Call when navigating into a session: stamps the last-seen watermark. - func onSessionOpened(_ sessionId: String) { - guard let summary = sessionStore.sessions.first(where: { $0.id == sessionId }) else { - return - } - lastSeenStore.markSeen(sessionId: sessionId, seenAt: summary.updatedAt) - } - /// `PUT /sessions/:id/pin` with store-side optimistic re-sort; failures /// surface on `actionError`. func setPinMode(sessionId: String, mode: SessionPinMode) { diff --git a/ios/Hapi/Features/Sessions/SessionListView.swift b/ios/Hapi/Features/Sessions/SessionListView.swift index 3b04e6db..45f63189 100644 --- a/ios/Hapi/Features/Sessions/SessionListView.swift +++ b/ios/Hapi/Features/Sessions/SessionListView.swift @@ -167,7 +167,6 @@ struct SessionListView: View { .tag(row.id) } else { Button { - model.onSessionOpened(row.id) onOpenSession(row.id) } label: { rowLabel(row, now: now) diff --git a/ios/Hapi/Models/AppModel.swift b/ios/Hapi/Models/AppModel.swift index 748513ee..f808d98b 100644 --- a/ios/Hapi/Models/AppModel.swift +++ b/ios/Hapi/Models/AppModel.swift @@ -56,6 +56,8 @@ final class AppModel { private let performer: any HTTPPerforming private let pairingService: HubPairingService @ObservationIgnored private var isForeground = false + @ObservationIgnored private var activationTask: Task? + @ObservationIgnored private var activationToken = UUID() init( registry: HubRegistry = HubRegistry(), @@ -224,8 +226,27 @@ final class AppModel { /// Builds (and starts, when foregrounded) the session for `hub`, /// replacing any previous one. private func activate(hub: String) { + let previousStore = session?.lastSeenStore session?.shutdown() session = nil + let token = UUID() + activationToken = token + if previousStore?.isPersisting == true || activationTask != nil { + let previousActivation = activationTask + activationTask = Task { [weak self] in + await previousActivation?.value + await previousStore?.flushPersistence() + guard let self, self.activationToken == token else { return } + self.activationTask = nil + guard self.registry.activeHub == hub else { return } + self.buildSession(hub: hub) + } + return + } + buildSession(hub: hub) + } + + private func buildSession(hub: String) { guard let newSession = HubSession( hubUrl: hub, credentialStore: credentialStore, diff --git a/ios/Hapi/Models/HubSession.swift b/ios/Hapi/Models/HubSession.swift index 3380fa6a..46d6b935 100644 --- a/ios/Hapi/Models/HubSession.swift +++ b/ios/Hapi/Models/HubSession.swift @@ -66,6 +66,8 @@ final class HubSession { @ObservationIgnored private var consumeTask: Task? @ObservationIgnored private var reportedAuthFailure = false @ObservationIgnored private var isShutDown = false + @ObservationIgnored private var readStateTask: Task? + @ObservationIgnored private let readNamespace: String? /// The chat currently on screen (at most one in this UI), forwarded the /// scene-phase transitions so its session-scope SSE parks in background. @@ -100,7 +102,11 @@ final class HubSession { let machineStore = MachineStore(api: api, snapshotDirectory: snapshotDirectory) self.sessionStore = sessionStore self.machineStore = machineStore - self.lastSeenStore = LastSeenStore(snapshotDirectory: snapshotDirectory) + let storedJWT = (try? credentialStore.credentials(forHub: hubUrl))?.jwt + let readNamespace = storedJWT.flatMap { JWT.claims(from: $0)?.ns } + self.readNamespace = readNamespace + self.lastSeenStore = LastSeenStore(snapshotDirectory: snapshotDirectory, + namespace: readNamespace) self.windows = MessageWindowControllers( provider: api, snapshots: WindowSnapshotStore( @@ -196,6 +202,27 @@ final class HubSession { } activeChat?.enterForeground() visibility.setForeground(true) + lastSeenStore.setForeground(true) + readStateTask?.cancel() + readStateTask = Task { [weak self] in + guard let self else { return } + do { + let namespace: String + if let known = readNamespace { namespace = known } + else { + let token = try await authManager.validToken() + guard let resolved = JWT.claims(from: token)?.ns else { return } + namespace = resolved + } + guard !Task.isCancelled else { return } + let api = self.api + await lastSeenStore.configure(namespace: namespace) { id, state in + let token = try await api.authManager.validToken() + guard JWT.claims(from: token)?.ns == namespace else { throw AuthError.reauthenticationRequired } + return try await api.updateSessionReadState(id: id, state: state).readState + } + } catch { /* Offline: persisted reads remain queued for the next foreground. */ } + } } /// Parks the SSE retry loop; a live connection is left to the OS. Also @@ -207,6 +234,7 @@ final class HubSession { } activeChat?.enterBackground() visibility.setForeground(false) + lastSeenStore.setForeground(false) let sessionStore = sessionStore let machineStore = machineStore let lastSeenStore = lastSeenStore @@ -220,6 +248,8 @@ final class HubSession { /// Tears everything down. The session is unusable afterwards. func shutdown() { isShutDown = true + readStateTask?.cancel() + lastSeenStore.shutdown() onTerminalAuthFailure = nil // Hub switch can outrun the chat view's disappear; stop is idempotent. activeChat?.stop() diff --git a/ios/Hapi/Resources/Localizable.xcstrings b/ios/Hapi/Resources/Localizable.xcstrings index 67a6d50d..292e31ef 100644 --- a/ios/Hapi/Resources/Localizable.xcstrings +++ b/ios/Hapi/Resources/Localizable.xcstrings @@ -5806,19 +5806,19 @@ } } }, - "Marks %lld unread sessions as read on this device." : { + "Marks %lld unread sessions as read across your devices." : { "extractionState" : "manual", "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Непрочитанные сессии на этом устройстве будут отмечены как прочитанные: %lld." + "value" : "Непрочитанные сессии на всех ваших устройствах будут отмечены как прочитанные: %lld." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "这会将当前设备上的 %lld 个未读会话标记为已读。" + "value" : "这会将 %lld 个未读会话在各设备上标为已读。" } } } diff --git a/ios/HapiTests/SessionListFilterTests.swift b/ios/HapiTests/SessionListFilterTests.swift index d66168dd..b72f7b80 100644 --- a/ios/HapiTests/SessionListFilterTests.swift +++ b/ios/HapiTests/SessionListFilterTests.swift @@ -152,8 +152,8 @@ final class SessionListFilterTests: XCTestCase { XCTAssertTrue(model.hasLoaded) XCTAssertTrue(model.rows.allSatisfy { !$0.unread }) sessions.sessions[0].updatedAt += 1 - model.onSessionOpened("a") - XCTAssertEqual(seen.lastSeenAt("a"), sessions.sessions[0].updatedAt) + model.selectedSessionId = "a" + XCTAssertEqual(seen.lastSeenAt("a"), 0, "Navigation and metadata are not reading") model.setPinMode(sessionId: "b", mode: .global) try await Task.sleep(for: .milliseconds(20)) XCTAssertEqual(model.rows.first?.id, "b") @@ -167,14 +167,14 @@ final class SessionListFilterTests: XCTestCase { XCTAssertEqual(model.rows.count, 1) } - func testUnreadLensKeepsTheSelectedSessionAndMarkAllReadClearsIt() { + func testUnreadLensKeepsTheSelectedSessionAndMarkAllReadClearsIt() async throws { var stale = HomeFilterTestData.summary("seen", machine: "mac") - stale.updatedAt = 10 + stale.contentRevision = 10 var fresh = HomeFilterTestData.summary("fresh", machine: "mac") - fresh.updatedAt = 20 + fresh.contentRevision = 20 let sessions = HomeFilterTestSessions([fresh, stale]) let lastSeen = LastSeenStore() - lastSeen.markSeen(sessionId: "seen", seenAt: 10) + await lastSeen.markSeen(sessionId: "seen", revision: 10) let model = SessionListModel(sessionStore: sessions, machineStore: HomeFilterTestMachines(), lastSeenStore: lastSeen, hubUrl: "test") XCTAssertEqual(model.unreadCount, 1) @@ -186,6 +186,7 @@ final class SessionListFilterTests: XCTestCase { XCTAssertEqual(model.rows.map(\.id), ["fresh", "seen"]) model.markAllRead() + try await Task.sleep(for: .milliseconds(30)) XCTAssertEqual(model.unreadCount, 0) XCTAssertEqual(model.rows.map(\.id), ["seen"]) model.clearFilters() diff --git a/ios/HapiTests/SessionRowStatusTests.swift b/ios/HapiTests/SessionRowStatusTests.swift index f7ab5259..db68538d 100644 --- a/ios/HapiTests/SessionRowStatusTests.swift +++ b/ios/HapiTests/SessionRowStatusTests.swift @@ -42,9 +42,10 @@ final class SessionRowStatusTests: XCTestCase { XCTAssertNil(SessionRowStatus(summary: summary)) } - func testReadingDoesNotResolveAttentionAndResolutionKeepsTheProject() { + func testReadingDoesNotResolveAttentionAndResolutionKeepsTheProject() async throws { var summary = HomeFilterTestData.summary("a", machine: "mac") summary.metadata?.summary = .init(text: "Keep the session context visible") + summary.contentRevision = 1 summary.pendingRequestsCount = 2 summary.pendingRequestKinds = [.permission] let sessions = HomeFilterTestSessions([summary]) @@ -52,7 +53,8 @@ final class SessionRowStatusTests: XCTestCase { XCTAssertTrue(model.rows[0].unread) XCTAssertEqual(model.rows[0].status, .needsApproval(2)) - model.onSessionOpened("a") + model.markAllRead() + try await Task.sleep(for: .milliseconds(30)) XCTAssertFalse(model.rows[0].unread) XCTAssertEqual(model.rows[0].status, .needsApproval(2)) XCTAssertEqual(model.rows[0].project, "hapi") @@ -64,7 +66,7 @@ final class SessionRowStatusTests: XCTestCase { sessions.sessions[0].thinking = true XCTAssertEqual(model.rows[0].status, .running) sessions.sessions[0].updatedAt += 1 - XCTAssertTrue(model.rows[0].unread, "Existing updatedAt-based unread semantics stay intact") + XCTAssertFalse(model.rows[0].unread, "Metadata changes do not create unread output") } func testMissingMetadataDoesNotCreatePlaceholderContent() { diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift index e7a3f5de..10efc66a 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Endpoints/SessionEndpoints.swift @@ -11,6 +11,10 @@ public enum SessionsListOrder: String, Sendable { /// Session list, detail, and lifecycle endpoints /// (`docs/api/client-contract/rest.md`). extension APIClient { + public func updateSessionReadState(id: String, state: SessionReadStateRequest) async throws -> SessionReadStateResponse { + try await request(.put, "/api/sessions/\(encodePathComponent(id))/read-state", body: state) + } + /// `GET /api/sessions`. public func listSessions( limit: Int? = nil, diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Stores/LastSeenStore.swift b/ios/Packages/HapiKit/Sources/HapiClient/Stores/LastSeenStore.swift index 35aa47fa..b65351ea 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Stores/LastSeenStore.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Stores/LastSeenStore.swift @@ -2,82 +2,183 @@ import Foundation import HapiProtocol import Observation -/// Snapshot payload: watermarks + which scopes already got their baseline. -public struct LastSeenState: Codable, Equatable, Sendable { - public var lastSeen: [String: Int] - public var baselines: Set +public struct PendingRead: Codable, Equatable, Sendable { + public var id: UUID + public var request: SessionReadStateRequest +} - public init(lastSeen: [String: Int] = [:], baselines: Set = []) { - self.lastSeen = lastSeen - self.baselines = baselines +/// Durable read revisions and outbox, isolated by hub directory and namespace. +public struct LastSeenState: Codable, Equatable, Sendable { + public var lastSeen: [String: Int] = [:] + public var server: [String: SessionReadState] = [:] + public var pending: [String: PendingRead] = [:] + public init() {} +} + +private actor ReadStateWriter { + func write(_ data: Data, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) } } -/// Per-session last-seen watermarks — port of `web/src/lib/sessionLastSeen.ts` -/// (localStorage → per-hub JSON snapshot) plus the unread derivation from -/// `web/src/lib/sessionAttention.ts`, mirroring the Android port -/// (`LastSeenStore`). -/// -/// The watermark is the session `updatedAt` the operator last had on screen; -/// a session is **unread** when its current `updatedAt` moved past it -/// (``isUnread(_:lastSeenAt:)`` — the reference compares `updatedAt` only; -/// message `seq` never reaches the summary). -/// ``initializeBaseline(scopeKey:sessions:)`` seeds missing watermarks from -/// the first session list so a fresh install does not mark every historical -/// session unread — once per ``LastSeenState/baselines`` scope, exactly like -/// the web's per-scope baseline flag. @MainActor @Observable public final class LastSeenStore { public private(set) var state: LastSeenState + public private(set) var persistenceError: String? + @ObservationIgnored private let directory: URL? + @ObservationIgnored private var namespace: String? + @ObservationIgnored private let writer = ReadStateWriter() + @ObservationIgnored private var mutationTask: Task? + @ObservationIgnored private var mutationToken: UUID? + @ObservationIgnored private var syncTask: Task? + @ObservationIgnored private var syncToken: UUID? + @ObservationIgnored private var sender: (@Sendable (String, SessionReadStateRequest) async throws -> SessionReadState)? + @ObservationIgnored private var foreground = true + @ObservationIgnored private var closed = false + public var isPersisting: Bool { mutationTask != nil } - @ObservationIgnored private let snapshot: DiskCache? + public init(snapshotDirectory: URL? = nil, namespace: String? = "default") { + self.directory = snapshotDirectory + self.namespace = namespace + self.state = Self.load(directory: snapshotDirectory, namespace: namespace) + } - public init(snapshotDirectory: URL? = nil) { - let cache = snapshotDirectory.map { - DiskCache(directory: $0, filename: "last-seen.json") + private static func file(directory: URL?, namespace: String?) -> URL? { + guard let directory, let namespace else { return nil } + let name = namespace.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" + return directory.appendingPathComponent("read-state-\(name).json") + } + + private static func load(directory: URL?, namespace: String?) -> LastSeenState { + guard let url = file(directory: directory, namespace: namespace), + let data = try? Data(contentsOf: url), + let state = try? HapiJSON.decoder.decode(LastSeenState.self, from: data) else { return LastSeenState() } + return state + } + + public func configure(namespace: String, sender: @escaping @Sendable (String, SessionReadStateRequest) async throws -> SessionReadState) async { + guard !closed else { return } + await flushPersistence() + if self.namespace != namespace { + syncTask?.cancel() + syncTask = nil + syncToken = nil + self.namespace = namespace + state = Self.load(directory: directory, namespace: namespace) } - self.snapshot = cache - self.state = cache?.load() ?? LastSeenState() + self.sender = sender + sync() } - public func lastSeenAt(_ sessionId: String) -> Int { - state.lastSeen[sessionId] ?? 0 + public func setForeground(_ value: Bool) { + foreground = value + if value { sync() } } - /// Forces the debounced snapshot to disk (app background / tests). - public func flushPersistence() async { - await snapshot?.flush() - } + public func shutdown() { closed = true; foreground = false; syncTask?.cancel(); syncTask = nil; syncToken = nil } - /// `markSessionSeen`: monotonic max — a stale screen never rewinds the - /// watermark. - public func markSeen(sessionId: String, seenAt: Int) { - guard !sessionId.isEmpty else { return } - let current = state.lastSeen[sessionId] ?? 0 - let next = max(current, seenAt) - if next == current, state.lastSeen[sessionId] != nil { - return + public func lastSeenAt(_ sessionId: String) -> Int { state.lastSeen[sessionId] ?? 0 } + + /// Every successful mark is already persisted; this waits for in-flight writes. + public func flushPersistence() async { await mutationTask?.value } + + private func commit(namespace expectedNamespace: String? = nil, _ transform: @escaping @MainActor (LastSeenState) -> LastSeenState) async { + let previous = mutationTask + let identity = expectedNamespace ?? namespace + let token = UUID() + mutationToken = token + let task = Task { @MainActor in + await previous?.value + guard self.namespace == identity else { return } + let next = transform(self.state) + guard next != self.state else { return } + do { + if let url = Self.file(directory: self.directory, namespace: identity) { + let data = try HapiJSON.encoder.encode(next) + try await self.writer.write(data, to: url) + } + guard self.namespace == identity else { return } + self.state = next + self.persistenceError = nil + } catch { self.persistenceError = error.localizedDescription } } - state.lastSeen[sessionId] = next - snapshot?.scheduleWrite(state) + mutationTask = task + await task.value + if mutationToken == token { mutationTask = nil; mutationToken = nil } } - /// `initializeSessionLastSeen`: on the first list load for `scopeKey` - /// (e.g. the hub origin), seed every session without a watermark at its - /// current `updatedAt`, then never again for that scope. - public func initializeBaseline(scopeKey: String, sessions: [SessionSummary]) { - guard !state.baselines.contains(scopeKey) else { return } - var next = state - for session in sessions where next.lastSeen[session.id] == nil { - next.lastSeen[session.id] = session.updatedAt + public func markSeen(sessionId: String, revision: Int, serverState: SessionReadState? = nil) async { + guard !closed, namespace != nil, !sessionId.isEmpty, revision >= 0 else { return } + await commit { current in + let server = SessionReadState.merge(current.server[sessionId], serverState) ?? SessionReadState() + let seen = max(current.lastSeen[sessionId] ?? 0, server.readRevision) + let manual = server.manualUnread && current.pending[sessionId]?.request.expectedVersion != server.version + guard seen < revision || manual else { return current } + var next = current + next.lastSeen[sessionId] = max(seen, revision) + next.server[sessionId] = server + next.pending[sessionId] = PendingRead(id: UUID(), request: SessionReadStateRequest( + readRevision: max(seen, revision), expectedVersion: serverState?.version ?? server.version + )) + return next } - next.baselines.insert(scopeKey) - state = next - snapshot?.scheduleWrite(next) + sync() } - /// `sessionIsUnread`: activity newer than the operator's watermark. - public static func isUnread(_ summary: SessionSummary, lastSeenAt: Int) -> Bool { - summary.updatedAt > lastSeenAt + private func sync() { + guard !closed, foreground, syncTask == nil, !state.pending.isEmpty, let sender else { return } + let identity = namespace + let token = UUID() + syncToken = token + syncTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.syncToken == token { + self.syncTask = nil + self.syncToken = nil + if !Task.isCancelled { self.sync() } + } + } + while !Task.isCancelled && self.foreground && self.namespace == identity { + guard let (id, pending) = self.state.pending.first else { break } + do { + let response = try await sender(id, pending.request) + guard !Task.isCancelled, self.namespace == identity else { break } + await self.commit(namespace: identity) { current in + var next = current + next.server[id] = SessionReadState.merge(current.server[id], response) + if next.pending[id]?.id == pending.id { next.pending[id] = nil } + return next + } + if self.persistenceError != nil { try await Task.sleep(for: .seconds(5)) } + } catch is CancellationError { break + } catch let error as APIError where [400, 403, 404, 409].contains(error.status) { + guard !Task.isCancelled, self.namespace == identity else { break } + let authoritative = error.body?.data(using: .utf8).flatMap { + try? HapiJSON.decoder.decode(SessionReadStateResponse.self, from: $0).readState + } + await self.commit(namespace: identity) { current in + var next = current + next.server[id] = SessionReadState.merge(current.server[id], authoritative) + if next.pending[id]?.id == pending.id { + next.pending[id] = nil + next.lastSeen[id] = next.server[id]?.readRevision ?? 0 + } + return next + } + if self.persistenceError != nil { try? await Task.sleep(for: .seconds(5)) } + } catch { + do { try await Task.sleep(for: .seconds(5)) } catch { break } + } + } + } + } + + public static func isUnread(_ summary: SessionSummary, state: LastSeenState) -> Bool { + let server = SessionReadState.merge(state.server[summary.id], summary.readState) ?? SessionReadState() + let manual = server.manualUnread && state.pending[summary.id]?.request.expectedVersion != server.version + return manual || (!(summary.active && summary.thinking) + && (summary.contentRevision ?? 0) > max(server.readRevision, state.lastSeen[summary.id] ?? 0)) } } diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift b/ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift index fdcf5313..0f9837a7 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift @@ -133,7 +133,9 @@ public final class SessionListStore: SessionListStoring { /// `GET /api/sessions/:id` into the detail cache (chat open / resync). @discardableResult public func loadSessionDetail(_ sessionId: String) async throws -> Session { - let session = try await api.session(id: sessionId) + var session = try await api.session(id: sessionId) + session.contentRevision = [details[sessionId]?.contentRevision, session.contentRevision].compactMap { $0 }.max() + session.readState = SessionReadState.merge(details[sessionId]?.readState, session.readState) details[sessionId] = session return session } @@ -169,10 +171,12 @@ public final class SessionListStore: SessionListStoring { guard generation > lastAppliedRefresh else { return } lastAppliedRefresh = generation // A user-input SSE patch can overtake an in-flight REST snapshot. - let clocks = Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, $0.lastUserMessageAt) }) + let cachedRows = Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, $0) }) let reconciled = list.map { row in var next = row - if let cached = clocks[row.id] ?? nil, cached > (row.lastUserMessageAt ?? Int.min) { + next.contentRevision = [cachedRows[row.id]?.contentRevision, row.contentRevision].compactMap { $0 }.max() + next.readState = SessionReadState.merge(cachedRows[row.id]?.readState, row.readState) + if let cached = cachedRows[row.id]?.lastUserMessageAt, cached > (row.lastUserMessageAt ?? Int.min) { next.lastUserMessageAt = cached } return next @@ -341,8 +345,11 @@ public final class SessionListStore: SessionListStoring { // already encodes the session-vs-strict-patch discrimination. switch data { case .session(let full) where full.id == sessionId: - details[full.id] = full - upsertSummary(full) + var merged = full + merged.contentRevision = [details[full.id]?.contentRevision, full.contentRevision].compactMap { $0 }.max() + merged.readState = SessionReadState.merge(details[full.id]?.readState, full.readState) + details[full.id] = merged + upsertSummary(merged) case .patch(let patch) where patch != SessionPatch(): patchDetail(sessionId: sessionId, patch: patch) if !patchSummary(sessionId: sessionId, patch: patch) { @@ -377,6 +384,8 @@ public final class SessionListStore: SessionListStoring { var summary = SummaryPatching.toSessionSummary(session) summary.futureScheduledMessageCount = existing?.futureScheduledMessageCount ?? 0 summary.nextScheduledAt = existing?.nextScheduledAt + summary.contentRevision = [existing?.contentRevision, session.contentRevision].compactMap { $0 }.max() + summary.readState = SessionReadState.merge(existing?.readState, session.readState) summary.lastUserMessageAt = session.lastUserMessageAt.map { max(existing?.lastUserMessageAt ?? 0, $0) } ?? existing?.lastUserMessageAt if let index { list[index] = summary diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift index e062e0b3..7bda198c 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/DecryptedMessage.swift @@ -10,6 +10,7 @@ import Foundation /// `content` degrades to `.null` instead of throwing. public struct DecryptedMessage: Codable, Equatable, Sendable { /// Server uuid. Optimistic rows use the client `localId` until echoed. + public var contentRevision: Int? public var id: String /// Per-session insert counter; `null` on optimistic rows. public var seq: Int? @@ -32,6 +33,7 @@ public struct DecryptedMessage: Codable, Equatable, Sendable { public var deliveryState: String? public init( + contentRevision: Int? = nil, id: String, seq: Int? = nil, localId: String? = nil, @@ -41,6 +43,7 @@ public struct DecryptedMessage: Codable, Equatable, Sendable { scheduledAt: Int? = nil, deliveryState: String? = nil ) { + self.contentRevision = contentRevision self.id = id self.seq = seq self.localId = localId @@ -53,6 +56,7 @@ public struct DecryptedMessage: Codable, Equatable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + contentRevision = try container.decodeIfPresent(Int.self, forKey: .contentRevision) id = try container.decode(String.self, forKey: .id) seq = try container.decodeIfPresent(Int.self, forKey: .seq) localId = try container.decodeIfPresent(String.self, forKey: .localId) diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift index cb582a9a..570339d3 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/MessagesResponse.swift @@ -12,6 +12,7 @@ public struct MessagesPage: Codable, Equatable, Sendable { case after } + public var readThroughRevision: Int? public var direction: Direction public var limit: Int /// Server's current epoch for this session; a mismatch on your next @@ -30,6 +31,7 @@ public struct MessagesPage: Codable, Equatable, Sendable { public var hasMore: Bool public init( + readThroughRevision: Int? = nil, direction: Direction, limit: Int, epoch: Int, @@ -42,6 +44,7 @@ public struct MessagesPage: Codable, Equatable, Sendable { snapshotHeadAt: Int? = nil, hasMore: Bool ) { + self.readThroughRevision = readThroughRevision self.direction = direction self.limit = limit self.epoch = epoch diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift index e784cd90..f342b55d 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Session.swift @@ -8,6 +8,8 @@ import Foundation /// `teamStateUpdatedAt` are the watermarks the versioned-patch gate in /// `applySessionDetailPatch(session:patch:)` checks against. public struct Session: Codable, Equatable, Sendable { + public var contentRevision: Int? + public var readState: SessionReadState? public var id: String public var namespace: String public var seq: Int @@ -44,6 +46,8 @@ public struct Session: Codable, Equatable, Sendable { public var copilotAgentMode: CopilotAgentMode? public init( + contentRevision: Int? = nil, + readState: SessionReadState? = nil, id: String, namespace: String, seq: Int, @@ -82,6 +86,8 @@ public struct Session: Codable, Equatable, Sendable { self.lastUserMessageAt = lastUserMessageAt self.pinned = pinned self.globalPinned = globalPinned + self.contentRevision = contentRevision + self.readState = readState self.active = active self.activeAt = activeAt self.metadata = metadata @@ -107,6 +113,8 @@ public struct Session: Codable, Equatable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + contentRevision = try container.decodeIfPresent(Int.self, forKey: .contentRevision) + readState = try container.decodeIfPresent(SessionReadState.self, forKey: .readState) id = try container.decode(String.self, forKey: .id) namespace = try container.decode(String.self, forKey: .namespace) seq = try container.decode(Int.self, forKey: .seq) diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift index b9e5697e..70bf13a3 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift @@ -46,6 +46,8 @@ public enum PatchField: Equatable, Sendab /// back to the strict patch, so a malformed full session must not silently /// half-decode as a lenient patch. public struct SessionPatch: Equatable, Sendable { + public var contentRevision: Int? + public var readState: SessionReadState? public var active: Bool? public var thinking: Bool? /// Carried on the wire but NOT applied by `applySessionDetailPatch` @@ -71,6 +73,8 @@ public struct SessionPatch: Equatable, Sendable { public var scratchlistUpdatedAt: Int? public init( + contentRevision: Int? = nil, + readState: SessionReadState? = nil, active: Bool? = nil, thinking: Bool? = nil, activeTurnStartedAt: PatchField? = nil, @@ -91,6 +95,8 @@ public struct SessionPatch: Equatable, Sendable { backgroundTaskCount: Int? = nil, scratchlistUpdatedAt: Int? = nil ) { + self.contentRevision = contentRevision + self.readState = readState self.active = active self.thinking = thinking self.activeTurnStartedAt = activeTurnStartedAt @@ -115,6 +121,7 @@ public struct SessionPatch: Equatable, Sendable { extension SessionPatch: Codable { private enum CodingKeys: String, CodingKey, CaseIterable { + case contentRevision, readState case active case thinking case activeTurnStartedAt @@ -146,6 +153,8 @@ extension SessionPatch: Codable { payloadName: "SessionPatch" ) let container = try decoder.container(keyedBy: CodingKeys.self) + contentRevision = try container.decodeIfPresent(Int.self, forKey: .contentRevision) + readState = try container.decodeIfPresent(SessionReadState.self, forKey: .readState) active = try container.decodeIfPresent(Bool.self, forKey: .active) thinking = try container.decodeIfPresent(Bool.self, forKey: .thinking) activeTurnStartedAt = try Self.decodePatchField(Int.self, in: container, forKey: .activeTurnStartedAt) @@ -169,6 +178,8 @@ extension SessionPatch: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(contentRevision, forKey: .contentRevision) + try container.encodeIfPresent(readState, forKey: .readState) try container.encodeIfPresent(active, forKey: .active) try container.encodeIfPresent(thinking, forKey: .thinking) try Self.encodePatchField(activeTurnStartedAt, in: &container, forKey: .activeTurnStartedAt) diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionReadState.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionReadState.swift new file mode 100644 index 00000000..e9db42a1 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionReadState.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct SessionReadState: Codable, Equatable, Sendable { + public var readRevision: Int + public var manualUnread: Bool + public var version: Int + + public init(readRevision: Int = 0, manualUnread: Bool = false, version: Int = 0) { + self.readRevision = readRevision + self.manualUnread = manualUnread + self.version = version + } + + public static func merge(_ current: Self?, _ incoming: Self?) -> Self? { + guard let incoming else { return current } + guard let current else { return incoming } + var next = incoming.version > current.version ? incoming : current + next.readRevision = max(current.readRevision, incoming.readRevision) + return next + } +} + +public struct SessionReadStateRequest: Codable, Equatable, Sendable { + public var type: String + public var readRevision: Int? + public var expectedVersion: Int + + public init(type: String = "read", readRevision: Int? = nil, expectedVersion: Int) { + self.type = type + self.readRevision = readRevision + self.expectedVersion = expectedVersion + } +} + +public struct SessionReadStateResponse: Codable, Equatable, Sendable { + public var readState: SessionReadState + public var contentRevision: Int +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift index e09e3f31..3f070b7d 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSummary.swift @@ -89,6 +89,8 @@ public struct SessionSummaryMetadata: Codable, Equatable, Sendable { /// hub-derived: badges must use `pendingRequestsCount` (authoritative total), /// never `pendingRequests.count` (capped at 5, oldest first). public struct SessionSummary: Codable, Equatable, Sendable { + public var contentRevision: Int? + public var readState: SessionReadState? /// Server-side cap on `pendingRequests` entries /// (`PENDING_REQUEST_SUMMARY_CAP`, `shared/src/sessionSummary.ts`). public static let pendingRequestSummaryCap = 5 @@ -121,6 +123,8 @@ public struct SessionSummary: Codable, Equatable, Sendable { public var effort: String? public init( + contentRevision: Int? = nil, + readState: SessionReadState? = nil, id: String, active: Bool, thinking: Bool, @@ -146,6 +150,8 @@ public struct SessionSummary: Codable, Equatable, Sendable { effort: String? = nil ) { self.id = id + self.contentRevision = contentRevision + self.readState = readState self.active = active self.thinking = thinking self.activeAt = activeAt diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SummaryPatching.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SummaryPatching.swift index 5709b078..b42d82e7 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SummaryPatching.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SummaryPatching.swift @@ -168,6 +168,8 @@ public enum SummaryPatching { /// (`upsertSessionSummary` in the web reference does exactly that). public static func toSessionSummary(_ session: Session) -> SessionSummary { SessionSummary( + contentRevision: session.contentRevision, + readState: session.readState, id: session.id, active: session.active, thinking: session.thinking, @@ -222,6 +224,8 @@ public enum SummaryPatching { _ patch: SessionPatch ) -> SessionSummary { var next = current + if let revision = patch.contentRevision { next.contentRevision = max(current.contentRevision ?? 0, revision) } + next.readState = SessionReadState.merge(current.readState, patch.readState) next.active = patch.active ?? current.active next.thinking = patch.thinking ?? current.thinking next.activeAt = patch.activeAt ?? current.activeAt @@ -292,6 +296,7 @@ public enum SummaryPatching { /// `undefined !== null` distinction for `modelReasoningEffort`; the only /// effect is suppressing a re-render that stores an equal-rendering value. public static func isRenderIrrelevantPatch(current: SessionSummary, next: SessionSummary) -> Bool { + if current.contentRevision != next.contentRevision || current.readState != next.readState { return false } if current.active != next.active { return false } if current.thinking != next.thinking { return false } if current.updatedAt != next.updatedAt { return false } diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift index 51abef87..894ef945 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Patch/SessionPatching.swift @@ -45,6 +45,8 @@ public func isRenderIrrelevantSessionPatch(session: Session, patch: SessionPatch return false } } + if let value = patch.contentRevision, value != session.contentRevision { return false } + if let value = patch.readState, value != session.readState { return false } if let value = patch.updatedAt, value != session.updatedAt { return false } if let value = patch.lastUserMessageAt, value != session.lastUserMessageAt { return false } // Versioned wrappers never equal the session's plain fields in the TS @@ -92,6 +94,8 @@ public func applySessionDetailPatch(session: Session, patch: SessionPatch) -> Se if let value = patch.activeAt { assign(\.activeAt, value) } // Monotonic with the hub's applySessionPatch: a rejected stale // metadata/agentState replay must not rewind updatedAt. + if let value = patch.contentRevision { assign(\.contentRevision, max(next.contentRevision ?? 0, value)) } + if let value = patch.readState { assign(\.readState, SessionReadState.merge(next.readState, value)) } if let value = patch.updatedAt { assign(\.updatedAt, max(next.updatedAt, value)) } if let value = patch.lastUserMessageAt { assign(\.lastUserMessageAt, max(next.lastUserMessageAt ?? 0, value)) } if let field = patch.model { assign(\.model, field.wireValue) } diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift index 013cf7d4..90a7e0a8 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift @@ -322,6 +322,7 @@ public enum MessageWindowLogic { oldest = responseOldest ?? previous.oldestPosition } var next = settingMessages(previous, trim.kept) + next.readThroughRevision = max(previous.readThroughRevision ?? 0, page.readThroughRevision ?? 0, trim.kept.compactMap(\.contentRevision).max() ?? 0) next.hasMore = page.hasMore || (!replaceServerRows && previous.hasMore) || !trim.dropped.isEmpty next.epoch = page.epoch next.oldestPosition = oldest @@ -379,6 +380,7 @@ public enum MessageWindowLogic { return merged } merged.epoch = page.epoch + merged.readThroughRevision = max(merged.readThroughRevision ?? 0, page.readThroughRevision ?? 0, merged.messages.compactMap(\.contentRevision).max() ?? 0) merged.newestPosition = maxPosition(nextAfter, merged.newestPosition) merged.warning = nil return merged @@ -581,6 +583,9 @@ public enum MessageWindowLogic { if merged.epoch == nil || merged.requiresLatestReset { return merged } + if let revision = merged.readThroughRevision { + merged.readThroughRevision = max(revision, incoming.compactMap(\.contentRevision).max() ?? 0) + } let incomingNewest = derivePosition(incoming, .newest) let currentNewest = merged.newestPosition if let incomingNewest, currentNewest == nil || incomingNewest > currentNewest! { @@ -802,7 +807,11 @@ public enum MessageWindowLogic { source: MessageWindowState, target: MessageWindowState ) -> MessageWindowState { - var next = settingMessages(createState(sessionId: target.sessionId), source.messages) + var next = settingMessages(createState(sessionId: target.sessionId), source.messages.map { message in + WindowMessage(id: message.id, seq: message.seq, localId: message.localId, content: message.content, + createdAt: message.createdAt, invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt, status: message.status) + }) next.hasMore = source.hasMore next.tailRevision = source.tailRevision next.oldestPosition = source.oldestPosition diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowState.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowState.swift index b3148d8b..62af9140 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowState.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowState.swift @@ -73,6 +73,7 @@ public enum OlderLoadOutcome: Sendable { /// type) — deliberately mirroring the web's instance-based change detection, /// not deep equality. public struct MessageWindowState: Equatable, Sendable { + public var readThroughRevision: Int? public let sessionId: String /// Window rows in position order (queued rows re-merged after each trim). public var messages: [WindowMessage] = [] diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.swift index ebfd93b6..7a23cc18 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.swift @@ -50,6 +50,7 @@ public final class WindowMessage: Sendable, Equatable { // MARK: Wire fields (`DecryptedMessageSchema`, with tri-state invokedAt) /// Server uuid. Optimistic rows use the client `localId` until echoed. + public let contentRevision: Int? public let id: String /// Per-session insert counter; `nil` on optimistic rows. public let seq: Int? @@ -69,6 +70,7 @@ public final class WindowMessage: Sendable, Equatable { public let status: MessageStatus? public init( + contentRevision: Int? = nil, id: String, seq: Int? = nil, localId: String? = nil, @@ -78,6 +80,7 @@ public final class WindowMessage: Sendable, Equatable { scheduledAt: Int? = nil, status: MessageStatus? = nil ) { + self.contentRevision = contentRevision self.id = id self.seq = seq self.localId = localId @@ -95,6 +98,7 @@ public final class WindowMessage: Sendable, Equatable { /// tri-state and this initializer picks it up). public convenience init(wire: DecryptedMessage, status: MessageStatus? = nil) { self.init( + contentRevision: wire.contentRevision, id: wire.id, seq: wire.seq, localId: wire.localId, @@ -146,6 +150,7 @@ public final class WindowMessage: Sendable, Equatable { /// Copy with `invokedAt` stamped to an explicit number. public func withInvokedAt(_ invokedAt: Int) -> WindowMessage { WindowMessage( + contentRevision: contentRevision, id: id, seq: seq, localId: localId, content: content, createdAt: createdAt, invokedAt: .number(invokedAt), scheduledAt: scheduledAt, status: status @@ -159,6 +164,7 @@ public final class WindowMessage: Sendable, Equatable { /// Copy with the client-side status replaced. public func withStatus(_ status: MessageStatus) -> WindowMessage { WindowMessage( + contentRevision: contentRevision, id: id, seq: seq, localId: localId, content: content, createdAt: createdAt, invokedAt: invokedAt, scheduledAt: scheduledAt, status: status @@ -169,6 +175,7 @@ public final class WindowMessage: Sendable, Equatable { /// `normalizeDecryptedMessage`). public var asDecryptedMessage: DecryptedMessage { DecryptedMessage( + contentRevision: contentRevision, id: id, seq: seq, localId: localId, @@ -189,6 +196,7 @@ public final class WindowMessage: Sendable, Equatable { /// when explicit null. extension WindowMessage: Codable { private enum CodingKeys: String, CodingKey { + case contentRevision case id, seq, localId, content, createdAt, invokedAt, scheduledAt, status } @@ -205,6 +213,7 @@ extension WindowMessage: Codable { invokedAt = .absent } self.init( + contentRevision: try container.decodeIfPresent(Int.self, forKey: .contentRevision), id: try container.decode(String.self, forKey: .id), seq: try container.decodeIfPresent(Int.self, forKey: .seq), localId: try container.decodeIfPresent(String.self, forKey: .localId), @@ -221,6 +230,7 @@ extension WindowMessage: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(contentRevision, forKey: .contentRevision) try container.encode(id, forKey: .id) // Optional-encoding writes explicit `null`s for seq/localId, matching // the wire rows the hub emits. diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift index dea5f1dc..bf2a40b3 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift @@ -22,6 +22,7 @@ public struct AnchoredTranscriptList: UIViewCont public var isInspectionPresented: Bool public var onViewport: (TranscriptViewport) -> Void public var onLayout: (Int, Bool) -> Void + public var onTailDisplayed: () -> Void public var spacingBefore: (Item?, Item) -> CGFloat public var content: (Item) -> AnyView fileprivate var environment = EnvironmentValues() @@ -30,6 +31,7 @@ public struct AnchoredTranscriptList: UIViewCont isInspectionPresented: Bool = false, onViewport: @escaping (TranscriptViewport) -> Void, onLayout: @escaping (Int, Bool) -> Void, + onTailDisplayed: @escaping () -> Void = {}, spacingBefore: @escaping (Item?, Item) -> CGFloat = { _, _ in 12 }, content: @escaping (Item) -> AnyView) { self.items = items @@ -39,6 +41,7 @@ public struct AnchoredTranscriptList: UIViewCont self.isInspectionPresented = isInspectionPresented self.onViewport = onViewport self.onLayout = onLayout + self.onTailDisplayed = onTailDisplayed self.spacingBefore = spacingBefore self.content = content } @@ -483,6 +486,10 @@ public final class TranscriptCollectionController JSONValue { .object([ "messages": .array(state.messages.map(projectMessage)), + "readThroughRevision": state.readThroughRevision.map { JSONValue.number(Double($0)) } ?? .null, "hasMore": .bool(state.hasMore), "epoch": state.epoch.map { JSONValue.number(Double($0)) } ?? .null, "viewMode": .string(state.viewMode.rawValue), @@ -328,6 +329,7 @@ struct PaginationFixtureTests { "optimistic": .bool(message.isOptimistic), ] // Wire tri-state: the key appears only when the wire carried it. + if let revision = message.contentRevision { projected["contentRevision"] = .number(Double(revision)) } switch message.invokedAt { case .absent: break diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/LastSeenStoreTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/LastSeenStoreTests.swift index aa561145..e77041d6 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/LastSeenStoreTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/LastSeenStoreTests.swift @@ -3,63 +3,86 @@ import HapiClient import HapiProtocol import Testing -/// Transcribes the Android reference suite (`LastSeenStoreTest.kt`): -/// monotonic watermarks, the unread derivation, per-scope baseline seeding, -/// and the snapshot round-trip. @MainActor @Suite("LastSeenStore") struct LastSeenStoreTests { - - @Test func markSeenIsMonotonicMax() { - let store = LastSeenStore() - store.markSeen(sessionId: "s1", seenAt: 1_000) - #expect(store.lastSeenAt("s1") == 1_000) - store.markSeen(sessionId: "s1", seenAt: 500) // stale screen must not rewind - #expect(store.lastSeenAt("s1") == 1_000) - store.markSeen(sessionId: "s1", seenAt: 2_000) - #expect(store.lastSeenAt("s1") == 2_000) - #expect(store.lastSeenAt("unknown") == 0) - } - - @Test func unreadComparesUpdatedAtAgainstTheWatermark() { - let row = storeSummary("s1", updatedAt: 1_000) - #expect(LastSeenStore.isUnread(row, lastSeenAt: 0)) - #expect(LastSeenStore.isUnread(row, lastSeenAt: 999)) - #expect(!LastSeenStore.isUnread(row, lastSeenAt: 1_000)) - #expect(!LastSeenStore.isUnread(row, lastSeenAt: 2_000)) - } - - @Test func baselineSeedsMissingWatermarksOnlyOncePerScope() { - let store = LastSeenStore() - store.markSeen(sessionId: "seen", seenAt: 50) - store.initializeBaseline( - scopeKey: "hub-a", - sessions: [storeSummary("seen", updatedAt: 900), storeSummary("fresh", updatedAt: 700)] - ) - // Existing watermarks are never overwritten; missing ones seed at - // updatedAt. - #expect(store.lastSeenAt("seen") == 50) - #expect(store.lastSeenAt("fresh") == 700) - #expect(!LastSeenStore.isUnread(storeSummary("fresh", updatedAt: 700), lastSeenAt: store.lastSeenAt("fresh"))) - - // Second call for the same scope is a no-op — later sessions stay - // unread. - store.initializeBaseline(scopeKey: "hub-a", sessions: [storeSummary("later", updatedAt: 999)]) - #expect(store.lastSeenAt("later") == 0) - #expect(LastSeenStore.isUnread(storeSummary("later", updatedAt: 999), lastSeenAt: store.lastSeenAt("later"))) - } - - @Test func stateRoundTripsThroughTheSnapshot() async throws { + @Test func markSeenPersistsRevisionAndOutboxBeforeReturning() async { let directory = makeTempDirectory() - let store = LastSeenStore(snapshotDirectory: directory) - store.markSeen(sessionId: "s1", seenAt: 1_234) - store.initializeBaseline(scopeKey: "hub-a", sessions: []) - await store.flushPersistence() + let store = LastSeenStore(snapshotDirectory: directory, namespace: "alice") + await store.markSeen(sessionId: "s1", revision: 7) + await store.markSeen(sessionId: "s1", revision: 3) + let cold = LastSeenStore(snapshotDirectory: directory, namespace: "alice") + #expect(cold.lastSeenAt("s1") == 7) + #expect(cold.state.pending["s1"]?.request.readRevision == 7) + #expect(LastSeenStore(snapshotDirectory: directory, namespace: "bob").lastSeenAt("s1") == 0) + #expect(LastSeenStore(snapshotDirectory: makeTempDirectory(), namespace: "alice").lastSeenAt("s1") == 0) + } + @Test func unreadIgnoresMetadataAndKeepsRunningOutputPending() async { + let store = LastSeenStore() + var row = storeSummary("s1", updatedAt: 999_999) + #expect(!LastSeenStore.isUnread(row, state: store.state)) + row.contentRevision = 2 + #expect(LastSeenStore.isUnread(row, state: store.state)) + row.active = true + row.thinking = true + #expect(!LastSeenStore.isUnread(row, state: store.state)) + row.active = false + #expect(LastSeenStore.isUnread(row, state: store.state)) + await store.markSeen(sessionId: "s1", revision: 1) + #expect(LastSeenStore.isUnread(row, state: store.state)) + row.readState = SessionReadState(readRevision: 2, version: 1) + #expect(!LastSeenStore.isUnread(row, state: store.state)) + row.readState = SessionReadState(readRevision: 2, manualUnread: true, version: 2) + #expect(LastSeenStore.isUnread(row, state: store.state)) + } + + @Test func staleReadCannotClearANewerManualChoice() async { + let store = LastSeenStore() + var row = storeSummary("s1", updatedAt: 1) + row.contentRevision = 4 + let observed = SessionReadState(readRevision: 0, version: 1) + await store.markSeen(sessionId: "s1", revision: 4, serverState: observed) + row.readState = SessionReadState(readRevision: 4, manualUnread: true, version: 2) + #expect(LastSeenStore.isUnread(row, state: store.state)) + await store.markSeen(sessionId: "s1", revision: 4, serverState: row.readState) + #expect(!LastSeenStore.isUnread(row, state: store.state)) + } + + @Test func reconnectSendsPersistedObservedRevision() async { + let directory = makeTempDirectory() + let offline = LastSeenStore(snapshotDirectory: directory) + await offline.markSeen(sessionId: "s1", revision: 3, serverState: SessionReadState(version: 2)) let cold = LastSeenStore(snapshotDirectory: directory) - #expect(cold.lastSeenAt("s1") == 1_234) - // Baseline flag persists too — no re-seeding on cold start. - cold.initializeBaseline(scopeKey: "hub-a", sessions: [storeSummary("s2", updatedAt: 700)]) - #expect(cold.lastSeenAt("s2") == 0) + let received = ReadStateRequests() + await cold.configure(namespace: "default") { id, request in + await received.append(id, request) + return SessionReadState(readRevision: request.readRevision ?? 0, version: 3) + } + for _ in 0..<200 where !cold.state.pending.isEmpty { + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(cold.state.pending.isEmpty) + #expect(await received.requests == [SessionReadStateRequest(readRevision: 3, expectedVersion: 2)]) + let reloaded = LastSeenStore(snapshotDirectory: directory) + #expect(reloaded.state.pending.isEmpty) + #expect(reloaded.state.server["s1"]?.readRevision == 3) + cold.shutdown() + } + + @Test func persistenceFailureDoesNotClaimRead() async throws { + let file = makeTempDirectory().appendingPathComponent("file") + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: file) + let store = LastSeenStore(snapshotDirectory: file) + await store.markSeen(sessionId: "s1", revision: 3) + #expect(store.lastSeenAt("s1") == 0) + #expect(store.state.pending.isEmpty) + #expect(store.persistenceError != nil) } } + +private actor ReadStateRequests { + var requests: [SessionReadStateRequest] = [] + func append(_ id: String, _ request: SessionReadStateRequest) { requests.append(request) } +} diff --git a/ios/README.md b/ios/README.md index a6ef41fe..71464b1d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -606,7 +606,7 @@ TEST_RUNNER_HAPI_TOOL_CAPTURE=/tmp/hapi-tool-review \ Home keeps a fixed Sessions title: hub switching on the leading edge, Filters and New Session on the trailing edge. The native menu offers an **Unread Only** lens, machine single-selection (two or more machines) and -**Mark All as Read** (local watermarks, confirmed first); only applied filters +**Mark All as Read** (shared read state, confirmed first); only applied filters add a summary line. The unread lens keeps the iPad's selected chat listed. The search field (`.searchable`) ranks matches with the web relevance rules (`SessionListSearch`: title, worktree, summary, agent, machine, path and id; @@ -637,3 +637,7 @@ TEST_RUNNER_HAPI_HOME_CAPTURE=/tmp/hapi-home-review \ with `CODE_SIGNING_ALLOWED=NO`. - CI uses the runner's default Xcode; each job prints `xcodebuild -version` first so failures are attributable to a toolchain bump. + +Unread state uses the hub’s [revision read-state contract](../docs/api/client-contract/read-state.md). +Only a foreground transcript laid out at the latest tail acknowledges content. +Local pending reads persist before the dot clears and retry when connectivity returns. diff --git a/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json b/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json index fcc105ed..d51b22a2 100644 --- a/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json +++ b/shared/fixtures/pagination/cancel-too-late-ingests-invoked-row.json @@ -23,6 +23,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/fetch-older-before-cursor.json b/shared/fixtures/pagination/fetch-older-before-cursor.json index 45e78fd6..20e786f9 100644 --- a/shared/fixtures/pagination/fetch-older-before-cursor.json +++ b/shared/fixtures/pagination/fetch-older-before-cursor.json @@ -31,6 +31,7 @@ "at": 9000, "seq": 9 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/latest-page-then-sse-ingest.json b/shared/fixtures/pagination/latest-page-then-sse-ingest.json index 17f4f2a7..bc173aab 100644 --- a/shared/fixtures/pagination/latest-page-then-sse-ingest.json +++ b/shared/fixtures/pagination/latest-page-then-sse-ingest.json @@ -40,6 +40,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/message-cancelled-removes-queued-row.json b/shared/fixtures/pagination/message-cancelled-removes-queued-row.json index 5f0de523..f497e1f6 100644 --- a/shared/fixtures/pagination/message-cancelled-removes-queued-row.json +++ b/shared/fixtures/pagination/message-cancelled-removes-queued-row.json @@ -6,6 +6,7 @@ "messages": [], "newestCursor": null, "olderCursor": null, + "readThroughRevision": null, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json b/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json index b3554e56..68773bf1 100644 --- a/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json +++ b/shared/fixtures/pagination/messages-consumed-stamps-invoked-at.json @@ -32,6 +32,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json b/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json index b8978f8e..0493a8c6 100644 --- a/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json +++ b/shared/fixtures/pagination/older-page-epoch-mismatch-resets.json @@ -22,6 +22,7 @@ "at": 20000, "seq": 20 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json b/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json index ba0fee09..ba57ec07 100644 --- a/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json +++ b/shared/fixtures/pagination/optimistic-echo-reconciles-by-localid.json @@ -17,6 +17,7 @@ ], "newestCursor": null, "olderCursor": null, + "readThroughRevision": null, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json b/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json index c5666797..209c2838 100644 --- a/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json +++ b/shared/fixtures/pagination/queued-state-reconciliation-drops-deleted.json @@ -32,6 +32,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/read-receipt-history-does-not-advance.json b/shared/fixtures/pagination/read-receipt-history-does-not-advance.json new file mode 100644 index 00000000..3952dfea --- /dev/null +++ b/shared/fixtures/pagination/read-receipt-history-does-not-advance.json @@ -0,0 +1,145 @@ +{ + "description": "Browsing history may reveal a delayed message with a higher content revision; an older-page load never certifies the current tail.", + "expectedState": { + "epoch": 0, + "hasMore": false, + "messages": [ + { + "contentRevision": 6, + "createdAt": 5000, + "id": "delayed", + "invokedAt": 5000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 11 + }, + { + "contentRevision": 5, + "createdAt": 10000, + "id": "answer", + "invokedAt": 10000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 10 + } + ], + "newestCursor": { + "at": 10000, + "seq": 10 + }, + "olderCursor": { + "at": 5000, + "seq": 11 + }, + "readThroughRevision": 5, + "viewMode": "history" + }, + "fixtureVersion": 1, + "name": "read-receipt-history-does-not-advance", + "ops": [ + { + "expectedRequests": [ + { + "limit": 20 + } + ], + "op": "sync-tail", + "responses": [ + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "answer", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "contentRevision": 5, + "createdAt": 10000, + "id": "answer", + "invokedAt": 10000, + "localId": null, + "seq": 10 + } + ], + "page": { + "direction": "latest", + "epoch": 0, + "hasMore": true, + "limit": 200, + "nextAfterAt": null, + "nextAfterSeq": null, + "nextBeforeAt": 10000, + "nextBeforeSeq": 10, + "readThroughRevision": 5, + "reset": false, + "snapshotHeadAt": 10000, + "snapshotHeadSeq": 10 + } + } + ] + }, + { + "mode": "history", + "op": "set-view-mode" + }, + { + "expectedOutcome": { + "addedRenderableCount": 1, + "hasMore": false, + "kind": "applied" + }, + "expectedRequests": [ + { + "beforeAt": 10000, + "beforeSeq": 10, + "limit": 200 + } + ], + "op": "fetch-older", + "responses": [ + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "delayed", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "contentRevision": 6, + "createdAt": 5000, + "id": "delayed", + "invokedAt": 5000, + "localId": null, + "seq": 11 + } + ], + "page": { + "direction": "before", + "epoch": 0, + "hasMore": false, + "limit": 200, + "nextAfterAt": null, + "nextAfterSeq": null, + "nextBeforeAt": 5000, + "nextBeforeSeq": 11, + "reset": false, + "snapshotHeadAt": null, + "snapshotHeadSeq": null + } + } + ] + } + ] +} diff --git a/shared/fixtures/pagination/read-receipt-latest-and-live-output.json b/shared/fixtures/pagination/read-receipt-latest-and-live-output.json new file mode 100644 index 00000000..5d94da9e --- /dev/null +++ b/shared/fixtures/pagination/read-receipt-latest-and-live-output.json @@ -0,0 +1,109 @@ +{ + "description": "A successful latest page establishes the read receipt; live readable rows advance it independently of position sequence.", + "expectedState": { + "epoch": 0, + "hasMore": false, + "messages": [ + { + "contentRevision": 5, + "createdAt": 10000, + "id": "answer", + "invokedAt": 10000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 10 + }, + { + "contentRevision": 6, + "createdAt": 12000, + "id": "next", + "invokedAt": 12000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 12 + } + ], + "newestCursor": { + "at": 12000, + "seq": 12 + }, + "olderCursor": null, + "readThroughRevision": 6, + "viewMode": "tail" + }, + "fixtureVersion": 1, + "name": "read-receipt-latest-and-live-output", + "ops": [ + { + "expectedRequests": [ + { + "limit": 20 + } + ], + "op": "sync-tail", + "responses": [ + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "answer", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "contentRevision": 5, + "createdAt": 10000, + "id": "answer", + "invokedAt": 10000, + "localId": null, + "seq": 10 + } + ], + "page": { + "direction": "latest", + "epoch": 0, + "hasMore": false, + "limit": 200, + "nextAfterAt": null, + "nextAfterSeq": null, + "nextBeforeAt": null, + "nextBeforeSeq": null, + "readThroughRevision": 5, + "reset": false, + "snapshotHeadAt": 10000, + "snapshotHeadSeq": 10 + } + } + ] + }, + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "next", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "contentRevision": 6, + "createdAt": 12000, + "id": "next", + "invokedAt": 12000, + "localId": null, + "seq": 12 + } + ], + "op": "sse-messages" + } + ] +} diff --git a/shared/fixtures/pagination/read-receipt-requires-initial-tail-sync.json b/shared/fixtures/pagination/read-receipt-requires-initial-tail-sync.json new file mode 100644 index 00000000..9833604e --- /dev/null +++ b/shared/fixtures/pagination/read-receipt-requires-initial-tail-sync.json @@ -0,0 +1,50 @@ +{ + "description": "SSE arriving before initial tail synchronization cannot establish a read receipt over an unloaded conversation.", + "expectedState": { + "epoch": null, + "hasMore": false, + "messages": [ + { + "contentRevision": 5, + "createdAt": 10000, + "id": "live", + "invokedAt": 10000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 10 + } + ], + "newestCursor": null, + "olderCursor": null, + "readThroughRevision": null, + "viewMode": "tail" + }, + "fixtureVersion": 1, + "name": "read-receipt-requires-initial-tail-sync", + "ops": [ + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "live", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "contentRevision": 5, + "createdAt": 10000, + "id": "live", + "invokedAt": 10000, + "localId": null, + "seq": 10 + } + ], + "op": "sse-messages" + } + ] +} diff --git a/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json index 26ce4562..31d5e265 100644 --- a/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json +++ b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json @@ -58,6 +58,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/reset-latest-preserves-optimistic.json b/shared/fixtures/pagination/reset-latest-preserves-optimistic.json index 6146cf08..d602eacc 100644 --- a/shared/fixtures/pagination/reset-latest-preserves-optimistic.json +++ b/shared/fixtures/pagination/reset-latest-preserves-optimistic.json @@ -32,6 +32,7 @@ "at": 2000, "seq": 2 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json b/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json index 689be3fc..88c85337 100644 --- a/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json +++ b/shared/fixtures/pagination/sse-hidden-rows-advance-cursor-only.json @@ -31,6 +31,7 @@ "at": 1000, "seq": 1 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json b/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json index 863895ad..92a80364 100644 --- a/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json +++ b/shared/fixtures/pagination/trim-preserves-queued-and-recomputes-cursor.json @@ -3613,6 +3613,7 @@ "at": 3000, "seq": 3 }, + "readThroughRevision": 0, "viewMode": "tail" }, "fixtureVersion": 1, diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 3047d023..b3b6c77a 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -125,6 +125,8 @@ export type SessionResponse = { session: Session } export type MessagesResponse = { messages: DecryptedMessage[] page: { + /** Readable revision covered by this tail snapshot; absent on history pages. */ + readThroughRevision?: number direction: 'latest' | 'before' | 'after' limit: number epoch: number diff --git a/shared/src/index.ts b/shared/src/index.ts index f15f4198..637d1744 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -15,6 +15,7 @@ export * from './rpcMethods' export * from './runnerCapabilities' export * from './search' export * from './socket' +export * from './sessionReadState' export * from './sessionSummary' export * from './sessionOrder' export * from './sessionCitation' diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 37e15738..06f2cc5b 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -179,6 +179,36 @@ export function extractAssistantPlainText(content: unknown): string | null { return null } +/** Output a reader can consume. Bookkeeping, reasoning and tool traffic do not + * create unread activity. Run at durable ingest, never on heartbeat/metadata. */ +export function hasUnreadMessageContent(value: unknown): boolean { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (record?.role !== 'agent' || !isObject(record.content)) return false + const content = record.content + if (isRedundantGoalStatusEventContent(value)) return false + const data = isObject(content.data) ? content.data : null + if (!data) return false + if (content.type === 'output' && (data.isMeta || data.isCompactSummary + || !isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype }))) return false + if (extractAssistantPlainText(content)?.trim()) return true + if (content.type === 'output') { + if (data.type === 'assistant' && typeof data.error === 'string' && data.error.trim()) return true + if (data.type === 'assistant' && isObject(data.message) && typeof data.message.content === 'string') { + return data.message.content.trim().length > 0 + } + if (data.type === 'result') return data.is_error === true || (typeof data.result === 'string' && data.result.trim().length > 0) + return data.type === 'system' && data.subtype === 'api_error' + } + if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE) { + if (data.type === 'error') return typeof data.message === 'string' && data.message.trim().length > 0 + if (data.type === 'generated-image') { + const id = data.imageId ?? data.image_id + return typeof id === 'string' && id.trim().length > 0 + } + } + return false +} + function hasConversationBlock(value: unknown): boolean { if (typeof value === 'string') return value.trim().length > 0 if (Array.isArray(value)) return value.some(hasConversationBlock) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a7162403..fe67e547 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { SessionReadStateSchema } from './sessionReadState' import { COPILOT_AGENT_MODES, type CopilotAgentMode } from './copilotModes' import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' import { AgentConfigDescriptorSchema } from './agentConfig' @@ -326,6 +327,7 @@ export const AttachmentMetadataSchema = z.object({ export type AttachmentMetadata = z.infer export const DecryptedMessageSchema = z.object({ + contentRevision: z.number().int().nonnegative().safe().optional(), id: z.string(), seq: z.number().nullable(), localId: z.string().nullable(), @@ -352,6 +354,8 @@ export const BackgroundTaskSchema = z.object({ export type BackgroundTask = z.infer export const SessionSchema = z.object({ + contentRevision: z.number().int().nonnegative().safe().optional(), + readState: SessionReadStateSchema.optional(), lastUserMessageAt: z.number().optional(), hasConversationContent: z.boolean().optional(), id: z.string(), @@ -422,6 +426,8 @@ const VersionedTeamStatePatchSchema = z.object({ }) export const SessionPatchSchema = z.object({ + contentRevision: z.number().int().nonnegative().safe().optional(), + readState: SessionReadStateSchema.optional(), lastUserMessageAt: z.number().optional(), active: z.boolean().optional(), thinking: z.boolean().optional(), diff --git a/shared/src/sessionReadState.test.ts b/shared/src/sessionReadState.test.ts new file mode 100644 index 00000000..53aa538f --- /dev/null +++ b/shared/src/sessionReadState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test' +import { hasUnreadMessageContent } from './messages' +import { mergeReadState, SessionReadStateRequestSchema } from './sessionReadState' + +describe('read activity contract', () => { + it('accepts visible answers, media and errors while excluding agent bookkeeping', () => { + const agent = (data: unknown) => ({ role: 'agent', content: { type: 'codex', data } }) + for (const data of [{ type: 'message', message: 'Answer' }, { type: 'generated-image', imageId: 'image' }, { type: 'error', message: 'Failed' }]) { + expect(hasUnreadMessageContent(agent(data))).toBe(true) + } + for (const data of [{ type: 'reasoning', message: 'Thinking' }, { type: 'tool-call', callId: '1' }, { type: 'token_count' }, { type: 'message', message: ' ' }]) { + expect(hasUnreadMessageContent(agent(data))).toBe(false) + } + expect(hasUnreadMessageContent({ role: 'user', content: { type: 'text', text: 'Prompt' } })).toBe(false) + }) + + it('matches Claude visible text and failures while excluding hidden transcript records', () => { + const output = (data: unknown) => ({ role: 'agent', content: { type: 'output', data } }) + const answer = { type: 'assistant', message: { content: [{ type: 'text', text: 'answer' }] } } + expect(hasUnreadMessageContent(output(answer))).toBe(true) + expect(hasUnreadMessageContent(output({ type: 'assistant', message: { content: 'answer' } }))).toBe(true) + expect(hasUnreadMessageContent(output({ ...answer, isMeta: true }))).toBe(false) + expect(hasUnreadMessageContent(output({ ...answer, isCompactSummary: true }))).toBe(false) + expect(hasUnreadMessageContent(output({ type: 'assistant', error: 'Prompt too long' }))).toBe(true) + expect(hasUnreadMessageContent(output({ type: 'result', is_error: true }))).toBe(true) + expect(hasUnreadMessageContent(output({ type: 'system', subtype: 'task_notification', summary: 'hidden' }))).toBe(false) + expect(hasUnreadMessageContent(output({ type: 'assistant', message: { content: [{ type: 'image', source: {} }] } }))).toBe(false) + }) + + it('rejects unsafe and fractional cursors', () => { + for (const readRevision of [-1, 0.1, Number.MAX_SAFE_INTEGER + 1]) { + expect(SessionReadStateRequestSchema.safeParse({ type: 'read', readRevision, expectedVersion: 0 }).success).toBe(false) + } + }) + + it('preserves a newer manual choice across stale responses', () => { + expect(mergeReadState({ version: 3, manualUnread: true, readRevision: 5 }, { version: 2, manualUnread: false, readRevision: 4 })) + .toEqual({ version: 3, manualUnread: true, readRevision: 5 }) + }) +}) diff --git a/shared/src/sessionReadState.ts b/shared/src/sessionReadState.ts new file mode 100644 index 00000000..42267700 --- /dev/null +++ b/shared/src/sessionReadState.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +/** Shared by every client in a session's namespace. Versions are hub-owned. */ +export const SessionReadStateSchema = z.object({ + readRevision: z.number().int().nonnegative().safe(), + manualUnread: z.boolean(), + version: z.number().int().nonnegative().safe() +}) + +export type SessionReadState = z.infer + +export const SessionReadStateRequestSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('read'), + readRevision: z.number().int().nonnegative().safe(), + expectedVersion: z.number().int().nonnegative().safe() + }).strict(), + z.object({ + type: z.literal('unread'), + expectedVersion: z.number().int().nonnegative().safe() + }).strict() +]) + +export type SessionReadStateRequest = z.infer + +export const EMPTY_READ_STATE: SessionReadState = { readRevision: 0, manualUnread: false, version: 0 } + +/** Full responses and both SSE subscriptions may arrive out of order. */ +export function mergeReadState(current: SessionReadState | undefined, incoming: SessionReadState | undefined): SessionReadState | undefined { + if (!incoming) return current + if (!current) return incoming + return { + ...(incoming.version > current.version ? incoming : current), + readRevision: Math.max(current.readRevision, incoming.readRevision) + } +} diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 991ee9ad..a2a792e6 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,5 @@ import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' +import type { SessionReadState } from './sessionReadState' import { isKnownFlavor } from './flavors' import type { AgentFlavor } from './modes' @@ -49,6 +50,8 @@ export type SessionSummaryMetadata = { } export type SessionSummary = { + contentRevision?: number + readState?: SessionReadState hasConversationContent?: boolean id: string active: boolean @@ -219,6 +222,8 @@ export function toSessionSummary(session: Session): SessionSummary { thinking: session.thinking, activeAt: session.activeAt, updatedAt: session.updatedAt, + contentRevision: session.contentRevision, + readState: session.readState, createdAt: session.createdAt, lastUserMessageAt: session.lastUserMessageAt, pinned: session.pinned ?? false, diff --git a/web/scripts/fixtures/pagination/build.ts b/web/scripts/fixtures/pagination/build.ts index 801e6865..dc8a4279 100644 --- a/web/scripts/fixtures/pagination/build.ts +++ b/web/scripts/fixtures/pagination/build.ts @@ -14,7 +14,7 @@ const PAGE_DIRECTIONS = new Set(['latest', 'before', 'after']) const PAGE_KEYS = [ 'direction', 'limit', 'epoch', 'reset', 'nextBeforeAt', 'nextBeforeSeq', 'nextAfterAt', 'nextAfterSeq', - 'snapshotHeadAt', 'snapshotHeadSeq', 'hasMore' + 'snapshotHeadAt', 'snapshotHeadSeq', 'hasMore', 'readThroughRevision' ] as const function fail(label: string, reason: string): never { @@ -67,6 +67,9 @@ function validateResponse(response: MessagesResponse, label: string): void { } if (typeof page.reset !== 'boolean') fail(label, 'page.reset must be a boolean') if (typeof page.hasMore !== 'boolean') fail(label, 'page.hasMore must be a boolean') + if (page.readThroughRevision !== undefined && (!Number.isSafeInteger(page.readThroughRevision) || (page.readThroughRevision as number) < 0)) { + fail(label, 'page.readThroughRevision must be a non-negative safe integer') + } for (const key of ['nextBeforeAt', 'nextBeforeSeq', 'nextAfterAt', 'nextAfterSeq', 'snapshotHeadAt', 'snapshotHeadSeq'] as const) { if (!isNullableNumber(page[key])) fail(label, `page.${key} must be a number or null`) } diff --git a/web/scripts/fixtures/pagination/cases.ts b/web/scripts/fixtures/pagination/cases.ts index 0f533d53..e0410b2c 100644 --- a/web/scripts/fixtures/pagination/cases.ts +++ b/web/scripts/fixtures/pagination/cases.ts @@ -9,8 +9,9 @@ import type { PaginationFixtureCase } from './types' * keep ids ASCII and positions distinct). */ -function agentMessage(init: { id: string; seq: number; at: number }): DecryptedMessage { +function agentMessage(init: { id: string; seq: number; at: number; contentRevision?: number }): DecryptedMessage { return { + ...(init.contentRevision !== undefined ? { contentRevision: init.contentRevision } : {}), id: init.id, seq: init.seq, localId: null, @@ -106,11 +107,13 @@ function pageResponse( nextBefore?: Position | null nextAfter?: Position | null snapshotHead?: Position | null + readThroughRevision?: number } ): MessagesResponse { return { messages, page: { + ...(page.readThroughRevision !== undefined ? { readThroughRevision: page.readThroughRevision } : {}), direction: page.direction, limit: 200, epoch: page.epoch, @@ -135,6 +138,34 @@ function paddedAgentRun(fromSeq: number, toSeq: number): DecryptedMessage[] { } export const paginationFixtureCases: PaginationFixtureCase[] = [ + { + name: 'read-receipt-latest-and-live-output', + description: 'A successful latest page establishes the read receipt; live readable rows advance it independently of position sequence.', + ops: [ + { op: 'sync-tail', responses: [pageResponse([ + agentMessage({ id: 'answer', seq: 10, at: 10_000, contentRevision: 5 }) + ], { direction: 'latest', epoch: 0, hasMore: false, snapshotHead: { at: 10_000, seq: 10 }, readThroughRevision: 5 })] }, + { op: 'sse-messages', messages: [agentMessage({ id: 'next', seq: 12, at: 12_000, contentRevision: 6 })] } + ] + }, + { + name: 'read-receipt-history-does-not-advance', + description: 'Browsing history may reveal a delayed message with a higher content revision; an older-page load never certifies the current tail.', + ops: [ + { op: 'sync-tail', responses: [pageResponse([ + agentMessage({ id: 'answer', seq: 10, at: 10_000, contentRevision: 5 }) + ], { direction: 'latest', epoch: 0, hasMore: true, nextBefore: { at: 10_000, seq: 10 }, snapshotHead: { at: 10_000, seq: 10 }, readThroughRevision: 5 })] }, + { op: 'set-view-mode', mode: 'history' }, + { op: 'fetch-older', responses: [pageResponse([ + agentMessage({ id: 'delayed', seq: 11, at: 5_000, contentRevision: 6 }) + ], { direction: 'before', epoch: 0, hasMore: false, nextBefore: { at: 5_000, seq: 11 } })] } + ] + }, + { + name: 'read-receipt-requires-initial-tail-sync', + description: 'SSE arriving before initial tail synchronization cannot establish a read receipt over an unloaded conversation.', + ops: [{ op: 'sse-messages', messages: [agentMessage({ id: 'live', seq: 10, at: 10_000, contentRevision: 5 })] }] + }, { name: 'latest-page-then-sse-ingest', description: 'Cold start: a latest page seeds the window, epoch, older cursor (page.nextBefore) and newest cursor (page.snapshotHead); a live SSE message appends and advances the newest cursor to its position.', diff --git a/web/scripts/fixtures/pagination/runner.ts b/web/scripts/fixtures/pagination/runner.ts index 277e422f..68ad6c0d 100644 --- a/web/scripts/fixtures/pagination/runner.ts +++ b/web/scripts/fixtures/pagination/runner.ts @@ -83,6 +83,7 @@ function projectOutcome(outcome: OlderLoadOutcome): ProjectedOlderOutcome { function projectMessage(message: DecryptedMessage): ProjectedWindowMessage { return { + ...(message.contentRevision !== undefined ? { contentRevision: message.contentRevision } : {}), id: message.id, localId: message.localId ?? null, seq: message.seq ?? null, @@ -116,6 +117,7 @@ export function projectWindowState(sessionId: string): ProjectedWindowState { } return { messages: state.messages.map(projectMessage), + readThroughRevision: state.readThroughRevision ?? null, hasMore: state.hasMore, epoch: state.epoch, viewMode: state.viewMode, diff --git a/web/scripts/fixtures/pagination/types.ts b/web/scripts/fixtures/pagination/types.ts index 1fe8cab6..45289276 100644 --- a/web/scripts/fixtures/pagination/types.ts +++ b/web/scripts/fixtures/pagination/types.ts @@ -100,6 +100,7 @@ export type PaginationOpObservation = { * client-side optimistic send state when present. `queued` and `optimistic` * are the derived predicates natives must reproduce. */ export type ProjectedWindowMessage = { + contentRevision?: number id: string localId: string | null seq: number | null @@ -115,6 +116,7 @@ export type ProjectedWindowMessage = { * the store's compound paging positions: `olderCursor` feeds the next * before-request, `newestCursor` the next after-request. */ export type ProjectedWindowState = { + readThroughRevision: number | null messages: ProjectedWindowMessage[] hasMore: boolean epoch: number | null diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3847922c..1f05d375 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -65,7 +65,7 @@ import type { UploadFileResponse } from '@hapi/protocol/apiTypes' import { fetchThroughAuthProxy } from '@/api/authProxy' -import type { AgentFlavor, MessageDeliveryMode } from '@hapi/protocol' +import type { SessionReadState, SessionReadStateRequest, AgentFlavor, MessageDeliveryMode } from '@hapi/protocol' import type { CancelMessageResponse, SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice' @@ -268,6 +268,21 @@ export class ApiClient { return await res.json() as AuthResponse } + getReadStateScope(): string | null { + try { + const token = this.getToken?.() ?? this.token + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) as { ns?: unknown } + if (typeof payload.ns !== 'string') return null + return JSON.stringify([this.baseUrl ?? window.location.origin, payload.ns]) + } catch { return null } + } + + async updateSessionReadState(sessionId: string, state: SessionReadStateRequest): Promise<{ readState: SessionReadState }> { + return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/read-state`, { + method: 'PUT', body: JSON.stringify(state) + }) + } + async getSessions(): Promise { return await this.request('/api/sessions') } diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 130648bb..ecfcd08b 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -1,3 +1,4 @@ +import { useSelectedSessionSeen } from '@/hooks/useSelectedSessionSeen' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ThreadPrimitive, unstable_useThreadMessageIds, useAuiState } from '@assistant-ui/react' import type { ComponentProps } from 'react' @@ -540,6 +541,7 @@ export function HappyThread(props: { onRewindConversation?: (messageLocalId: string) => Promise isLatestCompletedBoundary?: (messageId: string) => boolean onViewModeChange: (mode: 'tail' | 'history') => void + readThroughRevision?: number isSyncingTail: boolean messagesWarning: string | null hasMoreMessages: boolean @@ -682,6 +684,9 @@ export function HappyThread(props: { ) const startHistoryLoadAttemptRef = useRef<(runId: number) => void>(() => {}) const needsViewportCoverageRef = useRef<() => boolean>(() => false) + useSelectedSessionSeen(props.sessionId, + !props.isSyncingTail && !props.messagesWarning ? props.readThroughRevision : undefined, + props.session.readState?.version, viewportRef, contentRef) const atBottomRef = useRef(true) const onViewModeChangeRef = useRef(props.onViewModeChange) const forceScrollTokenRef = useRef(props.forceScrollToken) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 5bdaf633..d1d48919 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -586,6 +586,7 @@ function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean { } type SessionChatProps = { + readThroughRevision?: number api: ApiClient titleSuggestionAvailable?: boolean session: Session @@ -2098,6 +2099,7 @@ function SessionChatInner(props: SessionChatProps) { onRewindConversation={controlledByUser ? undefined : onRewindConversation} isLatestCompletedBoundary={isLatestCompletedBoundary} onViewModeChange={props.onViewModeChange} + readThroughRevision={!terminalVisible && props.viewMode === 'tail' ? props.readThroughRevision : undefined} isSyncingTail={props.isSyncingTail} messagesWarning={props.messagesWarning} hasMoreMessages={props.hasMoreMessages} diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index bed58d1d..23b815e7 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -534,7 +534,7 @@ export function SessionHeader(props: { sessionPinned={Boolean(session.pinned)} sessionGlobalPinned={Boolean(session.globalPinned)} onRename={() => setRenameOpen(true)} - onMarkUnread={() => markSessionUnread(session.id, session.updatedAt)} + onMarkUnread={() => markSessionUnread(session.id, session.readState)} onSetPinMode={api ? (mode) => void handleSetPinMode(mode) : undefined} onExport={() => setExportOpen(true)} onSyncCodex={api && codexSessionId && !session.active ? handleSyncCodex : undefined} diff --git a/web/src/components/SessionList.mark-all-read.test.tsx b/web/src/components/SessionList.mark-all-read.test.tsx index bbdbea00..fe915abd 100644 --- a/web/src/components/SessionList.mark-all-read.test.tsx +++ b/web/src/components/SessionList.mark-all-read.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react' import type { SessionSummary } from '@/types/api' import { I18nProvider } from '@/lib/i18n-context' import { ToastProvider } from '@/lib/toast-context' +import { markSessionSeen, getSessionLastSeenSnapshot } from '@/lib/sessionLastSeen' import { SessionList } from './SessionList' afterEach(() => cleanup()) @@ -28,6 +29,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi nextScheduledAt: null, model: null, effort: null, + contentRevision: overrides.updatedAt ?? 0, ...overrides } } @@ -68,7 +70,7 @@ describe('SessionList mark all as read', () => { }) it('only shows the independent action when unread sessions exist', () => { - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ read: 1000 })) + seedReads({ read: 1000 }) renderSessionList([ makeSession({ id: 'read', updatedAt: 1000, metadata: { path: '/work/read', name: 'Read' } }) ]) @@ -76,7 +78,7 @@ describe('SessionList mark all as read', () => { expect(screen.queryByRole('button', { name: 'Mark all as read (1)' })).toBeNull() cleanup() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ unread: 1000 })) + seedReads({ unread: 1000 }) renderSessionList([ makeSession({ id: 'unread', updatedAt: 2000, metadata: { path: '/work/unread', name: 'Unread' } }) ]) @@ -89,11 +91,11 @@ describe('SessionList mark all as read', () => { }) it('requires confirmation before marking every unread session read', async () => { - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ + seedReads({ unreadA: 1000, unreadB: 2000, read: 3000 - })) + }) renderSessionList([ makeSession({ id: 'unreadA', updatedAt: 1100, metadata: { path: '/work/a', name: 'Unread A' } }), makeSession({ id: 'unreadB', updatedAt: 2200, metadata: { path: '/work/b', name: 'Unread B' } }), @@ -105,16 +107,16 @@ describe('SessionList mark all as read', () => { const title = within(dialog).getByRole('heading', { name: 'Mark all as read?' }) expect(title.parentElement).toHaveClass('pr-0') expect(title).toHaveClass('min-h-6', 'px-10', 'text-center', 'leading-6') - expect(screen.getByText('This will mark 2 unread sessions as read on this device.')).toBeTruthy() + expect(screen.getByText('This will mark 2 unread sessions as read across your devices.')).toBeTruthy() expect(within(dialog).getByRole('button', { name: 'Confirm' })).toHaveClass('bg-red-600', 'text-white') - expect(JSON.parse(localStorage.getItem('hapi.sessionLastSeen.v1')!)).toMatchObject({ unreadA: 1000, unreadB: 2000 }) + expect(getSessionLastSeenSnapshot()).toMatchObject({ unreadA: 1000, unreadB: 2000 }) fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) await waitFor(() => { expect(screen.queryByRole('button', { name: 'Mark all as read (2)' })).toBeNull() }) - expect(JSON.parse(localStorage.getItem('hapi.sessionLastSeen.v1')!)).toMatchObject({ + expect(getSessionLastSeenSnapshot()).toMatchObject({ unreadA: 1100, unreadB: 2200, read: 3000 @@ -123,7 +125,7 @@ describe('SessionList mark all as read', () => { }) it('leaves unread state unchanged when the confirmation is cancelled', () => { - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ unread: 1000 })) + seedReads({ unread: 1000 }) renderSessionList([ makeSession({ id: 'unread', updatedAt: 2000, metadata: { path: '/work/unread', name: 'Unread' } }) ]) @@ -132,14 +134,14 @@ describe('SessionList mark all as read', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.getByRole('button', { name: 'Mark all as read (1)' })).toBeTruthy() - expect(JSON.parse(localStorage.getItem('hapi.sessionLastSeen.v1')!)).toEqual({ unread: 1000 }) + expect(getSessionLastSeenSnapshot()).toEqual({ unread: 1000 }) }) it('marks hidden duplicate session IDs so a dedup winner change stays read', async () => { - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ + seedReads({ visibleWinner: 2000, hiddenDuplicate: 0, - })) + }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -189,17 +191,17 @@ describe('SessionList mark all as read', () => { )))) expect(screen.queryByRole('button', { name: 'Mark all as read (1)' })).toBeNull() - expect(JSON.parse(localStorage.getItem('hapi.sessionLastSeen.v1')!)).toMatchObject({ + expect(getSessionLastSeenSnapshot()).toMatchObject({ visibleWinner: 2000, hiddenDuplicate: 1000, }) }) it('does not count unread inactive empty session stubs', () => { - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ + seedReads({ visible: 2000, emptyStub: 0, - })) + }) renderSessionList([ makeSession({ id: 'visible', @@ -214,7 +216,7 @@ describe('SessionList mark all as read', () => { it('uses the requested Chinese label', () => { localStorage.setItem('hapi-lang', 'zh-CN') - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ unread: 1000 })) + seedReads({ unread: 1000 }) renderSessionList([ makeSession({ id: 'unread', updatedAt: 2000, metadata: { path: '/work/unread', name: 'Unread' } }) ]) @@ -222,3 +224,7 @@ describe('SessionList mark all as read', () => { expect(screen.getByRole('button', { name: '全部标为已读(1)' })).toBeTruthy() }) }) + +function seedReads(values: Record) { + for (const [id, revision] of Object.entries(values)) markSessionSeen(id, revision) +} diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index e71cbdcb..4256c0d8 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -549,13 +549,13 @@ describe('filterUnreadSessionsOnly', () => { it('keeps only sessions newer than lastSeen; drops seen and selected-only quiet rows stay when selected', () => { const sessions = [ - makeSession({ id: 'unread', updatedAt: 5000, metadata: { path: '/p' } }), - makeSession({ id: 'seen', updatedAt: 1000, metadata: { path: '/p' } }), + makeSession({ id: 'unread', contentRevision: 5000, metadata: { path: '/p' } }), + makeSession({ id: 'seen', contentRevision: 1000, metadata: { path: '/p' } }), makeSession({ id: 'permission-but-seen', pendingRequestKinds: ['permission'], pendingRequestsCount: 1, - updatedAt: 1000, + contentRevision: 1000, metadata: { path: '/p' }, }), ] @@ -565,8 +565,8 @@ describe('filterUnreadSessionsOnly', () => { it('keeps the selected session visible even when it would otherwise filter out', () => { const sessions = [ - makeSession({ id: 'unread', updatedAt: 5000, metadata: { path: '/p' } }), - makeSession({ id: 'selected-quiet', updatedAt: 1000, metadata: { path: '/p' } }), + makeSession({ id: 'unread', contentRevision: 5000, metadata: { path: '/p' } }), + makeSession({ id: 'selected-quiet', contentRevision: 1000, metadata: { path: '/p' } }), ] expect(filterUnreadSessionsOnly(sessions, 'selected-quiet', lastSeen).map(s => s.id)) .toEqual(['unread', 'selected-quiet']) @@ -578,12 +578,12 @@ describe('filterUnreadSessionsOnly', () => { const sessions = [ makeSession({ id: 'unread-a', - updatedAt: 5000, + contentRevision: 5000, metadata: { path: '/a', machineId: 'machine-a' }, }), makeSession({ id: 'seen-b', - updatedAt: 1000, + contentRevision: 1000, metadata: { path: '/b', machineId: 'machine-b' }, }), ] @@ -598,7 +598,7 @@ describe('filterUnreadSessionsOnly', () => { it('keeps an explicitly unread generating session in the unread lens', () => { const sessions = [ - makeSession({ id: 'manual-thinking', active: true, thinking: true, updatedAt: 5000, metadata: { path: '/p' } }) + makeSession({ id: 'manual-thinking', active: true, thinking: true, contentRevision: 5000, metadata: { path: '/p' } }) ] expect(filterUnreadSessionsOnly( diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 6227d057..eb41cd34 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -398,13 +398,13 @@ export function filterUnreadSessionsOnly( sessions: SessionSummary[], selectedSessionId: string | null | undefined, getLastSeenAt: (sessionId: string) => number, - getManualUnreadAt: (sessionId: string) => number | null = () => null, + getManualUnreadAt: (sessionId: string, readState?: SessionSummary['readState']) => number | null | undefined = () => undefined, ): SessionSummary[] { return sessions.filter(session => session.id === selectedSessionId || sessionIsUnread(session, { lastSeenAt: getLastSeenAt(session.id), - manualUnreadAt: getManualUnreadAt(session.id) + manualUnreadAt: getManualUnreadAt(session.id, session.readState) }) ) } @@ -1238,7 +1238,7 @@ const SessionItem = memo(function SessionItem(props: { ? classifySessionAttention(s, { selected, lastSeenAt: getSessionLastSeenAt(s.id), - manualUnreadAt: getSessionManualUnreadAt(s.id) + manualUnreadAt: getSessionManualUnreadAt(s.id, s.readState) }) : null, [s, selected, showDetailedStatus, lastSeenVersion] @@ -1285,7 +1285,7 @@ const SessionItem = memo(function SessionItem(props: { onSetPinMode={(mode) => void handleSetPinMode(mode)} onRename={() => setRenameOpen(true)} onExport={() => setExportOpen(true)} - onMarkUnread={() => markSessionUnread(s.id, s.updatedAt)} + onMarkUnread={() => markSessionUnread(s.id, s.readState)} onArchive={() => setArchiveOpen(true)} onReopen={cursorReopenDisabledReason ? undefined : handleReopen} reopenDisabledReason={cursorReopenDisabledReason} diff --git a/web/src/components/SessionRowSummary.test.tsx b/web/src/components/SessionRowSummary.test.tsx index 62d0cdbd..375e2def 100644 --- a/web/src/components/SessionRowSummary.test.tsx +++ b/web/src/components/SessionRowSummary.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionSummary } from '@/types/api' import { I18nProvider } from '@/lib/i18n-context' import { markCodexSessionsImported } from '@/lib/codexImportedSessions' +import { markSessionSeen, markSessionUnread } from '@/lib/sessionLastSeen' import { SessionRowSummary } from './SessionRowSummary' afterEach(() => cleanup()) @@ -27,6 +28,7 @@ function makeSummary(overrides: Partial = {}): SessionSummary { nextScheduledAt: null, model: null, effort: null, + contentRevision: overrides.updatedAt ?? 0, ...overrides } } @@ -76,7 +78,7 @@ describe('SessionRowSummary background status', () => { backgroundTaskCount: 0, updatedAt: 2_000, }) - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) + markSessionSeen(session.id, 2_000) const view = render( { expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 1_999 })) + markSessionUnread(session.id) view.rerender( { backgroundTaskCount: 0, updatedAt: 2_000, }) - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) - localStorage.setItem('hapi.sessionManualUnread.v1', JSON.stringify({ [session.id]: 2_000 })) + markSessionSeen(session.id, 2_000) + markSessionUnread(session.id) const view = render( @@ -146,8 +148,8 @@ describe('SessionRowSummary background status', () => { thinking: true, updatedAt: 2_000, }) - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) - localStorage.setItem('hapi.sessionManualUnread.v1', JSON.stringify({ [session.id]: 2_000 })) + markSessionSeen(session.id, 2_000) + markSessionUnread(session.id) render( diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index c4ac4cd5..53f68c16 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -127,7 +127,7 @@ export function SessionRowSummary(props: { ? classifySessionAttention(s, { selected, lastSeenAt: getSessionLastSeenAt(s.id), - manualUnreadAt: getSessionManualUnreadAt(s.id), + manualUnreadAt: getSessionManualUnreadAt(s.id, s.readState), }) : null, [s, selected, showDetailedStatus, lastSeenVersion] diff --git a/web/src/hooks/queries/useMessages.ts b/web/src/hooks/queries/useMessages.ts index b0192279..0fbd5313 100644 --- a/web/src/hooks/queries/useMessages.ts +++ b/web/src/hooks/queries/useMessages.ts @@ -32,6 +32,7 @@ export const EMPTY_STATE: MessageWindowState = { export function useMessages(api: ApiClient | null, sessionId: string | null): { messages: DecryptedMessage[] + readThroughRevision?: number warning: string | null isSyncingTail: boolean isLoadingMore: boolean @@ -95,6 +96,7 @@ export function useMessages(api: ApiClient | null, sessionId: string | null): { return { messages: state.messages, + readThroughRevision: state.readThroughRevision, warning: state.warning, isSyncingTail: state.isSyncingTail, isLoadingMore: state.isLoadingMore, diff --git a/web/src/hooks/queries/useSession.ts b/web/src/hooks/queries/useSession.ts index adf086ea..2254cfe4 100644 --- a/web/src/hooks/queries/useSession.ts +++ b/web/src/hooks/queries/useSession.ts @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query' +import { mergeReadState } from '@hapi/protocol' +import { useQuery, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { Session } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -26,6 +27,7 @@ export function useSession(api: ApiClient | null, sessionId: string | null): { notFound: boolean refetch: () => Promise } { + const queryClient = useQueryClient() const resolvedSessionId = sessionId ?? 'unknown' const query = useQuery({ queryKey: queryKeys.session(resolvedSessionId), @@ -33,7 +35,12 @@ export function useSession(api: ApiClient | null, sessionId: string | null): { if (!api || !sessionId) { throw new Error('Session unavailable') } - return await api.getSession(sessionId) + const response = await api.getSession(sessionId) + const current = queryClient.getQueryData<{ session: Session }>(queryKeys.session(sessionId))?.session + return { ...response, session: { ...response.session, + contentRevision: Math.max(current?.contentRevision ?? 0, response.session.contentRevision ?? 0), + readState: mergeReadState(current?.readState, response.session.readState) + } } }, enabled: Boolean(api && sessionId), staleTime: SESSION_DETAIL_STALE_TIME_MS, diff --git a/web/src/hooks/queries/useSessions.ts b/web/src/hooks/queries/useSessions.ts index 2159fb96..1c7a5cf8 100644 --- a/web/src/hooks/queries/useSessions.ts +++ b/web/src/hooks/queries/useSessions.ts @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { compareSessionSummaries } from '@hapi/protocol' +import { compareSessionSummaries, mergeReadState } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import type { SessionsResponse, SessionSummary } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -25,14 +25,16 @@ export function useSessions(api: ApiClient | null, options: UseSessionsOptions = // SSE may have delivered newer input while this GET was in flight. // Retain its monotonic ordering clock, not deleted rows or stale data. const current = queryClient.getQueryData(queryKeys.sessions) - const clocks = new Map(current?.sessions.map(s => [s.id, s.lastUserMessageAt])) + const cached = new Map(current?.sessions.map(s => [s.id, s])) return { ...response, sessions: response.sessions.map(session => { - const at = clocks.get(session.id) - return at !== undefined && at > (session.lastUserMessageAt ?? -Infinity) - ? { ...session, lastUserMessageAt: at } - : session + const prior = cached.get(session.id) + return { ...session, + lastUserMessageAt: Math.max(prior?.lastUserMessageAt ?? 0, session.lastUserMessageAt ?? 0) || undefined, + contentRevision: Math.max(prior?.contentRevision ?? 0, session.contentRevision ?? 0), + readState: mergeReadState(prior?.readState, session.readState) + } }).sort(compareSessionSummaries) } }, diff --git a/web/src/hooks/useAppBadge.test.ts b/web/src/hooks/useAppBadge.test.ts index cad2d5e1..489370eb 100644 --- a/web/src/hooks/useAppBadge.test.ts +++ b/web/src/hooks/useAppBadge.test.ts @@ -16,6 +16,7 @@ function createSession( thinking, activeAt: updatedAt, updatedAt, + contentRevision: updatedAt, // Has real conversation content, so it's eligible for the sidebar's // "Unread" filter and "mark all read" — see createStubSession() below // for a session excluded from both. @@ -111,10 +112,8 @@ describe('useAppBadge', () => { it('sets the native badge to the number of unread sessions in an installed PWA', async () => { setDisplayMode(true) const { setAppBadge, clearAppBadge } = setAppBadgeNavigator() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ - 'session-a': 10, - 'session-b': 20, - })) + markSessionSeen('session-a', 10) + markSessionSeen('session-b', 20) renderHook(() => useAppBadge({ enabled: true, @@ -131,7 +130,7 @@ describe('useAppBadge', () => { it('clears the native badge after the unread session is seen', async () => { setDisplayMode(true) const { setAppBadge, clearAppBadge } = setAppBadgeNavigator() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ 'session-a': 10 })) + markSessionSeen('session-a', 10) renderHook(() => useAppBadge({ enabled: true, @@ -146,7 +145,7 @@ describe('useAppBadge', () => { await waitFor(() => expect(clearAppBadge).toHaveBeenCalledTimes(1)) }) - it('seeds the first session snapshot without showing existing sessions as unread', async () => { + it('shows unread content on the first load without silently seeding a baseline', async () => { setDisplayMode(true) const { setAppBadge, clearAppBadge } = setAppBadgeNavigator() @@ -158,14 +157,14 @@ describe('useAppBadge', () => { hasError: false, })) - await waitFor(() => expect(clearAppBadge).toHaveBeenCalledTimes(1)) - expect(setAppBadge).not.toHaveBeenCalled() + await waitFor(() => expect(setAppBadge).toHaveBeenCalledWith(1)) + expect(clearAppBadge).not.toHaveBeenCalled() }) it('clears the badge when the feature is disabled', async () => { setDisplayMode(true) const { setAppBadge, clearAppBadge } = setAppBadgeNavigator() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ 'session-a': 10 })) + markSessionSeen('session-a', 10) const { rerender } = renderHook( (options: { enabled: boolean }) => useAppBadge({ @@ -186,7 +185,7 @@ describe('useAppBadge', () => { it('does not repeat the native update when the unread count is unchanged', async () => { setDisplayMode(true) const { setAppBadge } = setAppBadgeNavigator() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ 'session-a': 10 })) + markSessionSeen('session-a', 10) const { rerender } = renderHook( (sessions: SessionSummary[]) => useAppBadge({ @@ -208,7 +207,7 @@ describe('useAppBadge', () => { it('does not count an empty stub session the sidebar filters out of "Unread"/"mark all read" (hapi#1845)', async () => { setDisplayMode(true) const { setAppBadge, clearAppBadge } = setAppBadgeNavigator() - localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ 'stub-session': 10 })) + markSessionSeen('stub-session', 10) renderHook(() => useAppBadge({ enabled: true, diff --git a/web/src/hooks/useAppBadge.ts b/web/src/hooks/useAppBadge.ts index 494b9980..15b93223 100644 --- a/web/src/hooks/useAppBadge.ts +++ b/web/src/hooks/useAppBadge.ts @@ -5,7 +5,7 @@ import { shouldShowSessionInSidebar } from '@/components/SessionList' import { getSessionLastSeenSnapshot, getSessionManualUnreadSnapshot, - initializeSessionLastSeen, + observeSessionReadStates, useSessionLastSeenVersion, } from '@/lib/sessionLastSeen' @@ -51,13 +51,13 @@ export function canUseAppBadging(): boolean { export function countUnreadSessions( sessions: readonly SessionSummary[], lastSeenById: Readonly>, - manualUnreadById: Readonly> = {}, + manualUnreadById: Readonly> = {}, ): number { return sessions.reduce( (count, session) => count + ( sessionIsUnread(session, { lastSeenAt: lastSeenById[session.id] ?? 0, - manualUnreadAt: manualUnreadById[session.id] ?? null + manualUnreadAt: manualUnreadById[session.id] }) ? 1 : 0 @@ -139,13 +139,12 @@ export function useAppBadge(options: UseAppBadgeOptions): void { // it can show a count nothing in the UI is able to clear (hapi#1845). const eligibleSessions = options.sessions.filter(session => shouldShowSessionInSidebar(session)) - // Match the session list's first-load baseline so existing sessions do - // not all appear unread when a user installs the PWA for the first time. - initializeSessionLastSeen(options.scope, eligibleSessions) + // Reconcile the shared read state before deriving the icon badge. + observeSessionReadStates(eligibleSessions) const count = countUnreadSessions( eligibleSessions, getSessionLastSeenSnapshot(), - getSessionManualUnreadSnapshot() + getSessionManualUnreadSnapshot(eligibleSessions) ) syncBadge(appBadgeNavigator, count, lastAppliedCountRef) }, [ diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 0108a5e6..2c7abdde 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -1,3 +1,4 @@ +import { mergeReadState } from '@hapi/protocol' import { useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { @@ -140,7 +141,9 @@ export function sameSessionSummaryMetadata( } export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSummary): boolean { - return current.active === next.active + return current.contentRevision === next.contentRevision + && current.readState?.version === next.readState?.version + && current.active === next.active && current.thinking === next.thinking && current.updatedAt === next.updatedAt && current.lastUserMessageAt === next.lastUserMessageAt @@ -473,6 +476,8 @@ export function useSSE(options: { const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined const summary = { ...toSessionSummary(session), + contentRevision: Math.max(existing?.contentRevision ?? 0, session.contentRevision ?? 0), + readState: mergeReadState(existing?.readState, session.readState), lastUserMessageAt: session.lastUserMessageAt === undefined ? existing?.lastUserMessageAt : Math.max(existing?.lastUserMessageAt ?? 0, session.lastUserMessageAt), @@ -510,6 +515,8 @@ export function useSSE(options: { const nextSummary: SessionSummary = { ...current, + contentRevision: Math.max(current.contentRevision ?? 0, patch.contentRevision ?? 0), + readState: mergeReadState(current.readState, patch.readState), active: patch.active ?? current.active, thinking: patch.thinking ?? current.thinking, activeAt: patch.activeAt ?? current.activeAt, @@ -729,7 +736,12 @@ export function useSSE(options: { void queryClient.removeQueries({ queryKey: queryKeys.session(event.sessionId) }) clearMessageWindow(event.sessionId) } else if (isSessionRecord(event.data) && event.data.id === event.sessionId) { - queryClient.setQueryData(queryKeys.session(event.sessionId), { session: event.data }) + const incoming = event.data + queryClient.setQueryData(queryKeys.session(event.sessionId), previous => ({ session: { + ...incoming, + contentRevision: Math.max(previous?.session.contentRevision ?? 0, incoming.contentRevision ?? 0), + readState: mergeReadState(previous?.session.readState, incoming.readState) + } })) upsertSessionSummary(event.data) } else { const patch = getSessionPatch(event.data) diff --git a/web/src/hooks/useSelectedSessionSeen.test.ts b/web/src/hooks/useSelectedSessionSeen.test.ts index 61c2fb98..a25d12a0 100644 --- a/web/src/hooks/useSelectedSessionSeen.test.ts +++ b/web/src/hooks/useSelectedSessionSeen.test.ts @@ -1,54 +1,68 @@ import { act, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSessionLastSeenAt, getSessionManualUnreadAt, observeSessionReadStates } from '@/lib/sessionLastSeen' import { useSelectedSessionSeen } from './useSelectedSessionSeen' -function setVisibilityState(state: DocumentVisibilityState): void { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - value: state, - }) +function setVisibility(state: DocumentVisibilityState): void { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: state }) + document.dispatchEvent(new Event('visibilitychange')) } +function viewport(atBottom = true) { + const element = document.createElement('div') + Object.defineProperty(element, 'clientHeight', { value: 100 }) + Object.defineProperty(element, 'scrollHeight', { value: 200 }) + element.scrollTop = atBottom ? 100 : 0 + return { current: element } +} +const content = { current: document.createElement('div') } +const settle = () => act(async () => { await vi.advanceTimersByTimeAsync(100) }) -beforeEach(() => { - window.localStorage.clear() -}) +beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); setVisibility('visible') }) +afterEach(() => { vi.useRealTimers(); setVisibility('visible') }) -afterEach(() => { - setVisibilityState('visible') -}) - -describe('useSelectedSessionSeen', () => { - it('does not mark hidden selected-session updates as seen', () => { - setVisibilityState('hidden') - const { rerender } = renderHook( - ({ updatedAt }) => useSelectedSessionSeen('session-a', updatedAt), - { initialProps: { updatedAt: 100 } }, - ) - - rerender({ updatedAt: 200 }) - - expect(getSessionLastSeenAt('session-a')).toBe(0) +describe('rendered read receipt', () => { + it('waits for visible rendered tail and uses only its observed revision', async () => { + setVisibility('hidden') + const view = viewport() + const { rerender } = renderHook(({ revision }) => useSelectedSessionSeen('s', revision, 0, view, content), { + initialProps: { revision: 2 }, + }) + await settle() + expect(getSessionLastSeenAt('s')).toBe(0) + rerender({ revision: 3 }) + act(() => setVisibility('visible')) + expect(getSessionLastSeenAt('s')).toBe(0) + await settle() + expect(getSessionLastSeenAt('s')).toBe(3) }) - it('marks the latest selected-session update when visibility resumes', () => { - setVisibilityState('hidden') - const { rerender } = renderHook( - ({ updatedAt }) => useSelectedSessionSeen('session-a', updatedAt), - { initialProps: { updatedAt: 100 } }, - ) - - rerender({ updatedAt: 200 }) - setVisibilityState('visible') - act(() => document.dispatchEvent(new Event('visibilitychange'))) - - expect(getSessionLastSeenAt('session-a')).toBe(200) + it('does not clear unread while browsing history or when no loaded receipt exists', async () => { + const view = viewport(false) + const { rerender } = renderHook(({ revision }: { revision?: number }) => useSelectedSessionSeen('s', revision, 0, view, content), { + initialProps: { revision: 3 as number | undefined }, + }) + await settle() + expect(getSessionLastSeenAt('s')).toBe(0) + rerender({ revision: undefined }) + view.current.scrollTop = 100 + act(() => view.current.dispatchEvent(new Event('scroll'))) + await settle() + expect(getSessionLastSeenAt('s')).toBe(0) + rerender({ revision: 3 }) + await settle() + expect(getSessionLastSeenAt('s')).toBe(3) }) - it('marks selected-session updates immediately while visible', () => { - setVisibilityState('visible') - renderHook(() => useSelectedSessionSeen('session-a', 300)) - - expect(getSessionLastSeenAt('session-a')).toBe(300) + it('read-state SSE alone cannot clear a manual unread choice', async () => { + const view = viewport() + const { rerender } = renderHook(({ version }) => useSelectedSessionSeen('s', 3, version, view, content), { + initialProps: { version: 0 }, + }) + await settle() + observeSessionReadStates([{ id: 's', readState: { readRevision: 3, version: 2, manualUnread: true } }]) + rerender({ version: 2 }) + act(() => view.current.dispatchEvent(new Event('scroll'))) + await settle() + expect(getSessionManualUnreadAt('s')).toBe(0) }) }) diff --git a/web/src/hooks/useSelectedSessionSeen.ts b/web/src/hooks/useSelectedSessionSeen.ts index 9e5fbce5..5a52acd3 100644 --- a/web/src/hooks/useSelectedSessionSeen.ts +++ b/web/src/hooks/useSelectedSessionSeen.ts @@ -1,19 +1,50 @@ -import { useEffect } from 'react' +import { useEffect, useRef, type RefObject } from 'react' import { markSessionSeen } from '@/lib/sessionLastSeen' import { useDocumentVisibility } from './useDocumentVisibility' -/** Mark the selected session read only while the document is visible. */ +/** Called by the rendered transcript, never by list selection or SSE metadata. */ export function useSelectedSessionSeen( - selectedSessionId: string | null, - updatedAt: number | undefined, + sessionId: string | null, + renderedRevision: number | undefined, + stateVersion: number | undefined, + viewportRef: RefObject, + contentRef: RefObject ): void { - const isDocumentVisible = useDocumentVisibility() - + const visible = useDocumentVisibility() + const version = useRef(stateVersion) + version.current = stateVersion useEffect(() => { - if (!isDocumentVisible || !selectedSessionId || updatedAt === undefined) { - return + if (!visible || !sessionId || renderedRevision === undefined) return + const viewport = viewportRef.current + const content = contentRef.current + if (!viewport || !content) return + let frame = 0 + let acknowledged = false + const check = () => { + cancelAnimationFrame(frame) + // The chat runtime publishes rows in an effect. Wait for its DOM + // and the subsequent layout/scroll correction before acknowledging. + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (acknowledged || viewport.clientHeight <= 0) return + if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > 2) return + acknowledged = true + markSessionSeen(sessionId, renderedRevision, version.current) + }) + }) } - - markSessionSeen(selectedSessionId, updatedAt) - }, [isDocumentVisible, selectedSessionId, updatedAt]) + const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(check) + const mutations = new MutationObserver(check) + resize?.observe(content) + mutations.observe(content, { subtree: true, childList: true, characterData: true }) + viewport.addEventListener('scroll', check, { passive: true }) + check() + return () => { + cancelAnimationFrame(frame) + resize?.disconnect() + mutations.disconnect() + viewport.removeEventListener('scroll', check) + } + // A read-state SSE update alone is not a new viewing action. + }, [visible, sessionId, renderedRevision, viewportRef, contentRef]) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1a647cd5..a0796de5 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -82,7 +82,7 @@ export default { 'sessions.toolbar.more': 'More session list actions', 'sessions.markAllRead.button': 'Mark all as read ({count})', 'sessions.markAllRead.title': 'Mark all as read?', - 'sessions.markAllRead.description': 'This will mark {count} unread sessions as read on this device.', + 'sessions.markAllRead.description': 'This will mark {count} unread sessions as read across your devices.', 'sessions.markAllRead.confirm': 'Confirm', 'sessions.markAllRead.confirming': 'Marking as read…', 'sessions.timeFilter.label': 'Filter sessions by last activity', diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts index 90c6c71d..50ee6ed3 100644 --- a/web/src/lib/locales/ru.ts +++ b/web/src/lib/locales/ru.ts @@ -82,7 +82,7 @@ export default { 'sessions.toolbar.more': 'Другие действия списка сессий', 'sessions.markAllRead.button': 'Отметить все как прочитанные ({count})', 'sessions.markAllRead.title': 'Отметить все как прочитанные?', - 'sessions.markAllRead.description': 'Будут отмечены как прочитанные {count} непрочитанных сессий на этом устройстве.', + 'sessions.markAllRead.description': 'Будут отмечены как прочитанные {count} непрочитанных сессий на всех ваших устройствах.', 'sessions.markAllRead.confirm': 'Подтвердить', 'sessions.markAllRead.confirming': 'Отмечаем как прочитанные…', 'sessions.timeFilter.label': 'Фильтр сессий по последней активности', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3383c328..578d1610 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -82,7 +82,7 @@ export default { 'sessions.toolbar.more': '更多会话列表操作', 'sessions.markAllRead.button': '全部标为已读({count})', 'sessions.markAllRead.title': '全部标为已读?', - 'sessions.markAllRead.description': '这会将当前设备上的 {count} 个未读会话标记为已读。', + 'sessions.markAllRead.description': '将 {count} 个未读会话在各设备上标为已读。', 'sessions.markAllRead.confirm': '确定', 'sessions.markAllRead.confirming': '标记中…', 'sessions.timeFilter.label': '按最后活动时间筛选会话', diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index aab572b3..dde0b823 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -26,6 +26,7 @@ export type OlderLoadOutcome = } export type MessageWindowState = { + readThroughRevision?: number sessionId: string messages: DecryptedMessage[] hasMore: boolean @@ -395,6 +396,7 @@ function getNewestCursor(state: InternalState): MessagePosition | null { function buildState( previous: InternalState, updates: Partial message.contentRevision ?? 0)), hasMore: response.page.hasMore || (!options.replaceServerRows && previous.hasMore) || dropped.length > 0, epoch: response.page.epoch, oldestPositionAt: oldest?.at ?? null, @@ -867,6 +871,8 @@ async function runTailSync(api: ApiClient, sessionId: string): Promise { ? (comparePosition(nextAfter, currentNewest) >= 0 ? nextAfter : currentNewest) : nextAfter ?? currentNewest return buildState(merged, { + readThroughRevision: Math.max(merged.readThroughRevision ?? 0, response.page.readThroughRevision ?? 0, + ...merged.messages.map(message => message.contentRevision ?? 0)), epoch: response.page.epoch, newestPositionAt: newest?.at ?? null, newestPositionSeq: newest?.seq ?? null, @@ -1186,6 +1192,8 @@ export function ingestIncomingMessages(sessionId: string, incoming: DecryptedMes ? incomingNewest : currentNewest merged = buildState(merged, { + readThroughRevision: merged.readThroughRevision === undefined ? undefined + : Math.max(merged.readThroughRevision, ...incoming.map(message => message.contentRevision ?? 0)), newestPositionAt: newest?.at ?? null, newestPositionSeq: newest?.seq ?? null }) @@ -1292,7 +1300,7 @@ export function seedMessageWindowFromSession(fromSessionId: string, toSessionId: const source = getState(fromSessionId) const target = getState(toSessionId) const seeded = buildState(createState(toSessionId), { - messages: [...source.messages], + messages: source.messages.map(message => ({ ...message, contentRevision: undefined })), hasMore: source.hasMore, tailRevision: source.tailRevision, oldestPositionAt: source.oldestPositionAt, diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index aa2c3527..9bceaddc 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -10,7 +10,8 @@ function makeSummary(overrides: Partial & { id: string }): Sessi active: true, thinking: false, activeAt: 0, - updatedAt: 1000, + updatedAt: 999_999, + contentRevision: 1000, metadata: null, metadataVersion: 0, agentStateVersion: 0, @@ -29,17 +30,17 @@ function makeSummary(overrides: Partial & { id: string }): Sessi } describe('classifySessionAttention', () => { - it('returns null for the selected session', () => { + it('keeps permission attention on the selected session', () => { const attention = classifySessionAttention( makeSummary({ id: 'a', pendingRequestKinds: ['permission'] }), { selected: true, lastSeenAt: 0 } ) - expect(attention).toBeNull() + expect(attention).toEqual({ kind: 'permission' }) }) it('shows an explicitly marked unread dot for the selected session', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', updatedAt: 5000 }), + makeSummary({ id: 'a', contentRevision: 5000 }), { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } ) expect(attention).toEqual({ kind: 'unread' }) @@ -47,23 +48,23 @@ describe('classifySessionAttention', () => { it('keeps an explicitly marked unread dot while the selected session is thinking', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'a', thinking: true, contentRevision: 5000 }), { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } ) expect(attention).toEqual({ kind: 'unread' }) }) - it('does not show selected-session attention for ordinary new activity', () => { + it('keeps unread content while the selected session is reading history', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', updatedAt: 5000 }), + makeSummary({ id: 'a', contentRevision: 5000 }), { selected: true, lastSeenAt: 1000 } ) - expect(attention).toBeNull() + expect(attention).toEqual({ kind: 'unread' }) }) it('carries an explicit unread dot across newer activity', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', updatedAt: 6000 }), + makeSummary({ id: 'a', contentRevision: 6000 }), { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } ) expect(attention).toEqual({ kind: 'unread' }) @@ -75,7 +76,7 @@ describe('classifySessionAttention', () => { id: 'a', pendingRequestKinds: ['permission'], pendingRequestsCount: 1, - updatedAt: 5000 + contentRevision: 5000 }), { selected: false, lastSeenAt: 0 } ) @@ -89,7 +90,7 @@ describe('classifySessionAttention', () => { active: false, pendingRequestKinds: ['permission'], pendingRequestsCount: 1, - updatedAt: 5000 + contentRevision: 5000 }), { selected: false, lastSeenAt: 5000 } ) @@ -97,7 +98,7 @@ describe('classifySessionAttention', () => { }) it('handles summaries from older APIs without pendingRequestKinds', () => { - const legacySummary = makeSummary({ id: 'legacy', updatedAt: 5000 }) as unknown as SessionSummary + const legacySummary = makeSummary({ id: 'legacy', contentRevision: 5000 }) as unknown as SessionSummary delete (legacySummary as Partial).pendingRequestKinds const attention = classifySessionAttention( @@ -110,7 +111,7 @@ describe('classifySessionAttention', () => { it('shows unread activity when the session has updated since last seen', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', updatedAt: 5000 }), + makeSummary({ id: 'a', contentRevision: 5000 }), { selected: false, lastSeenAt: 1000 } ) expect(attention).toEqual({ kind: 'unread' }) @@ -118,28 +119,28 @@ describe('classifySessionAttention', () => { it('does not show ordinary unread activity while the session is thinking', () => { expect(classifySessionAttention( - makeSummary({ id: 'generating', thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'generating', thinking: true, contentRevision: 5000 }), { selected: false, lastSeenAt: 1000 } )).toBeNull() }) it('keeps an explicitly marked unread dot while a session is thinking', () => { expect(classifySessionAttention( - makeSummary({ id: 'manual-thinking', thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'manual-thinking', thinking: true, contentRevision: 5000 }), { selected: false, lastSeenAt: 5000, manualUnreadAt: 5000 } )).toEqual({ kind: 'unread' }) }) it('treats inactive stale thinking as unread activity', () => { expect(classifySessionAttention( - makeSummary({ id: 'expired-thinking', active: false, thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'expired-thinking', active: false, thinking: true, contentRevision: 5000 }), { selected: false, lastSeenAt: 1000 } )).toEqual({ kind: 'unread' }) }) it('shows background work without treating it as unread', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', backgroundTaskCount: 2, updatedAt: 5000 }), + makeSummary({ id: 'a', backgroundTaskCount: 2, contentRevision: 5000 }), { selected: false, lastSeenAt: 0 } ) expect(attention).toEqual({ kind: 'background' }) @@ -147,7 +148,7 @@ describe('classifySessionAttention', () => { it('shows unread activity for inactive sessions updated since last seen', () => { const attention = classifySessionAttention( - makeSummary({ id: 'a', active: false, updatedAt: 5000 }), + makeSummary({ id: 'a', active: false, contentRevision: 5000 }), { selected: false, lastSeenAt: 1000 } ) expect(attention).toEqual({ kind: 'unread' }) @@ -159,7 +160,7 @@ describe('classifySessionAttention', () => { id: 'a', active: false, backgroundTaskCount: 2, - updatedAt: 5000 + contentRevision: 5000 }), { selected: false, lastSeenAt: 1000 } ) @@ -168,30 +169,30 @@ describe('classifySessionAttention', () => { }) describe('sessionIsUnread', () => { - it('is true when updatedAt is newer than lastSeenAt', () => { + it('is true when contentRevision is newer than the read revision', () => { expect(sessionIsUnread( - makeSummary({ id: 'u', updatedAt: 5000 }), + makeSummary({ id: 'u', contentRevision: 5000 }), { lastSeenAt: 1000 } )).toBe(true) }) it('is false when the operator has already seen this update', () => { expect(sessionIsUnread( - makeSummary({ id: 'seen', updatedAt: 1000 }), + makeSummary({ id: 'seen', contentRevision: 1000 }), { lastSeenAt: 5000 } )).toBe(false) }) it('is false for activity received while the session is thinking', () => { expect(sessionIsUnread( - makeSummary({ id: 'generating', thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'generating', thinking: true, contentRevision: 5000 }), { lastSeenAt: 1000 } )).toBe(false) }) it('allows unread activity from an inactive session with a stale thinking flag', () => { expect(sessionIsUnread( - makeSummary({ id: 'expired-thinking', active: false, thinking: true, updatedAt: 5000 }), + makeSummary({ id: 'expired-thinking', active: false, thinking: true, contentRevision: 5000 }), { lastSeenAt: 1000 } )).toBe(true) }) @@ -202,7 +203,7 @@ describe('sessionIsUnread', () => { id: 'p', pendingRequestKinds: ['permission'], pendingRequestsCount: 1, - updatedAt: 1000, + contentRevision: 1000, }), { lastSeenAt: 1000 } )).toBe(false) @@ -211,7 +212,7 @@ describe('sessionIsUnread', () => { makeSummary({ id: 'bg', backgroundTaskCount: 3, - updatedAt: 9000, + contentRevision: 9000, }), { lastSeenAt: 1000 } )).toBe(true) diff --git a/web/src/lib/sessionAttention.ts b/web/src/lib/sessionAttention.ts index f9e63ab0..25922e0c 100644 --- a/web/src/lib/sessionAttention.ts +++ b/web/src/lib/sessionAttention.ts @@ -15,24 +15,17 @@ export function sessionIsUnread( summary: SessionSummary, options: { lastSeenAt: number; manualUnreadAt?: number | null } ): boolean { - return hasManualUnreadMarker(options.manualUnreadAt) - || (!(summary.active && summary.thinking) && summary.updatedAt > options.lastSeenAt) + const manualUnread = options.manualUnreadAt === undefined + ? summary.readState?.manualUnread === true + : hasManualUnreadMarker(options.manualUnreadAt) + return manualUnread || (!(summary.active && summary.thinking) + && (summary.contentRevision ?? 0) > Math.max(options.lastSeenAt, summary.readState?.readRevision ?? 0)) } export function classifySessionAttention( summary: SessionSummary, options: { selected: boolean; lastSeenAt: number; manualUnreadAt?: number | null } ): SessionAttention | null { - if (options.selected) { - return hasManualUnreadMarker(options.manualUnreadAt) - ? { kind: 'unread' } - : null - } - - if (summary.active && summary.thinking && !hasManualUnreadMarker(options.manualUnreadAt)) { - return null - } - // Requests left on an archived session can no longer be answered. const pendingRequestKinds = summary.active && Array.isArray(summary.pendingRequestKinds) ? summary.pendingRequestKinds @@ -46,6 +39,10 @@ export function classifySessionAttention( return { kind: 'input' } } + if (options.manualUnreadAt === undefined ? summary.readState?.manualUnread : hasManualUnreadMarker(options.manualUnreadAt)) { + return { kind: 'unread' } + } + if (summary.active && (summary.backgroundTaskCount ?? 0) > 0) { return { kind: 'background' } } diff --git a/web/src/lib/sessionLastSeen.test.ts b/web/src/lib/sessionLastSeen.test.ts index 38e52629..281683ac 100644 --- a/web/src/lib/sessionLastSeen.test.ts +++ b/web/src/lib/sessionLastSeen.test.ts @@ -1,243 +1,95 @@ -import { createElement } from 'react' -import { act, render } from '@testing-library/react' -import { describe, expect, it, beforeEach, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - getSessionLastSeenAt, - getSessionLastSeenSnapshot, - getSessionManualUnreadAt, - getUnreadSessionCount, - initializeSessionLastSeen, - markAllSessionsSeen, - markSessionUnread, - markSessionSeen, - useSessionLastSeenVersion, + configureSessionReadState, flushSessionReadState, getSessionLastSeenAt, + getSessionManualUnreadAt, getUnreadSessionCount, markAllSessionsSeen, + markSessionSeen, markSessionUnread, observeSessionReadStates, } from './sessionLastSeen' +import type { SessionReadState, SessionReadStateRequest } from '@hapi/protocol' -function SessionLastSeenVersionProbe() { - const version = useSessionLastSeenVersion() - return createElement('output', { 'data-testid': 'last-seen-version' }, version) -} +const server = (readRevision = 0, version = 0, manualUnread = false): SessionReadState => ({ readRevision, version, manualUnread }) +const dormant = () => Promise.reject(new Error('offline')) +let cleanup = () => {} +function offlineScope(scope: string) { cleanup(); cleanup = configureSessionReadState(scope, dormant); cleanup() } -describe('sessionLastSeen', () => { - beforeEach(() => { - localStorage.clear() +beforeEach(() => { + vi.useFakeTimers() + localStorage.clear() + offlineScope('hub-a:alice') +}) +afterEach(async () => { cleanup(); await vi.advanceTimersByTimeAsync(0); vi.useRealTimers(); vi.restoreAllMocks() }) + +describe('durable session read state', () => { + it('ignores legacy timestamps and metadata-only changes', () => { + localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ s: Date.now() })) + expect(getSessionLastSeenAt('s')).toBe(0) + expect(getUnreadSessionCount([{ id: 's', contentRevision: 0 }])).toBe(0) + expect(getUnreadSessionCount([{ id: 's', contentRevision: 1 }])).toBe(1) }) - it('stores the latest seen timestamp for a session', () => { - markSessionSeen('session-a', 1000) - markSessionSeen('session-a', 2500) - expect(getSessionLastSeenAt('session-a')).toBe(2500) + it('persists the exact observed revision monotonically and scopes by hub and namespace', () => { + markSessionSeen('s', 3) + markSessionSeen('s', 1) + expect(getSessionLastSeenAt('s')).toBe(3) + offlineScope('hub-a:bob') + expect(getSessionLastSeenAt('s')).toBe(0) + offlineScope('hub-b:alice') + expect(getSessionLastSeenAt('s')).toBe(0) + offlineScope('hub-a:alice') + expect(getSessionLastSeenAt('s')).toBe(3) + expect(getUnreadSessionCount([{ id: 's', contentRevision: 4 }])).toBe(1) }) - it('snapshots the store once for bulk unread filtering', () => { - markSessionSeen('session-a', 1000) - markSessionSeen('session-b', 2500) - expect(getSessionLastSeenSnapshot()).toEqual({ - 'session-a': 1000, - 'session-b': 2500, + it('merges reads from other clients without allowing a stale response to erase manual unread', () => { + observeSessionReadStates([{ id: 's', readState: server(3, 2, true) }]) + observeSessionReadStates([{ id: 's', readState: server(2, 1) }]) + markSessionSeen('s', 3, 1) + expect(getSessionManualUnreadAt('s')).toBe(0) + markSessionSeen('s', 3, 2) + expect(getSessionManualUnreadAt('s')).toBeNull() + expect(getUnreadSessionCount([{ id: 's', contentRevision: 3, readState: server(3, 3, true) }])).toBe(1) + }) + + it('suppresses running content without discarding it or manual unread', () => { + const row = { id: 's', contentRevision: 2, active: true, thinking: true } + expect(getUnreadSessionCount([row])).toBe(0) + expect(getUnreadSessionCount([{ ...row, active: false }])).toBe(1) + markSessionUnread('s') + expect(getUnreadSessionCount([row])).toBe(1) + expect(markAllSessionsSeen([row])).toBe(1) + expect(getSessionLastSeenAt('s')).toBe(2) + }) + + it('does not claim a read when durable storage fails', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') }) + markSessionSeen('s', 3) + expect(getSessionLastSeenAt('s')).toBe(0) + }) + + it('retries the persisted operation and preserves a newer operation during acknowledgement', async () => { + markSessionSeen('s', 3) + let resolve!: (value: { readState: SessionReadState }) => void + const sent: SessionReadStateRequest[] = [] + cleanup = configureSessionReadState('hub-a:alice', async (_id, request) => { + sent.push({ ...request }) + if (sent.length === 1) return new Promise(r => { resolve = r }) + return { readState: server(request.type === 'read' ? request.readRevision : 0, 2) } }) + markSessionSeen('s', 4) + resolve({ readState: server(3, 1) }) + await vi.advanceTimersByTimeAsync(0) + await flushSessionReadState() + expect(sent.map(r => r.type === 'read' ? r.readRevision : null)).toEqual([3, 4]) + expect(getSessionLastSeenAt('s')).toBe(4) + expect(JSON.parse(localStorage.getItem('hapi.sessionReadState.v1:hub-a:alice')!).s.pending).toBeUndefined() }) - it('counts and marks all unread sessions in one local batch', () => { - markSessionSeen('read', 1000) - markSessionSeen('newer', 1000) - markSessionUnread('manual', 3000) - - const sessions = [ - { id: 'read', updatedAt: 1000 }, - { id: 'newer', updatedAt: 2000 }, - { id: 'manual', updatedAt: 3000 }, - { id: 'newer', updatedAt: 1500 }, - ] - - expect(getUnreadSessionCount(sessions)).toBe(2) - expect(markAllSessionsSeen(sessions)).toBe(2) - expect(getUnreadSessionCount(sessions)).toBe(0) - expect(getSessionLastSeenAt('newer')).toBe(2000) - expect(getSessionLastSeenAt('manual')).toBe(3000) - expect(getSessionManualUnreadAt('manual')).toBeNull() - }) - - it('does not count a thinking session until it completes', () => { - markSessionSeen('session-a', 1000) - - const thinking = [{ id: 'session-a', active: true, updatedAt: 2000, thinking: true }] - expect(getUnreadSessionCount(thinking)).toBe(0) - expect(markAllSessionsSeen(thinking)).toBe(0) - expect(getSessionLastSeenAt('session-a')).toBe(1000) - - const completed = [{ id: 'session-a', updatedAt: 2000, thinking: false }] - expect(getUnreadSessionCount(completed)).toBe(1) - expect(markAllSessionsSeen(completed)).toBe(1) - expect(getSessionLastSeenAt('session-a')).toBe(2000) - }) - - it('counts an inactive session even when thinking is stale after expiry', () => { - markSessionSeen('session-a', 1000) - - const expired = [{ id: 'session-a', active: false, updatedAt: 2000, thinking: true }] - expect(getUnreadSessionCount(expired)).toBe(1) - expect(markAllSessionsSeen(expired)).toBe(1) - expect(getSessionLastSeenAt('session-a')).toBe(2000) - }) - - it('preserves a manual unread marker across newer generating activity', () => { - markSessionUnread('session-a', 2000) - - const thinking = [{ id: 'session-a', active: true, updatedAt: 3000, thinking: true }] - expect(getUnreadSessionCount(thinking)).toBe(1) - expect(markAllSessionsSeen(thinking)).toBe(1) - expect(getSessionManualUnreadAt('session-a')).toBeNull() - expect(getSessionLastSeenAt('session-a')).toBe(3000) - }) - - it('notifies consumers once for a batch mark-as-read operation', () => { - markSessionUnread('session-a', 1000) - markSessionUnread('session-b', 2000) - const view = render(createElement(SessionLastSeenVersionProbe)) - const initialVersion = Number(view.getByTestId('last-seen-version').textContent) - - act(() => { - markAllSessionsSeen([ - { id: 'session-a', updatedAt: 1000 }, - { id: 'session-b', updatedAt: 2000 }, - ]) + it('rolls back a rejected operation to authoritative state', async () => { + markSessionSeen('s', 9) + cleanup = configureSessionReadState('hub-a:alice', async () => { + throw { status: 409, body: JSON.stringify({ readState: server(2, 3, true) }) } }) - - expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 1)) - }) - - it('does not write or report a change when all sessions are already read', () => { - markSessionSeen('session-a', 1000) - - expect(markAllSessionsSeen([{ id: 'session-a', updatedAt: 1000 }])).toBe(0) - expect(getSessionLastSeenSnapshot()).toEqual({ 'session-a': 1000 }) - }) - - it('does not move the watermark backwards', () => { - markSessionSeen('session-a', 5000) - markSessionSeen('session-a', 2000) - expect(getSessionLastSeenAt('session-a')).toBe(5000) - }) - - it('moves the watermark behind the current activity when marking unread', () => { - markSessionSeen('session-a', 5000) - - markSessionUnread('session-a', 5000) - - expect(getSessionLastSeenAt('session-a')).toBe(4999) - expect(getSessionManualUnreadAt('session-a')).toBe(5000) - }) - - it('does not change an already-unread watermark when marking unread', () => { - markSessionSeen('session-a', 1000) - - markSessionUnread('session-a', 5000) - - expect(getSessionLastSeenAt('session-a')).toBe(1000) - expect(getSessionManualUnreadAt('session-a')).toBe(5000) - }) - - it('clears the explicit unread marker when the session is seen', () => { - markSessionUnread('session-a', 5000) - expect(getSessionManualUnreadAt('session-a')).toBe(5000) - - markSessionSeen('session-a', 5000) - - expect(getSessionManualUnreadAt('session-a')).toBeNull() - }) - - it('preserves an explicit unread marker when a stale seen timestamp arrives', () => { - markSessionUnread('session-a', 5000) - - markSessionSeen('session-a', 4000) - - expect(getSessionLastSeenAt('session-a')).toBe(4999) - expect(getSessionManualUnreadAt('session-a')).toBe(5000) - }) - - it('notifies same-tab consumers when the watermark changes', () => { - const view = render(createElement(SessionLastSeenVersionProbe)) - const initialVersion = Number(view.getByTestId('last-seen-version').textContent) - - act(() => { - markSessionUnread('session-a', 5000) - }) - - expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 1)) - }) - - it('notifies consumers when either read-state key changes in another tab', () => { - const view = render(createElement(SessionLastSeenVersionProbe)) - const initialVersion = Number(view.getByTestId('last-seen-version').textContent) - - act(() => { - window.dispatchEvent(new StorageEvent('storage', { key: 'hapi.sessionLastSeen.v1' })) - }) - expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 1)) - - act(() => { - window.dispatchEvent(new StorageEvent('storage', { key: 'hapi.sessionManualUnread.v1' })) - }) - expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 2)) - }) - - it('uses the first session list as the unread baseline', () => { - initializeSessionLastSeen('hub-a', [ - { id: 'session-a', updatedAt: 1000 }, - { id: 'session-b', updatedAt: 2500 }, - ]) - - expect(getSessionLastSeenAt('session-a')).toBe(1000) - expect(getSessionLastSeenAt('session-b')).toBe(2500) - }) - - it('preserves existing watermarks while completing a legacy partial baseline', () => { - markSessionSeen('session-a', 1000) - - initializeSessionLastSeen('hub-a', [ - { id: 'session-a', updatedAt: 2500 }, - { id: 'session-b', updatedAt: 2500 }, - ]) - - expect(getSessionLastSeenAt('session-a')).toBe(1000) - expect(getSessionLastSeenAt('session-b')).toBe(2500) - - initializeSessionLastSeen('hub-a', [{ id: 'session-c', updatedAt: 3000 }]) - expect(getSessionLastSeenAt('session-c')).toBe(0) - }) - - it('initializes each hub independently', () => { - initializeSessionLastSeen('hub-a', [{ id: 'session-a', updatedAt: 1000 }]) - initializeSessionLastSeen('hub-b', [{ id: 'session-b', updatedAt: 2000 }]) - - expect(getSessionLastSeenAt('session-b')).toBe(2000) - }) - - it('ignores localStorage write failures', () => { - const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('quota exceeded') - }) - - expect(() => markSessionSeen('session-a', 1000)).not.toThrow() - - setItem.mockRestore() - }) - - it('returns zero when localStorage getter throws', () => { - const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage') - Object.defineProperty(window, 'localStorage', { - configurable: true, - get() { - throw new Error('storage denied') - }, - }) - - expect(getSessionLastSeenAt('session-a')).toBe(0) - expect(() => markSessionSeen('session-a', 1000)).not.toThrow() - - if (localStorageDescriptor) { - Object.defineProperty(window, 'localStorage', localStorageDescriptor) - } + await vi.advanceTimersByTimeAsync(0) + expect(getSessionLastSeenAt('s')).toBe(2) + expect(getSessionManualUnreadAt('s')).toBe(0) }) }) diff --git a/web/src/lib/sessionLastSeen.ts b/web/src/lib/sessionLastSeen.ts index 102cb0ef..b6670019 100644 --- a/web/src/lib/sessionLastSeen.ts +++ b/web/src/lib/sessionLastSeen.ts @@ -1,336 +1,191 @@ import { useSyncExternalStore } from 'react' +import { EMPTY_READ_STATE, mergeReadState, type SessionReadState, type SessionReadStateRequest } from '@hapi/protocol' -const STORAGE_KEY = 'hapi.sessionLastSeen.v1' -const MANUAL_UNREAD_KEY = 'hapi.sessionManualUnread.v1' -const BASELINE_KEY = 'hapi.sessionLastSeenBaseline.v1' -const CHANGE_EVENT = 'hapi.sessionLastSeen.changed' - +type SessionInput = { id: string; contentRevision?: number; readState?: SessionReadState; active?: boolean; thinking?: boolean } +type Pending = { id: string; request: SessionReadStateRequest } +type Entry = { server: SessionReadState; pending?: Pending } +type Store = Record +type Sender = (id: string, request: SessionReadStateRequest) => Promise<{ readState: SessionReadState }> +const CHANGE_EVENT = 'hapi.sessionReadState.changed' +const PREFIX = 'hapi.sessionReadState.v1:' +let scope = 'unconfigured' +let sender: Sender | null = null let changeVersion = 0 +let running = false +let retry: ReturnType | undefined -type LastSeenStore = Record -type ManualUnreadStore = Record - -function getLocalStorage(): Storage | null { - if (typeof window === 'undefined') { - return null - } - try { - return window.localStorage - } catch { - return null - } +function readStore(key = scope): Store { + try { return JSON.parse(localStorage.getItem(PREFIX + key) ?? '{}') as Store } catch { return {} } } - -function readStore(): LastSeenStore { - const storage = getLocalStorage() - if (!storage) { - return {} - } - - try { - const raw = storage.getItem(STORAGE_KEY) - if (!raw) { - return {} - } - const parsed: unknown = JSON.parse(raw) - if (!parsed || typeof parsed !== 'object') { - return {} - } - return parsed as LastSeenStore - } catch { - return {} - } +function save(store: Store, key = scope): boolean { + try { localStorage.setItem(PREFIX + key, JSON.stringify(store)); return true } catch { return false } } - -function readManualUnreadStore(): ManualUnreadStore { - const storage = getLocalStorage() - if (!storage) { - return {} - } - - try { - const raw = storage.getItem(MANUAL_UNREAD_KEY) - if (!raw) { - return {} - } - const parsed: unknown = JSON.parse(raw) - if (!parsed || typeof parsed !== 'object') { - return {} - } - return parsed as ManualUnreadStore - } catch { - return {} - } -} - -function writeStore(store: LastSeenStore): boolean { - const storage = getLocalStorage() - if (!storage) { - return false - } - try { - storage.setItem(STORAGE_KEY, JSON.stringify(store)) - return true - } catch { - // Ignore storage errors - return false - } -} - -function writeManualUnreadStore(store: ManualUnreadStore): boolean { - const storage = getLocalStorage() - if (!storage) { - return false - } - try { - storage.setItem(MANUAL_UNREAD_KEY, JSON.stringify(store)) - return true - } catch { - // Ignore storage errors - return false - } -} - -function notifyStoreChanged(): void { +function notify(): void { changeVersion += 1 - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event(CHANGE_EVENT)) + if (typeof window !== 'undefined') window.dispatchEvent(new Event(CHANGE_EVENT)) +} +function effective(entry?: Entry, incoming?: SessionReadState): SessionReadState { + const pending = entry?.pending?.request + const state = mergeReadState(entry?.server, incoming) ?? EMPTY_READ_STATE + if (!pending) return state + return { + ...state, + readRevision: pending.type === 'read' ? Math.max(state.readRevision, pending.readRevision) : state.readRevision, + manualUnread: pending.expectedVersion === state.version ? pending.type === 'unread' : state.manualUnread } } -function subscribeToStoreChanges(listener: () => void): () => void { - if (typeof window === 'undefined') { - return () => {} - } - - const handleStorage = (event: StorageEvent) => { - if (event.key !== STORAGE_KEY && event.key !== MANUAL_UNREAD_KEY) { - return - } - changeVersion += 1 - listener() - } - - window.addEventListener(CHANGE_EVENT, listener) - window.addEventListener('storage', handleStorage) +/** Scope includes the authenticated namespace; never re-use another hub's outbox. */ +export function configureSessionReadState(key: string, send: Sender): () => void { + scope = key + sender = send + notify() + void flushSessionReadState() + const retryNow = () => { void flushSessionReadState() } + window.addEventListener('online', retryNow) + window.addEventListener('focus', retryNow) return () => { - window.removeEventListener(CHANGE_EVENT, listener) - window.removeEventListener('storage', handleStorage) + window.removeEventListener('online', retryNow) + window.removeEventListener('focus', retryNow) + if (sender === send) { sender = null; clearTimeout(retry) } } } -function getStoreChangeVersion(): number { - return changeVersion -} - -/** Re-render consumers when same-tab read-state changes. */ -export function useSessionLastSeenVersion(): number { - return useSyncExternalStore( - subscribeToStoreChanges, - getStoreChangeVersion, - () => 0 - ) -} - -export function getSessionLastSeenAt(sessionId: string): number { - return readStore()[sessionId] ?? 0 -} - -/** Timestamp of the activity the operator explicitly marked unread, if any. */ -export function getSessionManualUnreadAt(sessionId: string): number | null { - const value = readManualUnreadStore()[sessionId] - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -/** One localStorage read/parse for bulk consumers such as the PWA badge. */ -export function getSessionManualUnreadSnapshot(): Readonly> { - return readManualUnreadStore() -} - -/** One localStorage read/parse for bulk filters (e.g. unread-only lens). */ -export function getSessionLastSeenSnapshot(): Readonly> { - return readStore() -} - -type SessionReadStateInput = { - id: string - updatedAt: number - active?: boolean - thinking?: boolean -} - -function isGenerating(session: SessionReadStateInput): boolean { - return session.active !== false && session.thinking === true -} - -function latestSessionUpdates(sessions: Iterable): Map { - const latest = new Map() - for (const session of sessions) { - if (!session.id || !Number.isFinite(session.updatedAt)) { - continue +/** Durable operations survive refresh/offline. A stale request is never rebased + * onto a newer manual choice; only a new viewing action can clear that choice. */ +export async function flushSessionReadState(): Promise { + if (running || !sender) return + running = true + const key = scope + const send = sender + let failed = false + try { + for (const [id, entry] of Object.entries(readStore(key))) { + const pending = entry.pending + if (!pending || sender !== send || scope !== key) continue + try { + const response = await send(id, pending.request) + const store = readStore(key) + const current = store[id] ?? entry + current.server = mergeReadState(current.server, response.readState)! + if (current.pending?.id === pending.id) delete current.pending + else if (current.pending?.request.expectedVersion === pending.request.expectedVersion + && response.readState.version <= pending.request.expectedVersion + 1 + && response.readState.manualUnread === (pending.request.type === 'unread')) { + // A newer local intent follows our own acknowledged write. + // Do not rebase across another client's manual-unread change. + current.pending.request.expectedVersion = response.readState.version + } + store[id] = current + if (!save(store, key)) failed = true + if (scope === key) notify() + } catch (error) { + const failure = error as { status?: number; body?: string } + if ([400, 403, 404, 409].includes(failure.status ?? 0)) { + const store = readStore(key) + const current = store[id] + if (current?.pending?.id === pending.id) { + // 409 includes the authoritative manual choice. + try { + const body = JSON.parse(failure.body ?? '{}') as { readState?: SessionReadState } + current.server = mergeReadState(current.server, body.readState)! + } catch { /* a removed/replaced session has no read state */ } + delete current.pending + if (!save(store, key)) failed = true + if (scope === key) notify() + } + } else { failed = true; break } + } } - const current = latest.get(session.id) - if ( - current === undefined - || session.updatedAt > current.updatedAt - || (session.updatedAt === current.updatedAt && isGenerating(current) && !isGenerating(session)) - ) { - latest.set(session.id, session) + } finally { + running = false + clearTimeout(retry) + if (Object.values(readStore()).some(entry => entry.pending)) { + retry = setTimeout(() => { void flushSessionReadState() }, failed ? 5_000 : 0) } } - return latest } -function hasUnreadActivity( - session: SessionReadStateInput, - store: LastSeenStore, - manualUnreadStore: ManualUnreadStore -): boolean { - const lastSeenAt = store[session.id] - const manualUnreadAt = manualUnreadStore[session.id] - return typeof manualUnreadAt === 'number' && Number.isFinite(manualUnreadAt) - || (!isGenerating(session) - && session.updatedAt > (typeof lastSeenAt === 'number' && Number.isFinite(lastSeenAt) ? lastSeenAt : 0)) -} - -/** Count unread sessions in the supplied list using one localStorage snapshot. */ -export function getUnreadSessionCount(sessions: Iterable): number { - const latest = latestSessionUpdates(sessions) +export function observeSessionReadStates(sessions: Iterable): void { const store = readStore() - const manualUnreadStore = readManualUnreadStore() + let changed = false + for (const session of sessions) { + if (!session.readState) continue + const current = store[session.id] + const server = mergeReadState(current?.server, session.readState)! + if (!current || JSON.stringify(current.server) !== JSON.stringify(server)) { + store[session.id] = { ...current, server } + changed = true + } + } + if (changed && save(store)) notify() +} + +function enqueue(sessionId: string, request: SessionReadStateRequest): void { + if (!sessionId) return + const store = readStore() + const entry = store[sessionId] ?? { server: EMPTY_READ_STATE } + const previous = entry.pending?.request + if (request.type === 'read' && previous?.type === 'read') { + request = { ...request, readRevision: Math.max(request.readRevision, previous.readRevision) } + } + store[sessionId] = { ...entry, pending: { id: crypto.randomUUID(), request } } + // Persistence failure must not claim a successful local read. + if (save(store)) { notify(); void flushSessionReadState() } +} + +export function getSessionLastSeenAt(sessionId: string): number { return effective(readStore()[sessionId]).readRevision } +export function getSessionManualUnreadAt(sessionId: string, serverState?: SessionReadState): number | null { + return effective(readStore()[sessionId], serverState).manualUnread ? 0 : null +} +export function getSessionLastSeenSnapshot(): Readonly> { + return Object.fromEntries(Object.entries(readStore()).map(([id, entry]) => [id, effective(entry).readRevision])) +} +export function getSessionManualUnreadSnapshot(sessions: readonly SessionInput[] = []): Readonly> { + const store = readStore() + return Object.fromEntries(sessions.map(session => [session.id, effective(store[session.id], session.readState).manualUnread ? 0 : null])) +} +export function markSessionSeen(sessionId: string, readRevision: number, observedVersion?: number): void { + if (!Number.isSafeInteger(readRevision) || readRevision < 0) return + const entry = readStore()[sessionId] + const current = effective(entry) + if (current.readRevision >= readRevision && !current.manualUnread) return + enqueue(sessionId, { type: 'read', readRevision, expectedVersion: observedVersion ?? entry?.server.version ?? 0 }) +} +export function markSessionUnread(sessionId: string, observedState?: SessionReadState): void { + if (observedState) observeSessionReadStates([{ id: sessionId, readState: observedState }]) + enqueue(sessionId, { type: 'unread', expectedVersion: readStore()[sessionId]?.server.version ?? 0 }) +} +export function markAllSessionsSeen(sessions: Iterable): number { + const list = [...sessions] + observeSessionReadStates(list) let count = 0 - for (const session of latest.values()) { - if (hasUnreadActivity(session, store, manualUnreadStore)) { + for (const session of list) { + const state = effective(readStore()[session.id], session.readState) + if (state.manualUnread || (!(session.active && session.thinking) && (session.contentRevision ?? 0) > state.readRevision)) { + markSessionSeen(session.id, session.contentRevision ?? 0) count += 1 } } return count } - -/** Mark all supplied unread sessions as seen on this device. Returns changed count. */ -export function markAllSessionsSeen(sessions: Iterable): number { - const latest = latestSessionUpdates(sessions) - if (latest.size === 0) { - return 0 - } - +export function getUnreadSessionCount(sessions: Iterable): number { const store = readStore() - const manualUnreadStore = readManualUnreadStore() - let count = 0 - let seenChanged = false - let manualUnreadChanged = false - - for (const session of latest.values()) { - if (!hasUnreadActivity(session, store, manualUnreadStore)) { - continue - } - - count += 1 - const currentSeenAt = store[session.id] - const nextSeenAt = Math.max( - typeof currentSeenAt === 'number' && Number.isFinite(currentSeenAt) ? currentSeenAt : 0, - session.updatedAt - ) - if (store[session.id] !== nextSeenAt) { - store[session.id] = nextSeenAt - seenChanged = true - } - if (Object.prototype.hasOwnProperty.call(manualUnreadStore, session.id)) { - delete manualUnreadStore[session.id] - manualUnreadChanged = true - } - } - - if (count === 0) { - return 0 - } - - const seenWritten = !seenChanged || writeStore(store) - const manualUnreadWritten = !manualUnreadChanged || writeManualUnreadStore(manualUnreadStore) - if (seenWritten || manualUnreadWritten) { - notifyStoreChanged() - } - return count + const unique = new Map([...sessions].map(session => [session.id, session])) + return [...unique.values()].filter(session => { + const state = effective(store[session.id], session.readState) + return state.manualUnread || (!(session.active && session.thinking) + && (session.contentRevision ?? 0) > Math.max(state.readRevision, session.readState?.readRevision ?? 0)) + }).length } - -export function initializeSessionLastSeen(scope: string, sessions: Iterable<{ id: string; updatedAt: number }>): void { - const storage = getLocalStorage() - if (!storage) { - return - } - - try { - const baselineKey = `${BASELINE_KEY}:${scope}` - if (storage.getItem(baselineKey) === '1') { - return - } - const store = readStore() - for (const session of sessions) { - store[session.id] ??= session.updatedAt - } - storage.setItem(STORAGE_KEY, JSON.stringify(store)) - storage.setItem(baselineKey, '1') - } catch { - // Ignore storage errors +function subscribe(listener: () => void): () => void { + const storage = (event: StorageEvent) => { + if (event.key === PREFIX + scope || event.key === null) { changeVersion += 1; listener(); void flushSessionReadState() } } + window.addEventListener(CHANGE_EVENT, listener) + window.addEventListener('storage', storage) + return () => { window.removeEventListener(CHANGE_EVENT, listener); window.removeEventListener('storage', storage) } } - -export function markSessionSeen(sessionId: string, seenAt: number): void { - if (!sessionId) { - return - } - const store = readStore() - const manualUnreadStore = readManualUnreadStore() - const nextSeenAt = Math.max(store[sessionId] ?? 0, seenAt) - const seenChanged = store[sessionId] !== nextSeenAt - const manualUnreadAt = manualUnreadStore[sessionId] - const manualUnreadChanged = typeof manualUnreadAt === 'number' - && Number.isFinite(manualUnreadAt) - && nextSeenAt >= manualUnreadAt - if (!seenChanged && !manualUnreadChanged) { - return - } - - if (seenChanged) { - store[sessionId] = nextSeenAt - } - if (manualUnreadChanged) { - delete manualUnreadStore[sessionId] - } - - const seenWritten = !seenChanged || writeStore(store) - const manualUnreadWritten = !manualUnreadChanged || writeManualUnreadStore(manualUnreadStore) - if (seenWritten || manualUnreadWritten) { - notifyStoreChanged() - } -} - -/** Move the local watermark just behind the current activity and remember the explicit action. */ -export function markSessionUnread(sessionId: string, updatedAt: number): void { - if (!sessionId || !Number.isFinite(updatedAt)) { - return - } - - const store = readStore() - const manualUnreadStore = readManualUnreadStore() - const unreadBefore = updatedAt - 1 - const currentSeenAt = store[sessionId] - const seenChanged = !(typeof currentSeenAt === 'number' && currentSeenAt <= unreadBefore) - const manualUnreadChanged = manualUnreadStore[sessionId] !== updatedAt - if (!seenChanged && !manualUnreadChanged) { - return - } - - if (seenChanged) { - store[sessionId] = unreadBefore - } - if (manualUnreadChanged) { - manualUnreadStore[sessionId] = updatedAt - } - - const seenWritten = !seenChanged || writeStore(store) - const manualUnreadWritten = !manualUnreadChanged || writeManualUnreadStore(manualUnreadStore) - if (seenWritten || manualUnreadWritten) { - notifyStoreChanged() - } +export function useSessionLastSeenVersion(): number { + return useSyncExternalStore(subscribe, () => changeVersion, () => 0) } diff --git a/web/src/lib/sessionPatch.ts b/web/src/lib/sessionPatch.ts index 518616c8..e0712a91 100644 --- a/web/src/lib/sessionPatch.ts +++ b/web/src/lib/sessionPatch.ts @@ -1,3 +1,4 @@ +import { mergeReadState } from '@hapi/protocol' import type { Session, SessionPatch } from '@/types/api' // Pure session-patch application logic, extracted from useSSE.ts so the rules @@ -62,6 +63,8 @@ export function applySessionDetailPatch(session: Session, patch: SessionPatch): changed = true } } + if (patch.contentRevision !== undefined) assign('contentRevision', Math.max(session.contentRevision ?? 0, patch.contentRevision)) + if (patch.readState) assign('readState', mergeReadState(session.readState, patch.readState)) if (patch.active !== undefined) assign('active', patch.active) if (patch.thinking !== undefined) assign('thinking', patch.thinking) if (patch.activeAt !== undefined) assign('activeAt', patch.activeAt) diff --git a/web/src/router.tsx b/web/src/router.tsx index 275ebfee..8be8001e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -57,8 +57,7 @@ import { getDraftAttachments } from '@/lib/composer-attachment-drafts' import { refreshSessionDetailPreservingActive } from '@/lib/session-detail-optimistic' import { inactiveSessionCanResume, resolveCursorReopenGate } from '@/lib/sessionResume' import { isOnSessionPage, resolveVoiceSession } from '@/lib/dictationSend' -import { initializeSessionLastSeen } from '@/lib/sessionLastSeen' -import { useSelectedSessionSeen } from '@/hooks/useSelectedSessionSeen' +import { configureSessionReadState, observeSessionReadStates } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' import { getSupersedingSessionId, prepareFollowSupersedingSession, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' @@ -187,14 +186,22 @@ function SessionsPage() { () => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null, [selectedSessionId, sessions] ) + const readStateScope = api?.getReadStateScope() + useEffect(() => { + const scope = readStateScope + if (!api || !scope) return + return configureSessionReadState(scope, (id, request) => { + if (api.getReadStateScope() !== scope) return Promise.reject(new Error('Read-state identity changed')) + return api.updateSessionReadState(id, request) + }) + }, [api, readStateScope]) useEffect(() => { if (isLoading || error) { return } - initializeSessionLastSeen(baseUrl, sessions) + observeSessionReadStates(sessions) setInitializedHub(baseUrl) }, [baseUrl, error, isLoading, sessions]) - useSelectedSessionSeen(selectedSessionId, selectedSession?.updatedAt) const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebarSearch = useLocation({ select: location => location.search }) const [isSidebarVisible, setIsSidebarVisible] = useState( @@ -405,6 +412,7 @@ function SessionPage() { messagesVersion, historyVersion, tailRevision, + readThroughRevision, setViewMode, } = useMessages(api, sessionId) @@ -849,6 +857,7 @@ function SessionPage() { return (