Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/LastSeenStore.swift
T
weishu 67b7fe7304 feat(ios): session list store + UI (A-M2a)
HapiProtocol (pure, mirroring the Android reference port):
- SummaryPatching: sessionSummary.ts derivations (pending requests
  cap-5/oldest-first/id-tiebreak, kinds, todo progress), toSessionSummary
  projection with the per-flavor agentSessionId resolution (legacy chain
  omits piSessionId, replicated), applySessionSummaryPatch with the
  deliberate >= version gates (vs the detail path's strict >), the
  keep-alive render-irrelevance filter (pendingRequests compare ignores
  'since'; metadata compare ignores hapiMcpUrl), max-monotonic updatedAt,
  post-patch updatedAt as fallback 'since', and the deprecated legacy
  detail-required gate kept for rule pinning.
- SessionSorting: exact list comparator (globalPinned > pinned > active >
  pendingRequestsCount desc among active > updatedAt desc; stable).
- SessionMetadata: per-flavor session-id fields (claude/codex/gemini/
  opencode/grok/agy/cursor/kimi/copilot/pi).

HapiClient stores (@MainActor @Observable, per hub):
- DiskCache: 500 ms debounced atomic JSON snapshots + SnapshotLocations.
- SessionListStore: sorted summaries + detail cache; full-session upsert
  preserving hub-computed scheduled fields; strict-> detail vs >= summary
  patch paths; keep-alive identity preservation (listRevision); empty-{}
  and unparseable payloads take the REST fallback; 16 ms coalesced
  refresh; optimistic pin (roll-forward) and archive (restore).
- MachineStore: the exact machine-updated decision tree.
- LastSeenStore: monotonic unread watermarks + per-scope baseline.
- SyncEventRouter: SyncEvent fan-out to the stores, global-scope message
  events refresh the list, gap handshake triggers the full resync.

App:
- HubSession owns the stores + router (replaces the TODO(M2) routing);
  background flushes snapshots.
- Features/Sessions: SessionListView + SessionListModel (status dot with
  thinking pulse, title cascade, flavor·machine·worktree meta, relative
  age on a minute timeline, pending/todo badges, unread dots, pinned
  section, machine filter chips >= 2, pull-to-refresh, empty/loading/
  offline states, long-press pin/archive context menu) + an M2f chat
  placeholder pushed on row tap.
- HomePlaceholderView -> HomeView hosting the list (hub switcher +
  connection dot kept in the toolbar).

Tests transcribe the Android suites: SummaryPatchingTest (18),
SessionSortingTest (6), SessionStoreTest, MachineStoreTest,
LastSeenStoreTest, JsonSnapshotStoreTest, StoreSyncTargetsTest — using
the existing HTTPPerforming recording stub plus a path-routing performer
for concurrent refetches.
2026-08-17 20:17:02 +08:00

84 lines
3.2 KiB
Swift

import Foundation
import HapiProtocol
import Observation
/// Snapshot payload: watermarks + which scopes already got their baseline.
public struct LastSeenState: Codable, Equatable, Sendable {
public var lastSeen: [String: Int]
public var baselines: Set<String>
public init(lastSeen: [String: Int] = [:], baselines: Set<String> = []) {
self.lastSeen = lastSeen
self.baselines = baselines
}
}
/// Per-session last-seen watermarks — port of `web/src/lib/sessionLastSeen.ts`
/// (localStorage → per-hub JSON snapshot) plus the unread derivation from
/// `web/src/lib/sessionAttention.ts`, mirroring the Android port
/// (`LastSeenStore`).
///
/// The watermark is the session `updatedAt` the operator last had on screen;
/// a session is **unread** when its current `updatedAt` moved past it
/// (``isUnread(_:lastSeenAt:)`` — the reference compares `updatedAt` only;
/// message `seq` never reaches the summary).
/// ``initializeBaseline(scopeKey:sessions:)`` seeds missing watermarks from
/// the first session list so a fresh install does not mark every historical
/// session unread — once per ``LastSeenState/baselines`` scope, exactly like
/// the web's per-scope baseline flag.
@MainActor @Observable
public final class LastSeenStore {
public private(set) var state: LastSeenState
@ObservationIgnored private let snapshot: DiskCache<LastSeenState>?
public init(snapshotDirectory: URL? = nil) {
let cache = snapshotDirectory.map {
DiskCache<LastSeenState>(directory: $0, filename: "last-seen.json")
}
self.snapshot = cache
self.state = cache?.load() ?? LastSeenState()
}
public func lastSeenAt(_ sessionId: String) -> Int {
state.lastSeen[sessionId] ?? 0
}
/// Forces the debounced snapshot to disk (app background / tests).
public func flushPersistence() async {
await snapshot?.flush()
}
/// `markSessionSeen`: monotonic max — a stale screen never rewinds the
/// watermark.
public func markSeen(sessionId: String, seenAt: Int) {
guard !sessionId.isEmpty else { return }
let current = state.lastSeen[sessionId] ?? 0
let next = max(current, seenAt)
if next == current, state.lastSeen[sessionId] != nil {
return
}
state.lastSeen[sessionId] = next
snapshot?.scheduleWrite(state)
}
/// `initializeSessionLastSeen`: on the first list load for `scopeKey`
/// (e.g. the hub origin), seed every session without a watermark at its
/// current `updatedAt`, then never again for that scope.
public func initializeBaseline(scopeKey: String, sessions: [SessionSummary]) {
guard !state.baselines.contains(scopeKey) else { return }
var next = state
for session in sessions where next.lastSeen[session.id] == nil {
next.lastSeen[session.id] = session.updatedAt
}
next.baselines.insert(scopeKey)
state = next
snapshot?.scheduleWrite(next)
}
/// `sessionIsUnread`: activity newer than the operator's watermark.
public static func isUnread(_ summary: SessionSummary, lastSeenAt: Int) -> Bool {
summary.updatedAt > lastSeenAt
}
}