Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
184 lines
8.1 KiB
Swift
184 lines
8.1 KiB
Swift
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<Bool> {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|