import SessionCore import SwiftUI /// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the /// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel — /// the "接入 TerminalScreen 的 wiring 归 T-iOS-15" hand-off. /// /// Layout decisions (documented): /// - `.id(controller.generation)`: a rebuild (background→foreground) swaps the /// ViewModels; without a new identity SwiftUI would keep the old UIView and /// `makeUIView` (where the output sink attaches) would never run again. /// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge /// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner /// is also top-aligned, but it hides on `.connected` while gates/digests /// only arrive connected — practical overlap is nil. /// - Plan gate presents as a medium-detent sheet; swiping it away is allowed /// (the user may need to scroll the terminal to read the plan), and a /// "计划待批" re-entry chip appears until the gate resolves. Deciding /// 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 /// T-iPad-3 · pass-through to TerminalScreen's pointer context-menu /// "结束会话" action (root layers supply the coordinator hop). nil on /// surfaces without a kill affordance (e.g. previews). var onKillSession: (@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 { static let planGatePending = "⚠ 计划待批准" } var body: some View { TerminalScreen( viewModel: controller.terminalViewModel, onNewSessionInCwd: onNewSessionInCwd, onKillSession: onKillSession ) .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) @ViewBuilder private var topOverlays: some View { let gateViewModel = controller.gateViewModel VStack(spacing: Metrics.overlaySpacing) { if let digest = gateViewModel.digest { AwayDigestView( digest: digest, isExpanded: gateViewModel.isDigestExpanded, onExpand: { expandDigestAndPresentTimeline() }, onDismiss: { gateViewModel.dismissDigest() } ) } if let gate = gateViewModel.toolGate { GateBanner(gate: gate) { affordance, epoch in gateViewModel.decide(affordance, epoch: epoch) } } if hasDismissedPendingPlanGate { Button(Copy.planGatePending) { dismissedPlanGateEpoch = nil } .buttonStyle(.borderedProminent) .tint(.orange) .controlSize(.small) } } .padding(.horizontal, Metrics.overlayHorizontalPadding) .padding(.top, Metrics.overlayTopPadding) .animation(.default, value: gateViewModel.toolGate) .animation(.default, value: gateViewModel.digest) } // MARK: - Timeline drill-down (T-iOS-24, additive) /// The digest「展开」affordance 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 { guard let gate = controller.gateViewModel.planGate else { return false } return gate.epoch == dismissedPlanGateEpoch } private var planGateBinding: Binding { Binding( get: { guard let gate = controller.gateViewModel.planGate else { return false } return gate.epoch != dismissedPlanGateEpoch }, set: { presented in guard !presented else { return } dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch } ) } @ViewBuilder private var planGateSheet: some View { if let gate = controller.gateViewModel.planGate { PlanGateSheet(gate: gate) { affordance, epoch in controller.gateViewModel.decide(affordance, epoch: epoch) } } } }