Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatHistoryPagingState.swift
T
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
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.
2026-09-10 16:22:36 +08:00

84 lines
2.9 KiB
Swift

import HapiProtocol
/// One pagination run includes its first rendered layout, not just the HTTP
/// request. Geometry may submit demand repeatedly; only an idle run starts.
/// Owned by the chat's main-thread coordinator. No timers or UI dependencies.
public struct ChatHistoryPagingState: Equatable, Sendable {
public enum Phase: Equatable, Sendable {
case idle, loading, awaitingLayout(Int), retrying, failed, paused, exhausted
}
public private(set) var phase: Phase = .idle
public private(set) var generation = 0
public private(set) var failures = 0
public private(set) var pagesWithoutProgress = 0
public init() {}
public mutating func begin() -> Int? {
guard phase == .idle else { return nil }
generation += 1
phase = .loading
return generation
}
/// Returns the bounded retry delay, in milliseconds, if one is needed.
@discardableResult
public mutating func received(_ result: OlderLoadOutcome, generation: Int) -> Int? {
guard self.generation == generation, phase == .loading else { return nil }
switch result {
case .applied(let version, _, _):
phase = .awaitingLayout(version)
failures = 0
case .failed:
failures += 1
phase = failures <= 2 ? .retrying : .failed
return failures == 1 ? 500 : failures == 2 ? 1500 : nil
case .stopped(let reason):
switch reason {
case .exhausted: phase = .exhausted
case .cursorDidNotAdvance: phase = .paused
default: phase = .idle
}
}
return nil
}
@discardableResult
public mutating func retryElapsed(generation: Int) -> Bool {
guard self.generation == generation, phase == .retrying else { return false }
phase = .idle
return true
}
/// Also called for pages with no new cells: their version still needs an
/// acknowledgement, otherwise hidden-only pages deadlock the loader.
@discardableResult
public mutating func laidOut(historyVersion: Int, madeProgress: Bool) -> Bool {
guard case .awaitingLayout(let target) = phase, historyVersion >= target else { return false }
pagesWithoutProgress = madeProgress ? 0 : pagesWithoutProgress + 1
phase = pagesWithoutProgress >= 3 ? .paused : .idle
return true
}
public mutating func resume() {
guard phase == .failed || phase == .paused else { return }
failures = 0
pagesWithoutProgress = 0
phase = .idle
}
/// Tail-window trimming can make older pages available again. Errors and
/// no-progress pauses still require an explicit reader action.
public mutating func refreshAvailability(hasMore: Bool) {
if hasMore, phase == .exhausted { phase = .idle }
}
public mutating func cancel() {
generation += 1
phase = .idle
failures = 0
pagesWithoutProgress = 0
}
}