feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal file
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal file
@@ -0,0 +1,319 @@
|
||||
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<SessionEvent>
|
||||
@ObservationIgnored private let haptics: any HapticSignaling
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
@ObservationIgnored private var consumeTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var fadeTask: Task<Void, Never>?
|
||||
/// 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<Void, Never>
|
||||
}
|
||||
|
||||
@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<SessionEvent>,
|
||||
haptics: any HapticSignaling, clock: any Clock<Duration>
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user