Files
web-terminal/ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

404 lines
16 KiB
Swift

import APIClient
import Foundation
import HostRegistry
import Observation
import SessionCore
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?
/// Sanitized OSC title (T-iOS-23) nil = no title, row falls back to
/// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
let title: String?
/// `lastOutputAt` strictly newer than the local last-seen watermark
/// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
let isUnread: Bool
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 let unreadStore: any UnreadWatermarkStore
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
/// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
/// NOT pruned to the active host's fetch other hosts' watermarks must
/// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
@ObservationIgnored private var unreadLedger: UnreadLedger
/// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only replay
/// re-emits the OSC sequence on next attach, persistence would only keep
/// stale titles. Other hosts' entries never match a visible row.
@ObservationIgnored private var sessionTitles: [UUID: String] = [:]
@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>,
unreadStore: any UnreadWatermarkStore,
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
) {
self.hostStore = hostStore
self.http = http
self.clock = clock
self.unreadStore = unreadStore
self.nowMs = nowMs
unreadLedger = UnreadLedger(watermarks: unreadStore.load())
}
// 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: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)
/// The user just looked at (or is about to look at) this session: stamp
/// the last-seen watermark NOW and persist it. Called on row tap
/// (`openSession`) and by the coordinator when a terminal closes output
/// that streamed while the user was watching must not relight the dot.
func markSeen(sessionId: UUID) {
unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
unreadStore.save(unreadLedger.watermarks)
rebuildRows()
}
// MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)
/// Surface a terminal-reported OSC title on the session's list row.
/// `title` is UNTRUSTED (host/attacker-controlled OSC payload) sanitized
/// again at THIS boundary regardless of upstream (sanitize is idempotent).
/// Empty after sanitisation clears the entry (web `title.trim() || null`).
func setSessionTitle(sessionId: UUID, title: String) {
let sanitized = TitleSanitizer.sanitize(title)
sessionTitles = sanitized.isEmpty
? sessionTitles.filter { $0.key != sessionId }
: sessionTitles.merging([sessionId: sanitized]) { _, new in new }
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.
/// Tapping = seeing: the unread watermark is stamped immediately (the
/// terminal replays everything anyway; the dot must not outlive the tap).
func openSession(id: UUID) {
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
markSeen(sessionId: id)
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),
title: sessionTitles[info.id],
isUnread: unreadLedger.isUnread(
sessionId: info.id, lastOutputAt: info.lastOutputAt
)
)
}
}
/// 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)"
}
}