Skip to content

Commit 3693d7b

Browse files
committed
fix(sessions): re-acknowledge unread while a session stays open on iOS and Android
Mirrors the web guard semantics flagged in review: the one-shot read acknowledgement resets when a server-confirmed read (unread=false) is observed, so a run finishing while the session remains open re-acknowledges instead of leaving a stale unread dot. Server-side lastReadAt stamping keeps the exchange convergent (no patch loops).
1 parent ce0fa2e commit 3693d7b

4 files changed

Lines changed: 105 additions & 8 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ class ChatController internal constructor(
132132
private var commandsAgentId: String? = null
133133
private var sessionsListArchived = false
134134

135-
// Stale session events can reassert unread; acknowledge at most once per selected-session visit.
135+
// One acknowledgement per unread episode: the pending flag clears when the
136+
// server-confirmed read (unread=false) arrives, so fresh activity on the open
137+
// session re-acknowledges without patch loops (lastReadAt is stamped server-side).
136138
private var unreadPatchSessionKey: String? = null
137139
private var unreadPatchRequested = false
138140

@@ -417,12 +419,7 @@ class ChatController internal constructor(
417419
unreadPatchSessionKey = key
418420
unreadPatchRequested = false
419421
}
420-
val entry = _sessions.value.firstOrNull { it.key == key }
421-
if (entry?.unread == true && !unreadPatchRequested) {
422-
unreadPatchRequested = true
423-
_sessions.value = _sessions.value.map { if (it.key == key) it.copy(unread = false) else it }
424-
scope.launch { patchSession(key = key, unread = false) }
425-
}
422+
acknowledgeUnreadIfNeeded(key, _sessions.value.firstOrNull { it.key == key })
426423
if (key == _sessionKey.value) return
427424
val generation = beginHistoryLoad(key, clearMessages = true)
428425
scope.launch {
@@ -993,6 +990,13 @@ class ChatController internal constructor(
993990
.any { session -> session.key == activeSessionKey }
994991
activeSessionKey.takeIf { result.isTruncated || activeOutsideLocalWindow }
995992
}
993+
unreadPatchSessionKey?.let { trackedKey ->
994+
acknowledgeUnreadIfNeeded(
995+
key = trackedKey,
996+
entry = result.sessions.firstOrNull { it.key == trackedKey },
997+
requireActive = true,
998+
)
999+
}
9961000
if (!archived) {
9971001
persistSessions(requestCacheScope, result.sessions, retainedSessionKey)
9981002
}
@@ -1856,19 +1860,47 @@ class ChatController internal constructor(
18561860
) {
18571861
val current = _sessions.value
18581862
val index = current.indexOfFirst { it.key == entry.key }
1863+
var applied = entry
18591864
_sessions.value =
18601865
if (index >= 0) {
18611866
current.toMutableList().also {
1862-
it[index] =
1867+
applied =
18631868
mergeChatSessionEntry(
18641869
existing = it[index],
18651870
next = entry,
18661871
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
18671872
)
1873+
it[index] = applied
18681874
}
18691875
} else {
18701876
listOf(entry) + current
18711877
}
1878+
acknowledgeUnreadIfNeeded(applied.key, applied, requireActive = true)
1879+
}
1880+
1881+
/**
1882+
* Acknowledges unread state for the visited session at most once per unread episode: the
1883+
* pending flag resets when the server-confirmed read (unread=false) is observed, so a run
1884+
* finishing while the session stays open re-acknowledges without patch loops (the gateway
1885+
* stamps lastReadAt server-side, which makes the exchange convergent).
1886+
*/
1887+
private fun acknowledgeUnreadIfNeeded(
1888+
key: String,
1889+
entry: ChatSessionEntry?,
1890+
requireActive: Boolean = false,
1891+
) {
1892+
if (key.isEmpty() || key != unreadPatchSessionKey) return
1893+
if (entry?.unread == false) {
1894+
unreadPatchRequested = false
1895+
return
1896+
}
1897+
if (entry?.unread != true || unreadPatchRequested) return
1898+
// switchSession acknowledges before _sessionKey updates; background upserts only
1899+
// re-acknowledge the session that is currently open.
1900+
if (requireActive && key != _sessionKey.value) return
1901+
unreadPatchRequested = true
1902+
_sessions.value = _sessions.value.map { if (it.key == key) it.copy(unread = false) else it }
1903+
scope.launch { patchSession(key = key, unread = false) }
18721904
}
18731905

18741906
private fun removeSessionEntry(sessionKey: String?) {

apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,52 @@ class ChatControllerCommandControlsTest {
350350
assertTrue(patch.contains("\"unread\":false"))
351351
}
352352

353+
@OptIn(ExperimentalCoroutinesApi::class)
354+
@Test
355+
fun openSessionReacknowledgesUnreadOncePerEpisode() =
356+
runTest {
357+
val requests = mutableListOf<Pair<String, String?>>()
358+
val controller =
359+
ChatController(
360+
scope = this,
361+
json = json,
362+
requestGateway = { method, paramsJson ->
363+
requests += method to paramsJson
364+
when (method) {
365+
"sessions.list" -> """{"sessions":[{"key":"main","unread":false}]}"""
366+
else -> "{}"
367+
}
368+
},
369+
)
370+
371+
controller.refreshSessions()
372+
advanceUntilIdle()
373+
controller.switchSession("main")
374+
advanceUntilIdle()
375+
assertEquals(0, requests.count { it.first == "sessions.patch" })
376+
377+
// A run completes while the session stays open: the gateway flags it unread again.
378+
controller.handleGatewayEvent(
379+
"sessions.changed",
380+
"""{"sessionKey":"main","session":{"key":"main","unread":true}}""",
381+
)
382+
advanceUntilIdle()
383+
assertEquals(1, requests.count { it.first == "sessions.patch" })
384+
385+
// Server-confirmed read resets the episode; a stale duplicate must not re-patch.
386+
controller.handleGatewayEvent(
387+
"sessions.changed",
388+
"""{"sessionKey":"main","session":{"key":"main","unread":false}}""",
389+
)
390+
advanceUntilIdle()
391+
controller.handleGatewayEvent(
392+
"sessions.changed",
393+
"""{"sessionKey":"main","session":{"key":"main","unread":true}}""",
394+
)
395+
advanceUntilIdle()
396+
assertEquals(2, requests.count { it.first == "sessions.patch" })
397+
}
398+
353399
@Test
354400
fun startNewChatWithoutLoadedParentCreatesFirstSession() =
355401
runTest {

apps/ios/Sources/Design/CommandCenterTab.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ struct CommandCenterTab: View {
489489
let transport = self.appModel.makeChatTransport()
490490
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
491491
self.applySessions(response.sessions)
492+
self.appModel.reconcileChatSessionReadState(response.sessions)
492493
await self.appModel.storeCachedChatSessions(response.sessions)
493494
} catch {
494495
await self.applyCachedSessions()
@@ -973,6 +974,7 @@ struct CommandSessionsScreen: View {
973974
guard requestsArchived == self.showArchived else { return }
974975
self.sessions = response.sessions
975976
if !requestsArchived {
977+
self.appModel.reconcileChatSessionReadState(response.sessions)
976978
await self.appModel.storeCachedChatSessions(response.sessions)
977979
}
978980
} catch {

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2286,6 +2286,23 @@ extension NodeAppModel {
22862286
self.openChatRequestID &+= 1
22872287
}
22882288

2289+
/// One acknowledgement per unread episode: the guard clears when a fresh
2290+
/// snapshot confirms the read (unread != true), so a run finishing while the
2291+
/// session stays open re-acknowledges without patch loops (the gateway stamps
2292+
/// lastReadAt server-side, which makes the exchange convergent).
2293+
func reconcileChatSessionReadState(_ entries: [OpenClawChatSessionEntry]) {
2294+
guard let acknowledgedKey = self.readAcknowledgedChatSessionKey,
2295+
let entry = entries.first(where: { $0.key == acknowledgedKey })
2296+
else { return }
2297+
if entry.unread != true {
2298+
self.readAcknowledgedChatSessionKey = nil
2299+
return
2300+
}
2301+
guard acknowledgedKey == self.chatSessionKey else { return }
2302+
self.readAcknowledgedChatSessionKey = nil
2303+
self.acknowledgeChatSessionReadIfNeeded(acknowledgedKey)
2304+
}
2305+
22892306
private func acknowledgeChatSessionReadIfNeeded(_ sessionKey: String) {
22902307
guard self.readAcknowledgedChatSessionKey != sessionKey else { return }
22912308
self.readAcknowledgedChatSessionKey = sessionKey

0 commit comments

Comments
 (0)