Files
hapi/ios/Hapi/Models/ChatSession.swift
T
weishu a4355d1837 feat(ios): read-only chat assembly (A-M2f)
Wires the merged M2 pieces into a usable pair -> list -> live chat flow:

- pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the
  existing HapiProtocol/HapiClient product references; only project change).
- HubSession: MessageWindowControllers registry + per-chat ChatSession
  factory with in-memory per-session SSE resume cursors and scene-phase
  forwarding to the active chat.
- ChatSession (app): session-scope SSEClient while the chat is open;
  window opened/activated before subscribing; a single consume task awaits
  every event into the window actor, giving Android-channel-equivalent
  arrival-order ingestion; session-updated and friends route through the
  shared SyncEventRouter (detail patching), session-removed clears the
  window, a gap handshake triggers full resync + detail refetch + catch-up
  tail sync; stop hands the cursor back for seamless reopen.
- ChatPipeline (HapiClient): actor running the reduction path off-main -
  queued-row filter, normalization memoized by row instance identity,
  reduce + buildVisibleChatBlocks with previousGroups-stable group ids.
- ChatModel: window state + detail agentState + machine labels -> serial
  ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly
  ordered), header title cascade, loading/empty/error states, loadOlder /
  retry, last-seen stamping on every update.
- ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor +
  scrollPosition(id:)), auto-stick at bottom, new-messages pill, top
  sentinel paging with documented scroll re-anchoring, degraded banners.
- Block views: user bubble w/ attachment chips, agent markdown, collapsed
  reasoning, tool cards (knownTools.tsx-parity presentation, status chip,
  read-only permission row w/ pending banner, per-tool bodies: terminal,
  before/after edits, write content, unified-diff detection -> DiffTextView,
  checklists, read-only questions, pretty-JSON fallback, children rail),
  tool groups, centered event rows (presentation.ts port), cli output,
  generated images (authed bytes + in-memory cache + full-screen viewer),
  codex review verdict card.
