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:
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal file
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal file
@@ -0,0 +1,351 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
|
||||
/// one glance answers "do I need to step in?" — status dot / ⚠ pending badge,
|
||||
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
|
||||
/// into `TerminalScreen`.
|
||||
///
|
||||
/// Polling: while the screen is visible, `GET /live-sessions` every
|
||||
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
|
||||
/// paced on an injected `Clock` (FakeClock in tests — zero real waits).
|
||||
/// `disappeared()` cancels the poll task; the leak test asserts the parked
|
||||
/// sleeper is reclaimed.
|
||||
///
|
||||
/// Documented decisions:
|
||||
/// - **Pending ⚠ is an overlay, not a list field**: `LiveSessionInfo`
|
||||
/// (src/types.ts:246-256) carries NO `pending` — that signal only exists on
|
||||
/// the WS status side-channel (the server's pendingApprovals), exactly like
|
||||
/// the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
|
||||
/// The T-iOS-15 wiring forwards gate/status events into
|
||||
/// `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
|
||||
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
|
||||
/// fetch minus `hiddenKilledIds`, so rollback restores the exact original
|
||||
/// position, and a poll racing the DELETE cannot resurrect the row. A 404
|
||||
/// on DELETE means "already gone" and counts as success.
|
||||
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
|
||||
/// a blank screen on one dropped packet.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SessionListViewModel {
|
||||
// MARK: - Row model
|
||||
|
||||
/// What the row leads with: the held-approval badge outranks the dot.
|
||||
enum StatusIndicator: Equatable, Sendable {
|
||||
case pendingApproval
|
||||
case status(ClaudeStatus)
|
||||
|
||||
/// Mirrors public/tabs.ts:74-80 `claudeIcon`; ⚠ for a held approval.
|
||||
var glyph: String {
|
||||
switch self {
|
||||
case .pendingApproval: return "⚠"
|
||||
case .status(.working): return "⚙"
|
||||
case .status(.waiting): return "⏳"
|
||||
case .status(.idle): return "✓"
|
||||
case .status(.stuck): return "⚠"
|
||||
case .status(.unknown): return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable render snapshot of one session (rebuilt wholesale per fetch).
|
||||
struct SessionRow: Equatable, Identifiable, Sendable {
|
||||
let info: LiveSessionInfo
|
||||
let isPendingApproval: Bool
|
||||
let telemetry: TelemetryChips.Model?
|
||||
|
||||
var id: UUID { info.id }
|
||||
var indicator: StatusIndicator {
|
||||
isPendingApproval ? .pendingApproval : .status(info.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
|
||||
/// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
|
||||
/// means repeated taps re-fire `onChange` observers.
|
||||
struct OpenRequest: Equatable, Sendable, Identifiable {
|
||||
let id: UUID
|
||||
let host: HostRegistry.Host
|
||||
let sessionId: UUID?
|
||||
}
|
||||
|
||||
enum EmptyState: Equatable {
|
||||
/// No hosts in the store — pairing is the only next step.
|
||||
case notPaired
|
||||
/// Paired + fetched successfully, but nothing is running.
|
||||
case noSessions
|
||||
}
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var hosts: [HostRegistry.Host] = []
|
||||
private(set) var activeHost: HostRegistry.Host?
|
||||
private(set) var rows: [SessionRow] = []
|
||||
/// Last poll failed (stale rows stay visible). Cleared by the next success.
|
||||
private(set) var fetchErrorMessage: String?
|
||||
/// Last kill failed (row already rolled back).
|
||||
private(set) var killErrorMessage: String?
|
||||
/// Host store read failed — explicit, never a silent empty list.
|
||||
private(set) var hostsErrorMessage: String?
|
||||
private(set) var openRequest: OpenRequest?
|
||||
|
||||
var emptyState: EmptyState? {
|
||||
if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
|
||||
if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dependencies & internal state (not observed)
|
||||
|
||||
@ObservationIgnored private let hostStore: any HostStore
|
||||
@ObservationIgnored private let http: any HTTPTransport
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
/// Injected time source for telemetry staleness (ms since epoch).
|
||||
@ObservationIgnored private let nowMs: @Sendable () -> Int
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
|
||||
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
|
||||
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
|
||||
@ObservationIgnored private var hasAttemptedHostLoad = false
|
||||
@ObservationIgnored private var hasLoadedOnce = false
|
||||
|
||||
private var apiClient: APIClient? {
|
||||
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
|
||||
}
|
||||
|
||||
init(
|
||||
hostStore: any HostStore,
|
||||
http: any HTTPTransport,
|
||||
clock: any Clock<Duration>,
|
||||
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
|
||||
) {
|
||||
self.hostStore = hostStore
|
||||
self.http = http
|
||||
self.clock = clock
|
||||
self.nowMs = nowMs
|
||||
}
|
||||
|
||||
// MARK: - Visibility lifecycle (poll task owner)
|
||||
|
||||
/// Screen became visible: load hosts, then poll every `listPollInterval`.
|
||||
/// Idempotent — a second call while polling is a no-op.
|
||||
func appeared() {
|
||||
guard pollTask == nil else { return }
|
||||
pollTask = Task { [weak self] in
|
||||
await self?.runPollLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen left: cancel the poll task (its parked sleep throws
|
||||
/// `CancellationError` and the loop exits — no leaked timer).
|
||||
func disappeared() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
releaseFetchWaiters()
|
||||
}
|
||||
|
||||
private func runPollLoop() async {
|
||||
await reloadHosts()
|
||||
while !Task.isCancelled {
|
||||
await refreshOnce()
|
||||
do {
|
||||
try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
|
||||
} catch {
|
||||
return // cancelled while parked — the only way sleep throws
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hosts (multi-host header hook)
|
||||
|
||||
/// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
|
||||
/// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
|
||||
/// up renames), else falls back to the first host.
|
||||
func reloadHosts() async {
|
||||
do {
|
||||
hosts = try await hostStore.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hosts = []
|
||||
hostsErrorMessage = SessionListCopy.hostsLoadFailed
|
||||
}
|
||||
hasAttemptedHostLoad = true
|
||||
activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
|
||||
}
|
||||
|
||||
/// Switch the list to another paired host: reset per-host state and fetch
|
||||
/// immediately (the poll loop keeps its own cadence).
|
||||
func selectHost(id: UUID) async {
|
||||
guard let host = hosts.first(where: { $0.id == id }),
|
||||
host.id != activeHost?.id else { return }
|
||||
activeHost = host
|
||||
latestFetched = []
|
||||
hiddenKilledIds = []
|
||||
pendingSessionIds = []
|
||||
hasLoadedOnce = false
|
||||
fetchErrorMessage = nil
|
||||
killErrorMessage = nil
|
||||
rebuildRows()
|
||||
await refreshOnce()
|
||||
}
|
||||
|
||||
// MARK: - Fetch (poll tick + pull-to-refresh share one path)
|
||||
|
||||
/// Manual refresh (pull-to-refresh).
|
||||
func refresh() async {
|
||||
await refreshOnce()
|
||||
}
|
||||
|
||||
private func refreshOnce() async {
|
||||
defer {
|
||||
completedFetchCount += 1
|
||||
resumeFetchWaiters()
|
||||
}
|
||||
guard let client = apiClient else { return }
|
||||
do {
|
||||
let sessions = try await client.liveSessions()
|
||||
let fetchedIds = Set(sessions.map(\.id))
|
||||
latestFetched = sessions
|
||||
// Prune overlays for sessions the server no longer reports.
|
||||
hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
|
||||
pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
|
||||
fetchErrorMessage = nil
|
||||
hasLoadedOnce = true
|
||||
} catch {
|
||||
// Keep the stale rows — a blank list on one dropped poll is worse.
|
||||
fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
|
||||
}
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Pending ⚠ overlay (fed by the WS status side-channel, T-iOS-15)
|
||||
|
||||
func setPendingApproval(sessionId: UUID, pending: Bool) {
|
||||
pendingSessionIds = pending
|
||||
? pendingSessionIds.union([sessionId])
|
||||
: pendingSessionIds.subtracting([sessionId])
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Swipe-to-kill (optimistic + rollback)
|
||||
|
||||
func kill(sessionId: UUID) async {
|
||||
guard let client = apiClient,
|
||||
latestFetched.contains(where: { $0.id == sessionId }) else { return }
|
||||
killErrorMessage = nil
|
||||
hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
|
||||
rebuildRows()
|
||||
do {
|
||||
try await client.killSession(id: sessionId)
|
||||
} catch APIClientError.sessionNotFound {
|
||||
// Already gone (exited/reaped/killed elsewhere) — removal stands.
|
||||
} catch {
|
||||
hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
|
||||
rebuildRows()
|
||||
killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation signals
|
||||
|
||||
/// "+ New session" → `sessionId: nil` (server spawns a fresh PTY).
|
||||
func requestNewSession() {
|
||||
guard let activeHost else { return }
|
||||
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
|
||||
}
|
||||
|
||||
/// Open a listed session. Unknown ids are refused at the boundary.
|
||||
func openSession(id: UUID) {
|
||||
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
|
||||
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
|
||||
}
|
||||
|
||||
// MARK: - Row derivation (immutable rebuild, single owner)
|
||||
|
||||
private func rebuildRows() {
|
||||
let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
|
||||
let now = nowMs()
|
||||
rows = Self.groupingExitedLast(visible).map { info in
|
||||
SessionRow(
|
||||
info: info,
|
||||
isPendingApproval: pendingSessionIds.contains(info.id),
|
||||
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the server's order (newest-first, src/session/manager.ts:194) but
|
||||
/// group `exited:true` at the bottom — order preserved inside each group.
|
||||
static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
|
||||
sessions.filter { !$0.exited } + sessions.filter(\.exited)
|
||||
}
|
||||
|
||||
// MARK: - Error copy helpers
|
||||
|
||||
/// `APIClientError` already carries user-facing copy; transport-level
|
||||
/// errors fall back to their localized description.
|
||||
private static func errorDetail(_ error: any Error) -> String {
|
||||
(error as? APIClientError)?.message ?? error.localizedDescription
|
||||
}
|
||||
|
||||
private nonisolated static let millisecondsPerSecond = 1_000.0
|
||||
|
||||
nonisolated static func currentTimeMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
|
||||
}
|
||||
|
||||
// MARK: - Deterministic test barriers (same pattern as TerminalViewModel)
|
||||
|
||||
/// Completed fetch attempts (successful or not, including no-host ticks).
|
||||
@ObservationIgnored private(set) var completedFetchCount = 0
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
|
||||
|
||||
/// Suspends until at least `count` fetches have completed.
|
||||
func waitUntilFetches(count target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard completedFetchCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeFetchWaiters() {
|
||||
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
|
||||
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseFetchWaiters() {
|
||||
let all = fetchWaiters
|
||||
fetchWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing session-list copy (plan §4: explicit errors, actionable 话术).
|
||||
enum SessionListCopy {
|
||||
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
|
||||
|
||||
static func fetchFailed(_ detail: String) -> String {
|
||||
"刷新会话列表失败:\(detail)"
|
||||
}
|
||||
|
||||
static func killFailed(_ detail: String) -> String {
|
||||
"结束会话失败:\(detail)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user