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
352 lines
13 KiB
Swift
352 lines
13 KiB
Swift
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)"
|
|
}
|
|
}
|