Files
web-terminal/ios/App/WebTerm/Wiring/TerminalContainerView.swift
Yaojia Wang 660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
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/.
2026-07-05 22:00:31 +02:00

184 lines
8.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (backgroundforeground) 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 digestaffordance 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)
}
}
}
}