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 /// 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? 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 } private enum Copy { static let planGatePending = "⚠ 计划待批准" } var body: some View { TerminalScreen(viewModel: controller.terminalViewModel) .id(controller.generation) .navigationBarTitleDisplayMode(.inline) .overlay(alignment: .top) { topOverlays } .sheet(isPresented: planGateBinding) { planGateSheet } } // 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: { gateViewModel.expandDigest() }, 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: - 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) } } } }