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:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user