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

@@ -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
}