mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices. Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
180 lines
7.8 KiB
Swift
180 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
/// State machine model for one session's message window — the faithful port
|
|
/// of the web reference `web/src/lib/message-window-store.ts` (its
|
|
/// `InternalState`), per `docs/api/client-contract/pagination.md`. The pure
|
|
/// transitions over this state live in ``MessageWindowLogic``; the async
|
|
/// orchestration (`HapiClient` `MessageWindowController`) drives them.
|
|
/// Structure and semantics mirror the Android reference port
|
|
/// (`android/core/protocol/.../window/MessageWindowState.kt`) one-to-one.
|
|
|
|
/// Windowing constants — normative, from the web reference.
|
|
public enum MessageWindowConstants {
|
|
/// Max regular rows kept in tail mode (following the live bottom).
|
|
public static let visibleWindowSize = 400
|
|
/// Max regular rows kept in history mode (user scrolled back).
|
|
public static let historyWindowSize = 600
|
|
/// Temporary cap while an older page is being merged (prepend).
|
|
public static let olderLoadWindowSize = 800
|
|
/// Separate trim bucket for codex `agent-run-*` rows so background-agent
|
|
/// traces don't evict chat.
|
|
public static let agentRunWindowSize = 800
|
|
/// Request size for ordinary latest/reset, forward, and older-page fetches.
|
|
public static let pageSize = 200
|
|
/// Request size for a genuinely cold latest page, prioritizing first paint.
|
|
public static let initialPageSize = 20
|
|
}
|
|
|
|
public enum MessageViewMode: String, Sendable {
|
|
/// Following the live bottom; trims from the top.
|
|
case tail
|
|
/// Scrolled back; trims from the bottom and may force a latest reset.
|
|
case history
|
|
}
|
|
|
|
/// Compound paging position `(at, seq)` where `at = invokedAt ?? createdAt` —
|
|
/// both halves always travel together (`pagination.md` "Position key").
|
|
public struct MessagePosition: Equatable, Comparable, Sendable {
|
|
public let at: Int
|
|
public let seq: Int
|
|
|
|
public init(at: Int, seq: Int) {
|
|
self.at = at
|
|
self.seq = seq
|
|
}
|
|
|
|
public static func < (lhs: MessagePosition, rhs: MessagePosition) -> Bool {
|
|
lhs.at != rhs.at ? lhs.at < rhs.at : lhs.seq < rhs.seq
|
|
}
|
|
}
|
|
|
|
/// Outcome of one older-page load (`fetchOlderMessages` in the web).
|
|
public enum OlderLoadOutcome: Sendable {
|
|
case applied(historyVersion: Int, hasMore: Bool, addedRenderableCount: Int)
|
|
case stopped(StopReason)
|
|
case failed(any Error)
|
|
|
|
public enum StopReason: String, Sendable {
|
|
case unavailable
|
|
case busy
|
|
case invalidated
|
|
case epochReset = "epoch-reset"
|
|
case exhausted
|
|
case cursorDidNotAdvance = "cursor-did-not-advance"
|
|
}
|
|
}
|
|
|
|
/// The full window state. Field-for-field port of the web `InternalState`
|
|
/// (public `MessageWindowState` + internal cursor/generation fields), with
|
|
/// the two `(at, seq)` half-pairs folded into nullable ``MessagePosition``s —
|
|
/// the web only ever reads/writes them pairwise (`readPosition`).
|
|
///
|
|
/// `Equatable` compares ``WindowMessage`` rows by **identity** (see that
|
|
/// 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] = []
|
|
/// Older history exists (server flag, or rows were trimmed away).
|
|
public var hasMore: Bool = false
|
|
/// min/max `seq` over ``messages`` — derived, kept for UI parity.
|
|
public var oldestSeq: Int?
|
|
public var newestSeq: Int?
|
|
/// Cached server epoch; nil until the first page (or after invalidation).
|
|
public var epoch: Int?
|
|
public var isSyncingTail: Bool = false
|
|
public var isLoadingMore: Bool = false
|
|
public var warning: String?
|
|
public var viewMode: MessageViewMode = .tail
|
|
/// Local presentation policy; not persisted and not part of the wire.
|
|
public var historyRetentionLimit = MessageWindowConstants.historyWindowSize
|
|
/// Bumped whenever the ``messages`` list instance changes.
|
|
public var messagesVersion: Int = 0
|
|
/// Bumped per applied older page (scroll-anchoring handle).
|
|
public var historyVersion: Int = 0
|
|
/// Bumped on tail-side content changes (auto-scroll handle).
|
|
public var tailRevision: Int = 0
|
|
/// Next `before` request position (web `oldestPositionAt/Seq`).
|
|
public var oldestPosition: MessagePosition?
|
|
/// Next `after` request position (web `newestPositionAt/Seq`).
|
|
public var newestPosition: MessagePosition?
|
|
/// Cursors are unusable — the next tail sync must fetch a fresh latest page.
|
|
public var requiresLatestReset: Bool = false
|
|
/// Re-activation with persisted state: fetch the current tail first.
|
|
public var preferLatestOnActivation: Bool = false
|
|
/// Invalidates in-flight tail syncs (compare-and-ignore).
|
|
public var syncGeneration: Int = 0
|
|
/// Invalidates in-flight older-page loads.
|
|
public var olderGeneration: Int = 0
|
|
|
|
public init(sessionId: String) {
|
|
self.sessionId = sessionId
|
|
}
|
|
}
|
|
|
|
/// Snapshot shape persisted per session (web `PersistedMessageWindowState`,
|
|
/// storage key `hapi:message-window:v2:`): messages + cursors + epoch only —
|
|
/// transient flags and counters never survive a restart.
|
|
///
|
|
/// Encoding writes every key (`null` for empty cursor halves), matching the
|
|
/// web's v2 JSON shape; decoding is lenient (absent keys fall back to the
|
|
/// defaults) so an older snapshot never throws.
|
|
public struct PersistedMessageWindow: Codable, Sendable {
|
|
public var messages: [WindowMessage] = []
|
|
public var hasMore: Bool = false
|
|
public var oldestPositionAt: Int?
|
|
public var oldestPositionSeq: Int?
|
|
public var newestPositionAt: Int?
|
|
public var newestPositionSeq: Int?
|
|
public var epoch: Int?
|
|
|
|
public init(
|
|
messages: [WindowMessage] = [],
|
|
hasMore: Bool = false,
|
|
oldestPositionAt: Int? = nil,
|
|
oldestPositionSeq: Int? = nil,
|
|
newestPositionAt: Int? = nil,
|
|
newestPositionSeq: Int? = nil,
|
|
epoch: Int? = nil
|
|
) {
|
|
self.messages = messages
|
|
self.hasMore = hasMore
|
|
self.oldestPositionAt = oldestPositionAt
|
|
self.oldestPositionSeq = oldestPositionSeq
|
|
self.newestPositionAt = newestPositionAt
|
|
self.newestPositionSeq = newestPositionSeq
|
|
self.epoch = epoch
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case messages, hasMore
|
|
case oldestPositionAt, oldestPositionSeq
|
|
case newestPositionAt, newestPositionSeq
|
|
case epoch
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
messages = try container.decodeIfPresent([WindowMessage].self, forKey: .messages) ?? []
|
|
hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false
|
|
oldestPositionAt = try container.decodeIfPresent(Int.self, forKey: .oldestPositionAt)
|
|
oldestPositionSeq = try container.decodeIfPresent(Int.self, forKey: .oldestPositionSeq)
|
|
newestPositionAt = try container.decodeIfPresent(Int.self, forKey: .newestPositionAt)
|
|
newestPositionSeq = try container.decodeIfPresent(Int.self, forKey: .newestPositionSeq)
|
|
epoch = try container.decodeIfPresent(Int.self, forKey: .epoch)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(messages, forKey: .messages)
|
|
try container.encode(hasMore, forKey: .hasMore)
|
|
try container.encode(oldestPositionAt, forKey: .oldestPositionAt)
|
|
try container.encode(oldestPositionSeq, forKey: .oldestPositionSeq)
|
|
try container.encode(newestPositionAt, forKey: .newestPositionAt)
|
|
try container.encode(newestPositionSeq, forKey: .newestPositionSeq)
|
|
try container.encode(epoch, forKey: .epoch)
|
|
}
|
|
}
|