mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-19 21:09:50 +00:00
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.
223 lines
9.1 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|