Files
web-terminal/ios/App/WebTerm/Components/ReconnectBanner.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

130 lines
5.2 KiB
Swift
Raw Permalink 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 SwiftUI
import WireProtocol
/// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal
/// must NEVER "look connected but dead" every non-live state is explicit).
///
/// Render input is the `Model` enum only; the mapping from engine events to a
/// `Model` lives in `TerminalViewModel.bannerModel` (tested there).
struct ReconnectBanner: View {
/// What the banner says. Terminal phases (`failed`/`exited`) win over
/// transient connection states (mapping in `TerminalViewModel.bannerModel`).
enum Model: Equatable {
case connecting
case reconnecting(attempt: Int, retryIn: Duration)
/// Non-retryable failure: actionable copy, NO spinner, NO retry loop.
case failed(message: String)
/// Session over terminal is read-only from here on.
case exited(code: Int, reason: String?)
}
let model: Model
/// T-iOS-29 · "" on the EXITED banner only (session over the
/// natural next step; manager.ts:145-153 the old session is done for
/// good). nil = affordance hidden. Not offered on `.failed`: that copy
/// asks the user to change a knob first, not to spawn again.
var onNewSession: (@MainActor () -> Void)? = nil
private enum Copy {
static let newSession = "开新会话"
}
/// + +
/// GROUP BRIEF
var body: some View {
HStack(spacing: DS.Space.sm8) {
leadingIndicator
Text(text)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.multilineTextAlignment(.leading)
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
Button(Copy.newSession) { onNewSession() }
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.buttonStyle(.plain)
.accessibilityIdentifier("terminal.exitNewSessionButton")
}
}
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityElement(children: .combine)
}
/// Spinner while a connection is in flight, otherwise the state's icon
/// both painted in the semantic indicator color (shape + color, never color
/// alone).
@ViewBuilder private var leadingIndicator: some View {
if isSpinning {
ProgressView()
.controlSize(.small)
.tint(indicatorColor)
} else {
Image(systemName: iconName)
.foregroundStyle(indicatorColor)
}
}
/// The new-session affordance appears on the `.exited` banner only
/// pure predicate so the T-iOS-29 tests pin the rule.
static func isNewSessionActionAvailable(for model: Model) -> Bool {
if case .exited = model { return true }
return false
}
private var isSpinning: Bool {
switch model {
case .connecting, .reconnecting: return true
case .failed, .exited: return false
}
}
private var iconName: String {
switch model {
case .failed: return "exclamationmark.octagon.fill"
case .exited: return "flag.checkered"
case .connecting, .reconnecting: return "wifi"
}
}
/// Semantic indicator color (DS only): connecting is quiet gray, reconnecting
/// is the calm accent (not an orange alarm), failed is the stuck red, exited
/// is dimmed secondary. Paired with a distinct icon so it is never color-only.
private var indicatorColor: Color {
switch model {
case .connecting: return DS.Palette.textSecondary
case .reconnecting: return DS.Palette.accent
case .failed: return DS.Palette.statusStuck
case .exited: return DS.Palette.statusExited
}
}
private var text: String {
switch model {
case .connecting:
return "连接中…"
case .reconnecting(let attempt, let retryIn):
return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…"
case .failed(let message):
return message
case .exited(let code, let reason):
return Self.exitText(code: code, reason: reason)
}
}
/// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads
/// as a conclusion. `reason` is server-supplied display text (untrusted
/// rendered as plain text only).
private static func exitText(code: Int, reason: String?) -> String {
if code == WireConstants.spawnFailedExitCode {
return "启动 shell 失败:\(reason ?? "未知原因")"
}
let suffix = reason.map { "\($0)" } ?? ""
return "会话已退出 exit \(code)\(suffix) · 终端已只读"
}
}