Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionUnreadPatchGuard.swift
RoboClaw 44d60a29e6 fix: preserve unread reminder for open sessions (#129386)
* fix: preserve manual unread markers in open sessions

* fix: distinguish explicit session reads

* fix(ui): gate unread contract on gateway capability

* perf(ui): keep server capabilities out of startup bundle

* test(gateway): keep agent fixtures roster-consistent

* fix(sessions): preserve legacy read compatibility

* test(gateway): type agent fixture configs

* fix(ui): remove unread gateway fallback

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(infra): avoid fixed SSH tunnel port

* fix(ui): acknowledge unread after history commit

* docs: clarify unread upgrade boundary

* test(ui): drive mobile session menu by tap

* fix(ios): remove stale read reconciliation call

---------

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-08-26 05:04:39 -07:00

159 lines
5.4 KiB
Swift

import Foundation
struct ChatSessionUnreadPatchGuard {
private var activeSessionKey = ""
private var activationObserved = false
private var activationMarkedUnreadAt: Double?
private var requested = false
private var activeExplicitUnread: Bool?
private var confirmedUnreadByKey: [String: Bool] = [:]
private var pendingExplicitUnreadByKey: [String: Bool] = [:]
private var pendingExplicitRevisions: [String: Int] = [:]
private var revisions: [String: Int] = [:]
mutating func observe(key: String, unread: Bool?) {
guard self.pendingExplicitRevisions[key] == nil,
!(key == self.activeSessionKey && self.activeExplicitUnread != nil)
else { return }
if let unread {
self.confirmedUnreadByKey[key] = unread
}
if key == self.activeSessionKey, unread == false {
self.requested = false
}
}
mutating func shouldPatch(key: String, unread: Bool?, markedUnreadAt: Double?) -> Int? {
guard !key.isEmpty else { return nil }
self.activate(key: key)
if !self.activationObserved {
self.activationObserved = true
self.activationMarkedUnreadAt = markedUnreadAt
}
if unread == false {
self.activationMarkedUnreadAt = nil
self.requested = false
return nil
}
if let markedUnreadAt, markedUnreadAt != self.activationMarkedUnreadAt {
return nil
}
guard unread == true, !self.requested else { return nil }
self.requested = true
return self.advanceRevision(key: key)
}
mutating func activate(key: String) {
guard key != self.activeSessionKey else { return }
self.activeSessionKey = key
self.activationObserved = false
self.activationMarkedUnreadAt = nil
self.requested = false
self.activeExplicitUnread = nil
}
mutating func beginExplicitPatch(key: String, unread: Bool, isActive: Bool) -> Int {
let revision = self.advanceRevision(key: key)
self.pendingExplicitRevisions[key] = revision
self.pendingExplicitUnreadByKey[key] = unread
if isActive {
self.activeSessionKey = key
// The explicit action owns this activation. Only navigation opens
// the next episode; stale list snapshots cannot undo the action.
self.requested = true
self.activeExplicitUnread = unread
}
return revision
}
mutating func patchSucceeded(key: String, unread: Bool, revision: Int) -> Bool {
guard self.revisions[key] == revision else { return false }
if self.pendingExplicitRevisions[key] == revision {
self.pendingExplicitRevisions.removeValue(forKey: key)
self.pendingExplicitUnreadByKey.removeValue(forKey: key)
}
self.confirmedUnreadByKey[key] = unread
return true
}
mutating func patchFailed(key: String, revision: Int) -> Bool {
guard self.revisions[key] == revision else { return false }
if self.pendingExplicitRevisions[key] == revision {
self.pendingExplicitRevisions.removeValue(forKey: key)
self.pendingExplicitUnreadByKey.removeValue(forKey: key)
}
if key == self.activeSessionKey {
self.requested = false
self.activeExplicitUnread = nil
}
return true
}
func confirmedUnread(key: String) -> Bool? {
self.confirmedUnreadByKey[key]
}
func localUnreadOverride(key: String) -> Bool? {
if let unread = self.pendingExplicitUnreadByKey[key] {
return unread
}
guard key == self.activeSessionKey else { return nil }
return self.activeExplicitUnread
}
private mutating func advanceRevision(key: String) -> Int {
let revision = self.revisions[key, default: 0] + 1
self.revisions[key] = revision
return revision
}
}
@MainActor
final class ChatSessionUnreadMutationQueue {
private struct Tail {
let id: Int
let task: Task<Void, Never>
}
private var nextID = 0
private var tails: [String: Tail] = [:]
func reserve(
routeLease: Task<OpenClawChatSessionMutationRouteLease?, Never>,
queueKey: String,
routeKey: String,
expectedMarkedUnreadAt: Double?? = nil,
unread: Bool) -> Task<Void, Error>
{
let previous = self.tails[queueKey]?.task
self.nextID += 1
let id = self.nextID
let operation = Task { @MainActor in
let resolvedRouteLease = await routeLease.value
await previous?.value
guard let resolvedRouteLease else {
throw OpenClawChatTransportSendError.notDispatched
}
try await resolvedRouteLease.patchSession(
key: routeKey,
expectedMarkedUnreadAt: expectedMarkedUnreadAt,
label: nil,
category: nil,
pinned: nil,
archived: nil,
unread: unread)
}
let tail = Task { @MainActor in
_ = try? await operation.value
}
self.tails[queueKey] = Tail(id: id, task: tail)
Task { @MainActor in
await tail.value
if self.tails[queueKey]?.id == id {
self.tails.removeValue(forKey: queueKey)
}
}
return operation
}
}