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 Foundation
import HostRegistry
import Observation
import SwiftUI
import WireProtocol
/// T-iOS-15 · Navigation + lifecycle owner: Pairing SessionList Terminal
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
@@ -25,16 +26,25 @@ final class AppCoordinator {
/// Add-host pairing VM (sheet from the list header).
private(set) var addHostPairingViewModel: PairingViewModel?
var isAddHostPresented = false
/// T-iOS-26 · Projects sheet RootView toolbar
/// `SessionListScreen` W7 T-iOS-23 VM
/// prefs
private(set) var projectsViewModel: ProjectsViewModel?
var isProjectsPresented = false
let sessionList: SessionListViewModel
@ObservationIgnored let environment: AppEnvironment
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
init(environment: AppEnvironment) {
self.environment = environment
sessionList = SessionListViewModel(
hostStore: environment.hostStore,
http: environment.http,
clock: ContinuousClock()
clock: ContinuousClock(),
unreadStore: environment.unreadStore
)
}
@@ -54,6 +64,7 @@ final class AppCoordinator {
if route == .pairing {
rootPairingViewModel = makePairingViewModel()
}
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
}
/// First-run pairing done move to the list (the paired host is already
@@ -83,30 +94,110 @@ final class AppCoordinator {
Task { await sessionList.reloadHosts() }
}
// MARK: - Projects (T-iOS-26)
/// Toolbar RootView Projects sheet
func presentProjects() {
guard let host = sessionList.activeHost else { return }
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
isProjectsPresented = true
}
/// Sheet OR VM
func projectsDismissed() {
projectsViewModel = nil
}
/// "" sheet fresh spawn`attach(null, cwd)`+
/// attach `claude\r` engine attach-first
func openProject(_ request: ProjectOpenRequest) {
guard terminalController == nil else { return } // one foreground session
isProjectsPresented = false
projectsViewModel = nil
startTerminal(
host: request.host, sessionId: nil,
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
)
}
// MARK: - Terminal open/close
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
/// "" banner both land here. `sessionId == nil` = new session.
func open(_ request: SessionListViewModel.OpenRequest) {
guard terminalController == nil else { return } // one foreground session
startTerminal(host: request.host, sessionId: request.sessionId)
}
/// controller T-iOS-26 spawn
private func startTerminal(
host: HostRegistry.Host,
sessionId: UUID?,
spawnCwd: String? = nil,
bootstrapInput: String? = nil
) {
let controller = TerminalSessionController(
host: request.host,
sessionId: request.sessionId,
host: host,
sessionId: sessionId,
environment: environment,
onPendingChanged: { [weak self] sessionId, pending in
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
}
},
onTitleChanged: { [weak self] sessionId, title in
// T-iOS-23 · OSC title list row (already sanitized in the
// VM; the list VM sanitizes once more at its own boundary).
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
},
spawnCwd: spawnCwd,
bootstrapInput: bootstrapInput
)
terminalController = controller
controller.start()
}
/// Back navigation popped the terminal: explicit detach.
/// Back navigation popped the terminal: explicit detach. Also the first
/// half of every session SWITCH (single live WS invariant, plan §1):
/// list back-nav and `openDeepLinkedSession` both close here before the
/// next `open` one engine at a time, always closeopen with replay.
func closeTerminal() {
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
// adopted session so output watched in the terminal never relights
// the list dot.
if let sessionId = terminalController?.terminalViewModel.sessionId {
sessionList.markSeen(sessionId: sessionId)
}
terminalController?.teardown()
terminalController = nil
}
// MARK: - "" (T-iOS-29)
/// cwd/live-sessions server-adopted
/// id VM controller `spawnCwd`T-iOS-26
/// fresh-spawn nil
/// ProjectsViewModel engine
var currentTerminalCwd: String? {
guard let controller = terminalController else { return nil }
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
sessionList.rows.first(where: { $0.id == id })?.info.cwd
}
let candidate = fromRows ?? controller.spawnCwd
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
}
/// TerminalScreen exit cwd fresh
/// spawn`attach(null, cwd)` web tabs.ts `newTab()` M6
/// WS `closeTerminal()` last-seen engine
/// detach controllercwd
/// no-op bootstrap " shell"" claude"
func openNewSessionInCurrentCwd() {
guard let controller = terminalController else { return }
let host = controller.host
let cwd = currentTerminalCwd
closeTerminal()
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
}
// MARK: - "" (cold start step 5)
var continueLastSessionId: UUID? {