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
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -2,6 +2,7 @@ import APIClient
import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
@@ -56,6 +57,12 @@ final class SessionListViewModel {
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 {
@@ -105,10 +112,19 @@ final class SessionListViewModel {
@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
@@ -120,12 +136,15 @@ final class SessionListViewModel {
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)
@@ -230,6 +249,32 @@ final class SessionListViewModel {
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 {
@@ -258,8 +303,11 @@ final class SessionListViewModel {
}
/// 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)
}
@@ -272,7 +320,11 @@ final class SessionListViewModel {
SessionRow(
info: info,
isPendingApproval: pendingSessionIds.contains(info.id),
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
title: sessionTitles[info.id],
isUnread: unreadLedger.isUnread(
sessionId: info.id, lastOutputAt: info.lastOutputAt
)
)
}
}