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 (