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:
@@ -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 后
|
||||
/// 的重开(suspend→resume)不再注入。
|
||||
@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 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为
|
||||
/// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend
|
||||
/// 前已采纳的 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user