mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention. Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main. Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
177 lines
7.6 KiB
Swift
177 lines
7.6 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 every page fetch.
|
|
public static let pageSize = 200
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|