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? {

View File

@@ -31,6 +31,10 @@ struct AppEnvironment: Sendable {
/// over the real transports (two-step: RO GET, then WS attach + guarded
/// kill; only runs after the user's explicit confirm, T-iOS-12).
let probe: PairingViewModel.Probe
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
/// `var` + default so the memberwise init stays source-compatible for
/// pre-P1 call sites while tests can inject an in-memory fake.
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
static func production() -> AppEnvironment {
let http = URLSessionHTTPTransport()

View File

@@ -28,12 +28,35 @@ struct RootView: View {
.onChange(of: scenePhase) { _, phase in
coordinator.handleScenePhase(phase)
}
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
} message: {
Text(coordinator.deepLink.hintMessage ?? "")
}
.sheet(
isPresented: $coordinator.isAddHostPresented,
onDismiss: { coordinator.addHostDismissed() }
) {
addHostSheet
}
.sheet(
isPresented: $coordinator.isProjectsPresented,
onDismiss: { coordinator.projectsDismissed() }
) {
projectsSheet
}
}
/// Deep-link hint alert (unknown host / store failure, T-iOS-22).
private var deepLinkHintBinding: Binding<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
}
// MARK: - Route switch
@@ -66,6 +89,22 @@ struct RootView: View {
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
// T-iOS-26 · Projects RootView SessionListScreen
// T-iOS-23 W7 leading
// topBarTrailing hostMenu
.toolbar { projectsToolbarItem }
}
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
}
// MARK: - "" highlight (cold start step 5)
@@ -99,7 +138,17 @@ struct RootView: View {
@ViewBuilder private var terminalDestination: some View {
if let controller = coordinator.terminalController {
TerminalContainerView(controller: controller)
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
)
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
// (new-in-cwd, deep link) swaps the controller while the
// destination stays presented without a new identity SwiftUI
// keeps the old SwiftTerm UIView and the new ViewModel's output
// sink never attaches (makeUIView never re-runs). Also resets the
// container's per-session @State (plan-gate dismissal, timeline).
.id(controller.id)
}
}
@@ -114,6 +163,20 @@ struct RootView: View {
}
}
}
// MARK: - Projects sheet (T-iOS-26)
/// NavigationStack sheet push
/// "" sheet
@ViewBuilder private var projectsSheet: some View {
if let viewModel = coordinator.projectsViewModel {
NavigationStack {
ProjectsScreen(viewModel: viewModel) { request in
coordinator.openProject(request)
}
}
}
}
}
private enum RootMetrics {
@@ -123,4 +186,5 @@ private enum RootMetrics {
private enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
}

View File

@@ -19,15 +19,26 @@ import SwiftUI
/// removes the gate server-side `planGate` goes nil sheet dismisses.
struct TerminalContainerView: View {
let controller: TerminalSessionController
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
/// "" action (RootView supplies the coordinator hop).
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// Epoch of a plan gate the user swiped away suppresses re-present for
/// THAT gate only; a new epoch re-presents automatically.
@State private var dismissedPlanGateEpoch: Int?
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
/// FRESH VM per presentation (each open re-fetches /events).
@State private var timelineViewModel: TimelineViewModel?
/// T-iOS-25 (additive) · Quick-reply palette store per-container over
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
@State private var quickReplyStore = QuickReplyStore()
private enum Metrics {
static let overlaySpacing: CGFloat = 8
static let overlayHorizontalPadding: CGFloat = 12
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone.
static let overlayTopPadding: CGFloat = 52
/// Breathing room between the chip row and the keyboard/key-bar edge.
static let quickReplyBottomPadding: CGFloat = 8
}
private enum Copy {
@@ -35,11 +46,33 @@ struct TerminalContainerView: View {
}
var body: some View {
TerminalScreen(viewModel: controller.terminalViewModel)
.id(controller.generation)
TerminalScreen(
viewModel: controller.terminalViewModel,
onNewSessionInCwd: onNewSessionInCwd
)
.id(controller.generation)
.navigationBarTitleDisplayMode(.inline)
.overlay(alignment: .top) { topOverlays }
.overlay(alignment: .bottom) { quickReplyOverlay }
.sheet(isPresented: planGateBinding) { planGateSheet }
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
}
// MARK: - Quick-reply chips (T-iOS-25, additive)
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
/// while a gate is waiting visibility/read-only logic lives in
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
/// VMs already consume (no extra branch needed).
@ViewBuilder private var quickReplyOverlay: some View {
QuickReplyBar(
terminalViewModel: controller.terminalViewModel,
gateViewModel: controller.gateViewModel,
store: quickReplyStore
)
.padding(.horizontal, Metrics.overlayHorizontalPadding)
.padding(.bottom, Metrics.quickReplyBottomPadding)
.animation(.default, value: controller.gateViewModel.currentGate)
}
// MARK: - Digest + tool gate (top stack)
@@ -51,7 +84,7 @@ struct TerminalContainerView: View {
AwayDigestView(
digest: digest,
isExpanded: gateViewModel.isDigestExpanded,
onExpand: { gateViewModel.expandDigest() },
onExpand: { expandDigestAndPresentTimeline() },
onDismiss: { gateViewModel.dismissDigest() }
)
}
@@ -73,6 +106,21 @@ struct TerminalContainerView: View {
.animation(.default, value: gateViewModel.digest)
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// The digestaffordance does BOTH: inline expansion (which cancels
/// the auto-fade, T-iOS-14 semantics the digest must survive under the
/// sheet) and the full-timeline sheet. No adopted sessionId yet
/// `forSession` returns nil inline expand only (defensive; a digest
/// only ever arrives after `attached`).
private func expandDigestAndPresentTimeline() {
controller.gateViewModel.expandDigest()
timelineViewModel = TimelineViewModel.forSession(
controller.terminalViewModel.sessionId,
source: controller.timelineEventsSource
)
}
// MARK: - Plan gate sheet
private var hasDismissedPendingPlanGate: Bool {

View File

@@ -37,9 +37,26 @@ final class TerminalSessionController: Identifiable {
private(set) var terminalViewModel: TerminalViewModel
private(set) var gateViewModel: GateViewModel
@ObservationIgnored private let host: HostRegistry.Host
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
/// (the host to reopen against a controller is host-bound for life).
@ObservationIgnored let host: HostRegistry.Host
@ObservationIgnored private let environment: AppEnvironment
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title)
/// re-attached to every rebuilt TerminalViewModel so a background
/// foreground rebuild never silently drops the list-title feed.
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
/// T-iOS-26 · fresh-spawn `attach(null, cwd)`
/// sessionId nil cwd
/// T-iOS-29 coordinator fresh spawn
/// new-in-cwd cwd 退
@ObservationIgnored let spawnCwd: String?
/// T-iOS-26 · attach `claude\r`engine
/// attach-first attach 线 id
/// suspendresume
@ObservationIgnored private let bootstrapInput: String?
/// open(+bootstrap) ProjectOpenWiringTests
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
@ObservationIgnored private var engine: SessionEngine
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
@ObservationIgnored private var bridge: SessionActivityBridge
@@ -62,11 +79,17 @@ final class TerminalSessionController: Identifiable {
host: HostRegistry.Host,
sessionId: UUID?,
environment: AppEnvironment,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
spawnCwd: String? = nil,
bootstrapInput: String? = nil
) {
self.host = host
self.environment = environment
self.onPendingChanged = onPendingChanged
self.onTitleChanged = onTitleChanged
self.spawnCwd = spawnCwd
self.bootstrapInput = bootstrapInput
self.targetSessionId = sessionId
let stack = Self.makeStack(
host: host, environment: environment, onPendingChanged: onPendingChanged
@@ -76,6 +99,7 @@ final class TerminalSessionController: Identifiable {
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged
}
// MARK: - Lifecycle entry points
@@ -86,9 +110,7 @@ final class TerminalSessionController: Identifiable {
guard !hasStarted, !isTornDown else { return }
hasStarted = true
startConsumers()
let engine = engine
let sessionId = targetSessionId
Task { await engine.open(sessionId: sessionId, cwd: nil) }
openEngineAtTarget()
}
/// scenePhase `.background`: clean detach (PTY keeps running).
@@ -112,9 +134,23 @@ final class TerminalSessionController: Identifiable {
isSuspended = false
rebuildStack()
startConsumers()
openEngineAtTarget()
}
/// (Re)open attachfresh spawn id
/// nil cwd attach + attach bootstrapT-iOS-26suspend
/// id bootstrap
private func openEngineAtTarget() {
let engine = engine
let sessionId = targetSessionId
Task { await engine.open(sessionId: sessionId, cwd: nil) }
let cwd = sessionId == nil ? spawnCwd : nil
let bootstrap = sessionId == nil ? bootstrapInput : nil
openTask = Task {
await engine.open(sessionId: sessionId, cwd: cwd)
if let bootstrap {
await engine.send(.input(data: bootstrap))
}
}
}
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
@@ -136,6 +172,15 @@ final class TerminalSessionController: Identifiable {
Task { await engine.close() }
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// Per-host timeline fetcher for the container's `TimelineSheet` the
/// same `APIClient.events` wrapper the engine's away-digest uses; the
/// single derivation point stays `AppEnvironment.makeEventsSource`.
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
environment.makeEventsSource(endpoint: host.endpoint)
}
// MARK: - Stack assembly
private struct Stack {
@@ -186,6 +231,7 @@ final class TerminalSessionController: Identifiable {
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
generation += 1 // new SwiftUI identity fresh SwiftTerm view
}

View File

@@ -0,0 +1,44 @@
import Foundation
/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks.
/// The ledger itself is persistence-agnostic (plan §7) the App layer wires
/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain =
/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`.
protocol UnreadWatermarkStore: Sendable {
func load() -> [UUID: Int]
func save(_ watermarks: [UUID: Int])
}
/// UserDefaults-backed implementation with an injectable suite for tests
/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`:
/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable
/// state of its own.
struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
private static let key = "unreadWatermarks"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func load() -> [UUID: Int] {
guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
return [:]
}
// Stored data crosses a storage boundary validate every key; garbage
// is dropped, never trusted (two case-variant spellings of one UUID
// collapse via max, so this can never crash on duplicates).
let pairs = raw.compactMap { key, value in
UUID(uuidString: key).map { ($0, value) }
}
return Dictionary(pairs, uniquingKeysWith: max)
}
func save(_ watermarks: [UUID: Int]) {
let plist = Dictionary(
uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) }
)
defaults.set(plist, forKey: Self.key)
}
}