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
404 lines
16 KiB
Swift
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)"
|
|
}
|
|
}
|