import Foundation import Observation import SessionCore import UIKit import WireProtocol /// Haptic seam (T-iOS-14): gate-arrival feedback is injected so tests can /// assert "exactly once per gate epoch" without UIKit hardware. @MainActor protocol HapticSignaling { /// A NEW gate (fresh epoch) is waiting for the user's decision. func gateDidArrive() } /// Production haptics: notification-style buzz — a gate is Claude asking for a /// decision (THE steering primitive), not a mere UI tick. @MainActor final class GateHaptics: HapticSignaling { private let generator = UINotificationFeedbackGenerator() func gateDidArrive() { generator.notificationOccurred(.warning) } } /// T-iOS-14 · Gate + away-digest state (plan §3.5): a STANDALONE /// `@MainActor @Observable` VM consuming the SAME `SessionEvent` stream as /// `TerminalViewModel` — `.gate`/`.digest` are its domain, everything else is /// ignored here. TerminalScreen wiring (who fans the stream out) is T-iOS-15. /// /// Stale-tap guard (FIRST line): every rendered gate button carries the epoch /// of the gate it was rendered against; `decide` compares that epoch — and the /// affordance — against the LATEST gate event received and silently drops a /// mismatch, so a slow tap can never resolve a NEWER gate than the one that /// was on screen (mirrors the web's pendingEpoch nonce, /// public/terminal-session.ts:98-102). The engine's `GateTracker.canDecide` /// is the SECOND line (SessionEngine type doc) — both must agree to send. /// /// Plan-gate mapping is frozen in SessionCore /// (`GateState.Affordance.clientMessage`, mirror of public/tabs.ts:345-347): /// Approve+Auto → acceptEdits, Approve+Review → default, Keep Planning → /// reject. There is NO allowAutoMode gating anywhere — the web client never /// gates the plan three-way, and `uiConfig` is reserved for a future /// permission-mode picker (plan §3.1 note). @MainActor @Observable final class GateViewModel { // MARK: - Observable UI state /// The latest gate event received (nil = lifted). This is what taps are /// validated against. private(set) var currentGate: GateState? /// Visible away digest (nil = nothing rendered; all-zero digests never /// become visible). private(set) var digest: AwayDigest? /// True once the user opened the recent-entries detail; expansion cancels /// the auto-fade (a digest must never vanish while being read). private(set) var isDigestExpanded = false /// Tool gate → two-button `GateBanner`. var toolGate: GateState? { gate(of: .tool) } /// Plan gate → three-way `PlanGateSheet`. var planGate: GateState? { gate(of: .plan) } // MARK: - Dependencies & plumbing (not observed) @ObservationIgnored private let engine: SessionEngine @ObservationIgnored private let events: AsyncStream @ObservationIgnored private let haptics: any HapticSignaling @ObservationIgnored private let clock: any Clock @ObservationIgnored private var consumeTask: Task? @ObservationIgnored private var fadeTask: Task? /// Highest epoch that already buzzed — the haptic fires ONCE per epoch /// (sustained-pending refreshes reuse the epoch and stay silent). @ObservationIgnored private var lastHapticEpoch = 0 /// Ordered decision queue: taps are synchronous, `engine.send` is async — /// one pump task preserves submission order (same pattern as /// TerminalViewModel's send pump). @ObservationIgnored private var decisionQueue: [ClientMessage] = [] @ObservationIgnored private var isPumping = false // MARK: - Test-visible diagnostics & deterministic barriers (internal) /// Events applied so far — `waitUntilProcessed` barrier counter. @ObservationIgnored private(set) var processedEventCount = 0 /// Decisions handed to the engine — `waitUntilForwarded` barrier counter. @ObservationIgnored private(set) var forwardedDecisionCount = 0 /// Taps dropped by the first-line guard (stale epoch / lifted gate / /// affordance no longer offered). @ObservationIgnored private(set) var droppedStaleDecisionCount = 0 /// Completed auto-fades — `waitUntilFadeCompleted` barrier counter. @ObservationIgnored private(set) var fadeCompletedCount = 0 /// Test tap, called after each event is applied (state already coherent). @ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)? private struct CountWaiter { let target: Int let continuation: CheckedContinuation } @ObservationIgnored private var eventWaiters: [CountWaiter] = [] @ObservationIgnored private var decisionWaiters: [CountWaiter] = [] @ObservationIgnored private var fadeWaiters: [CountWaiter] = [] // MARK: - Lifecycle /// - Parameters: /// - engine: send-side dependency (decisions only — open/close belong to /// the T-iOS-15 lifecycle owner). /// - events: the event stream to consume; the T-iOS-15 wiring passes a /// fan-out branch of `engine.events` shared with `TerminalViewModel`. /// - haptics: gate-arrival feedback (production: `GateHaptics`). /// - clock: drives the digest auto-fade (production: `ContinuousClock`). init( engine: SessionEngine, events: AsyncStream, haptics: any HapticSignaling, clock: any Clock ) { self.engine = engine self.events = events self.haptics = haptics self.clock = clock } /// Begin consuming events. Idempotent — a second call is a no-op. func start() { guard consumeTask == nil else { return } consumeTask = Task { [weak self] in guard let events = self?.events else { return } for await event in events { guard let self else { return } self.apply(event) } self?.releaseAllWaiters() // stream over — never leave a test hanging } } /// Stop consuming (screen torn down); cancels the fade timer too. func stop() { consumeTask?.cancel() consumeTask = nil cancelFade() releaseAllWaiters() } // MARK: - Decisions (Approve / Reject / plan three-way) /// Resolve the held gate with `affordance`, tapped against the gate whose /// `epoch` the button rendered. First-line stale guard: the epoch must /// match the LATEST gate received AND the affordance must still be one the /// current gate offers (a same-epoch kind morph invalidates old buttons). /// Mismatch → dropped, nothing is sent. func decide(_ affordance: GateState.Affordance, epoch: Int) { guard let gate = currentGate, gate.epoch == epoch, gate.affordances.contains(affordance) else { droppedStaleDecisionCount += 1 return } decisionQueue = decisionQueue + [affordance.clientMessage] pumpIfIdle() } // MARK: - Digest interactions /// Show the recent-entries detail; cancels the auto-fade so the digest /// never vanishes while being read (documented decision). func expandDigest() { guard digest != nil else { return } isDigestExpanded = true cancelFade() } /// Explicitly dismiss the digest (the ✕ on the summary row). func dismissDigest() { digest = nil isDigestExpanded = false cancelFade() } // MARK: - Event application (MainActor) private func apply(_ event: SessionEvent) { switch event { case .gate(let gate): applyGate(gate) case .digest(let digest): applyDigest(digest) case .connection, .adopted, .output, .exited, .telemetry: break // TerminalViewModel's domain (T-iOS-11) } processedEventCount += 1 onEventApplied?(event) eventWaiters = drainWaiters(eventWaiters, reached: processedEventCount) } private func applyGate(_ gate: GateState?) { currentGate = gate guard let gate, gate.epoch > lastHapticEpoch else { return } lastHapticEpoch = gate.epoch // exactly one buzz per epoch haptics.gateDidArrive() } /// All-zero digest → render nothing (spec). A visible digest starts /// collapsed and auto-fades after `Tunables.digestFadeDelay` unless the /// user expands it first. private func applyDigest(_ incoming: AwayDigest) { guard !incoming.isEmpty else { return } cancelFade() digest = incoming isDigestExpanded = false scheduleFade() } private func gate(of kind: GateKind) -> GateState? { guard let currentGate, currentGate.kind == kind else { return nil } return currentGate } // MARK: - Digest auto-fade (injected clock; zero real waits in tests) private func scheduleFade() { let clock = self.clock fadeTask = Task { [weak self] in do { try await clock.sleep(for: Tunables.digestFadeDelay, tolerance: nil) } catch { return // cancelled: expanded, dismissed, replaced, or stopped } self?.completeFade() } } private func completeFade() { guard !isDigestExpanded else { return } // expand cancels; belt & braces digest = nil fadeTask = nil fadeCompletedCount += 1 fadeWaiters = drainWaiters(fadeWaiters, reached: fadeCompletedCount) } private func cancelFade() { fadeTask?.cancel() fadeTask = nil } // MARK: - Ordered decision pump private func pumpIfIdle() { guard !isPumping else { return } isPumping = true Task { await self.pumpDecisions() } } private func pumpDecisions() async { while let next = decisionQueue.first { decisionQueue = Array(decisionQueue.dropFirst()) await engine.send(next) forwardedDecisionCount += 1 decisionWaiters = drainWaiters(decisionWaiters, reached: forwardedDecisionCount) } isPumping = false } // MARK: - Deterministic test barriers (no polling, no real sleeps) /// Suspends until at least `eventCount` events have been applied. func waitUntilProcessed(eventCount target: Int) async { await withCheckedContinuation { continuation in guard processedEventCount < target else { continuation.resume() return } eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)] } } /// Suspends until at least `decisionCount` decisions reached the engine. func waitUntilForwarded(decisionCount target: Int) async { await withCheckedContinuation { continuation in guard forwardedDecisionCount < target else { continuation.resume() return } decisionWaiters = decisionWaiters + [CountWaiter(target: target, continuation: continuation)] } } /// Suspends until at least `count` auto-fades have completed. func waitUntilFadeCompleted(count target: Int) async { await withCheckedContinuation { continuation in guard fadeCompletedCount < target else { continuation.resume() return } fadeWaiters = fadeWaiters + [CountWaiter(target: target, continuation: continuation)] } } /// Resumes every waiter satisfied by `count`; returns the still-waiting /// remainder (immutable style — the caller reassigns). private func drainWaiters(_ waiters: [CountWaiter], reached count: Int) -> [CountWaiter] { let satisfied = waiters.filter { $0.target <= count } for waiter in satisfied { waiter.continuation.resume() } return waiters.filter { $0.target > count } } private func releaseAllWaiters() { let all = eventWaiters + decisionWaiters + fadeWaiters eventWaiters = [] decisionWaiters = [] fadeWaiters = [] for waiter in all { waiter.continuation.resume() } } }