Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/SyncEventRouting.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

101 lines
4.3 KiB
Swift

import Foundation
import HapiProtocol
/// Fan-out from an SSE subscription's event stream to the stores — the
/// session-list half of the event routing, mirroring the global-scope rules
/// of `web/src/hooks/useSSE.ts` via the Android port
/// (`SyncEventRouter` + `StoreSyncTargets`):
///
/// - session lifecycle events → `SessionListStoring.applySessionEvent`;
/// - `machine-updated` → `MachineListStoring.applyMachineEvent`;
/// - global-scope message-stream events refresh the session list where the
/// web invalidates it (`messages-invalidated`, `messages-consumed`,
/// `message-cancelled`, `scheduled-matured`, and a `message-received`
/// carrying `scheduledAt` — they all move the hub-computed
/// scheduled/queued fields the client cannot derive);
/// - `toast` → the injected callback;
/// - a `gap` handshake verdict triggers the full REST resync (session list +
/// cached details + machines); `ok` means the hub replays every missed
/// event right after the handshake, so the REST resync is skipped;
/// - `heartbeat`/`connection-changed`/unknown types are engine/no-op
/// territory here.
///
/// The app's `HubSession` owns one of these per hub and calls
/// ``handleHandshake(resume:)`` / ``route(_:scope:)`` from its SSE consume
/// loop. Since M2f the app's per-chat `ChatSession` reuses this router for
/// the session-scope pipe's non-message events and delivers the
/// message-stream family to the open `MessageWindowController` itself —
/// awaiting each ingest from its single consume task, which is what
/// preserves SSE arrival order into the window actor (the role the Android
/// port's `StoreSyncTargets` channel plays).
@MainActor
public struct SyncEventRouter {
private let sessions: any SessionListStoring
private let machines: any MachineListStoring
private let onToast: @MainActor (ToastPayload) -> Void
public init(
sessions: any SessionListStoring,
machines: any MachineListStoring,
onToast: @escaping @MainActor (ToastPayload) -> Void = { _ in }
) {
self.sessions = sessions
self.machines = machines
self.onToast = onToast
}
/// `resume == .ok` skips the resync (the replay that follows covers every
/// missed event); `.gap` — or, defensively, anything else — triggers the
/// full refetch. (`SSEClient` already maps an absent wire verdict to
/// `.gap`, per the contract's absence rule.)
public func handleHandshake(resume: ResumeVerdict?) {
guard resume != .ok else { return }
requestFullResync()
}
/// Full REST resync: session list + cached details, then machines.
/// Failures are swallowed — offline keeps the snapshot state and the
/// next reconnect retries.
public func requestFullResync() {
let sessions = self.sessions
let machines = self.machines
Task { @MainActor in
try? await sessions.fullResync()
try? await machines.refresh()
}
}
/// Routes one decoded `SyncEvent` from the subscription identified by
/// `scope` (the dual-subscription model: the global pipe drives the
/// list; per-chat `.session` pipes arrive with M2f and their
/// message-stream events belong to the message window, not the list).
public func route(_ event: SyncEvent, scope: SSEScope = .global) {
switch event {
case .sessionAdded, .sessionUpdated, .sessionRemoved, .sessionEnded:
sessions.applySessionEvent(event)
case .machineUpdated(_, let machineId, let data):
machines.applyMachineEvent(machineId: machineId, data: data)
case .messagesInvalidated, .messagesConsumed, .messageCancelled, .scheduledMatured:
guard scope == .global else { return }
sessions.scheduleRefresh()
case .messageReceived(_, _, let message):
guard scope == .global else { return }
if message.scheduledAt != nil {
sessions.scheduleRefresh()
}
case .toast(_, let payload):
onToast(payload)
case .heartbeat, .connectionChanged, .unknown:
// Heartbeats feed the SSE watchdog; the handshake surfaces
// through `handleHandshake`; unknown types are forward-compat
// no-ops.
break
}
}
}