- Navigation/links: session list now pushes ChatView; app-level
  \.hapiOpenURL handler (https/http -> SFSafariViewController, custom
  schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection.
- Tests: ChatPipelineTests drive the runner with fixture-derived windows
  (stable ids, memo-stable recomputes, queued filter, group-id stability
  across an older-page arrival).
- README: M2f app-layer description.
2026-08-17 20:55:36 +08:00

223 lines
9.1 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
/// Per-open-chat transport wiring (M2f) — the iOS counterpart of the
/// Android `ChatViewModel`'s lifecycle half:
///
/// - opens this session's ``MessageWindowController`` through the hub's
/// registry (snapshot hydration + `activate()`), then kicks a tail sync;
/// - owns the **session-scope** SSE subscription while the chat is on screen
/// (dual-subscription model: `HubSession` keeps the global pipe for the
/// list) and routes its events:
/// - message-stream family → the window controller's ingest hooks,
/// - `session-updated`/lifecycle + `machine-updated` + `toast` → the
/// shared `SyncEventRouter` (detail patching included),
/// - `session-removed` for this session → clears the window,
/// - a `gap` handshake verdict → full REST resync + detail refetch +
/// the window's catch-up tail sync (`ensureAfterCurrent`);
/// - suspends/resumes with the scene phase (forwarded by `HubSession`) and
/// on `stop()` hands its SSE resume cursor back to the hub session, so a
/// reopened chat resumes instead of replaying cold.
///
/// **Ordering.** The window must observe SSE events in arrival order (the
/// Android port funnels them through a single-consumer Channel for this).
/// Here the same guarantee falls out of structure: `SSEClient` yields into
/// one `AsyncStream`, exactly one task consumes it, and every event is
/// `await`ed to completion — including the hop into the window controller's
/// actor — before the next one is pulled. One producer, one consumer, one
/// awaited hop per event: no interleaving is possible.
@MainActor @Observable
final class ChatSession {
let sessionId: String
/// Session-pipe connection state, for the chat header/banner.
private(set) var connectionState: SSEConnectionState = .idle
/// Set once `start()` opened the window; the chat model observes its
/// state stream and calls its `fetchOlder`/`syncTail`.
private(set) var windowController: MessageWindowController?
/// Fired (on the main actor) after every handled SSE event so the chat
/// model can re-run its pipeline over the freshly patched stores.
@ObservationIgnored var onStoreActivity: (@MainActor () -> Void)?
private let baseURL: URL
private let authManager: AuthManager
private let windows: MessageWindowControllers
private let sessionStore: SessionListStore
@ObservationIgnored private let router: SyncEventRouter
@ObservationIgnored private let initialCursor: String?
@ObservationIgnored private let registerActive: @MainActor (ChatSession) -> Void
@ObservationIgnored private let saveCursor: @MainActor (String?) -> Void
@ObservationIgnored private var sse: SSEClient?
@ObservationIgnored private var consumeTask: Task<Void, Never>?
@ObservationIgnored private var started = false
@ObservationIgnored private var stopped = false
init(
sessionId: String,
baseURL: URL,
authManager: AuthManager,
windows: MessageWindowControllers,
sessionStore: SessionListStore,
machineStore: MachineStore,
initialCursor: String?,
registerActive: @escaping @MainActor (ChatSession) -> Void,
saveCursor: @escaping @MainActor (String?) -> Void
) {
self.sessionId = sessionId
self.baseURL = baseURL
self.authManager = authManager
self.windows = windows
self.sessionStore = sessionStore
self.router = SyncEventRouter(sessions: sessionStore, machines: machineStore)
self.initialCursor = initialCursor
self.registerActive = registerActive
self.saveCursor = saveCursor
}
// MARK: - Lifecycle
/// Open the window, then subscribe: every routed message event finds the
/// controller already in place (the Android wiring order). Idempotent.
func start() async {
guard !started, !stopped else { return }
started = true
registerActive(self)
let controller = await windows.open(sessionId: sessionId)
await controller.activate()
// A stop() can land while the opens above were suspended; do not
// bring the SSE up for a dead chat.
guard !stopped else { return }
windowController = controller
startSessionSSE()
// Explicit catch-up on entry (the snapshot may be stale); the SSE
// handshake's own gap handling covers everything missed after this.
Task { await controller.syncTail() }
}
/// Tears the session pipe down; the engine-side resume cursor is saved
/// so a reopened chat for this session resumes where it left off.
/// Idempotent — both the view's disappear and a hub shutdown call it.
func stop() {
guard !stopped else { return }
stopped = true
onStoreActivity = nil
consumeTask?.cancel()
consumeTask = nil
connectionState = .idle
// Nobody observes the detail once the chat closes (mirror of the
// Android `sessionStore.releaseDetail`).
sessionStore.releaseDetail(sessionId)
if let sse {
self.sse = nil
let save = saveCursor
Task {
let cursor = await sse.lastEventId
await sse.stop()
save(cursor)
}
}
}
/// Scene-phase forwarding (see `HubSession.enterForeground`): resume the
/// parked retry loop, with the 45 s staleness distrust for a socket that
/// survived suspension.
func enterForeground() {
guard started, !stopped, let sse else { return }
Task { await sse.resume() }
}
func enterBackground() {
guard let sse else { return }
Task { await sse.suspend() }
}
// MARK: - Session-scope SSE
private func startSessionSSE() {
let auth = authManager
let configuration = SSEClientConfiguration(
baseUrl: baseURL,
tokenProvider: {
// Transient failures park the SSE loop in backoff; the
// terminal (re-pair required) case is owned by the global
// pipe in `HubSession`, which tears this session down too.
try? await auth.validToken()
},
scope: .session(sessionId)
)
let client = SSEClient(configuration: configuration, pathObserver: NWPathObserver())
sse = client
let cursor = initialCursor
consumeTask = Task { [weak self] in
// Seed before start (the client ignores seeds after the run
// loop exists); both calls run sequentially on the client actor.
if let cursor { await client.seedCursor(cursor) }
let stream = await client.start()
for await event in stream {
guard let self, !Task.isCancelled else { return }
// Sequential handling — see the ordering note on the type.
await self.handle(event)
}
}
}
private func handle(_ event: SSEClientEvent) async {
switch event {
case .stateChanged(let state):
connectionState = state
case .handshake(let resume, _):
// `.ok` = the replay that follows covers the gap. Anything else
// (including the verdict-less first connect) cannot prove
// continuity for this filter set.
if resume != .ok {
recoverFromGap()
}
case .event(let syncEvent):
await route(syncEvent)
}
onStoreActivity?()
}
private func route(_ event: SyncEvent) async {
switch event {
case .messageReceived, .messagesConsumed, .messageCancelled,
.messagesInvalidated, .scheduledMatured:
// Message-stream family → the open window (the controller
// re-checks the session id defensively).
if let controller = windowController {
await controller.onMessageEvent(event)
}
case .sessionRemoved(_, let removedId):
router.route(event, scope: .session(sessionId))
if removedId == sessionId {
// Web `clearMessageWindow` on session-removed.
await windows.clear(sessionId: sessionId)
}
default:
// `session-updated` (detail patch / full session), lifecycle,
// `machine-updated`, `toast` — the shared store fan-out.
router.route(event, scope: .session(sessionId))
}
}
/// `resume: gap` on the session pipe (Android `requestFullResync` with a
/// session key): list + cached details + machines over REST, make sure
/// THIS detail exists even if it was never cached, and catch the window
/// up past any in-flight sync (web `resyncMessages`).
private func recoverFromGap() {
router.requestFullResync()
if sessionStore.detail(for: sessionId) == nil {
let store = sessionStore
let id = sessionId
Task { try? await store.loadSessionDetail(id) }
}
if let controller = windowController {
Task { await controller.syncTail(ensureAfterCurrent: true) }
}
}
}