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 /// Injected time source for telemetry staleness (ms since epoch). @ObservationIgnored private let nowMs: @Sendable () -> Int @ObservationIgnored private var pollTask: Task? @ObservationIgnored private var latestFetched: [LiveSessionInfo] = [] @ObservationIgnored private var hiddenKilledIds: Set = [] @ObservationIgnored private var pendingSessionIds: Set = [] @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, 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 } @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)" } }