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() /// 无障碍:减弱动态效果时,overlay 进出塌成即时切换(DS.Motion.gated)。 @Environment(\.accessibilityReduceMotion) private var reduceMotion private enum Metrics { /// Clears TerminalScreen's own top-aligned ReconnectBanner zone: a /// hit-target-tall pill plus a gap. (They rarely co-exist — the banner /// hides on `.connected` while gates/digests only arrive connected.) static let overlayTopPadding: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8 } private enum Copy { /// The re-entry chip's own amber StatusBadge carries the "needs me" /// warning shape, so the copy itself stays plain (no emoji). 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, DS.Space.md12) .padding(.bottom, DS.Space.sm8) .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: controller.gateViewModel.currentGate ) } // MARK: - Digest + tool gate (top stack) @ViewBuilder private var topOverlays: some View { let gateViewModel = controller.gateViewModel VStack(spacing: DS.Space.sm8) { 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 { planGatePendingChip } } .padding(.horizontal, DS.Space.md12) .padding(.top, Metrics.overlayTopPadding) .animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: gateViewModel.toolGate) .animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: gateViewModel.digest) } /// The "plan still pending" re-entry chip shown after the user swiped the /// plan sheet away — an amber pending-approval pill that re-presents the /// sheet on tap. private var planGatePendingChip: some View { Button { dismissedPlanGateEpoch = nil } label: { HStack(spacing: DS.Space.xs4) { StatusBadge(status: .pendingApproval) Text(Copy.planGatePending) .font(DS.Typography.callout.weight(.medium)) .foregroundStyle(DS.Palette.textPrimary) } .padding(.horizontal, DS.Space.md12) .frame(minHeight: DS.Layout.minHitTarget) .background(.regularMaterial, in: Capsule()) .overlay( Capsule().strokeBorder(DS.Palette.statusWaiting, lineWidth: DS.Stroke.hairline) ) } .buttonStyle(.plain) } // 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) } } } }