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

256 lines
9.7 KiB
Swift

import SessionCore
import SwiftUI
/// T-iOS-25 · Quick-reply chip row + (plan §7; behavior mirror of
/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`).
///
/// Visibility decision (documented): the plan step says "waiting ".
/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by
/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the
/// stream's ONLY waiting projection is the HELD GATE a `status:'waiting',
/// pending:true` frame surfaces as `.gate(GateState)` and the lift as
/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal
/// is live (not exited/failed). Waiting-without-pending (a Notification
/// permission_prompt with no held relay) never reaches the stream accepted
/// limitation; the observation point stays the SAME fan-out branches the
/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra
/// fan-out branch needed).
///
/// Send path: a chip tap goes through `TerminalViewModel.sendInput` the one
/// ordered send pump so a rapid double-tap yields two frames in tap order,
/// never interleaved, and the read-only guard drops taps on a dead terminal.
struct QuickReplyBar: View {
enum Copy {
static let managePhrases = "管理常用语"
}
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let store: QuickReplyStore
@State private var isPanelPresented = false
/// Pure visibility rule (see type doc): held gate = the stream's waiting
/// signal; read-only (exited/failed) always hides.
static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool {
gate != nil && !isReadOnly
}
/// The production tap path (also exercised directly by tests): compose the
/// payload via `QuickReplyPalette` and hand it to the VM's ordered pump.
static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) {
viewModel.sendInput(QuickReplyPalette.payload(for: chip))
}
var body: some View {
if Self.isVisible(
gate: gateViewModel.currentGate,
isReadOnly: terminalViewModel.isReadOnly
) {
chipRow
.sheet(isPresented: $isPanelPresented) {
QuickReplyPanel(store: store)
}
}
}
private var chipRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DS.Space.sm8) {
ForEach(store.allChips) { chip in
chipButton(chip)
}
managePhrasesButton
}
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs4)
}
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
private func chipButton(_ chip: QuickReplyChip) -> some View {
Button {
Self.send(chip, through: terminalViewModel)
} label: {
// Text(verbatim:) user/label strings render as inert text, never
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
Text(verbatim: chip.label)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.accent)
.lineLimit(1)
.padding(.horizontal, DS.Space.md12)
.frame(minHeight: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
}
private var managePhrasesButton: some View {
Button {
isPanelPresented = true
} label: {
Image(systemName: "plus")
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(width: DS.Layout.minHitTarget, height: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel(Copy.managePhrases)
}
}
/// : user-phrase CRUD + drag reorder (), presented from the
/// chip row's `+`. The add/edit form mirrors the web inline editor: text,
/// optional label (defaults to text) and an append-Enter toggle that starts
/// on. Tapping an existing row loads it into the form for editing ().
struct QuickReplyPanel: View {
enum Copy {
static let title = "常用语"
static let addSection = "添加新常用语"
static let customSection = "自定义常用语"
static let textPlaceholder = "要发送的文本"
static let labelPlaceholder = "标签(可选,默认同文本)"
static let appendEnterToggle = "发送后自动回车"
static let addButton = "添加"
static let saveButton = "保存修改"
static let cancelEditButton = "取消编辑"
static let doneButton = "完成"
static let emptyState = "还没有自定义常用语"
/// Payload preview suffix for append-Enter chips (web: `${text}`).
static let enterSuffixSymbol = ""
}
let store: QuickReplyStore
@Environment(\.dismiss) private var dismiss
@State private var draftText = ""
@State private var draftLabel = ""
/// Mirrors the web editor default: `enterCheck.checked = true`.
@State private var draftAppendEnter = true
/// Non-nil while the form edits an existing chip instead of adding.
@State private var editingChipId: String?
var body: some View {
NavigationStack {
List {
customSection
editorSection
}
.navigationTitle(Copy.title)
.toolbar {
ToolbarItem(placement: .topBarLeading) { EditButton() }
ToolbarItem(placement: .topBarTrailing) {
Button(Copy.doneButton) { dismiss() }
}
}
}
}
// MARK: - Custom phrases (delete / reorder / tap-to-edit)
@ViewBuilder private var customSection: some View {
Section(Copy.customSection) {
if store.userChips.isEmpty {
Text(Copy.emptyState)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(store.userChips) { chip in
Button {
beginEditing(chip)
} label: {
chipRow(chip)
}
.tint(DS.Palette.textPrimary)
}
.onDelete { offsets in
// Resolve ids FIRST removing while iterating offsets
// would shift indices under us.
let ids = offsets.map { store.userChips[$0].id }
for id in ids {
store.removeChip(id: id)
}
}
.onMove { fromOffsets, toOffset in
store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
}
}
private func chipRow(_ chip: QuickReplyChip) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// Text(verbatim:) stored strings are boundary data (SEC-L3 mirror).
Text(verbatim: chip.label)
.font(DS.Typography.body)
.lineLimit(1)
Text(verbatim: chip.appendEnter
? chip.text + Copy.enterSuffixSymbol
: chip.text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
}
// MARK: - Add / edit form
@ViewBuilder private var editorSection: some View {
Section(Copy.addSection) {
TextField(Copy.textPlaceholder, text: $draftText)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField(Copy.labelPlaceholder, text: $draftLabel)
Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter)
Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) {
commitDraft()
}
.disabled(trimmedDraftText.isEmpty)
if editingChipId != nil {
Button(Copy.cancelEditButton, role: .cancel) {
clearDraft()
}
}
}
}
private var trimmedDraftText: String {
draftText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func beginEditing(_ chip: QuickReplyChip) {
editingChipId = chip.id
draftText = chip.text
draftLabel = chip.label
draftAppendEnter = chip.appendEnter
}
/// Web `onSaveClick` semantics: empty text is a no-op (button is disabled
/// anyway belt and braces), empty label defaults to the text.
private func commitDraft() {
let text = trimmedDraftText
guard !text.isEmpty else { return }
let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let label = trimmedLabel.isEmpty ? text : trimmedLabel
if let id = editingChipId {
store.updateChip(id: id, text: text, label: label,
appendEnter: draftAppendEnter)
} else {
store.addChip(text: text, label: label, appendEnter: draftAppendEnter)
}
clearDraft()
}
private func clearDraft() {
editingChipId = nil
draftText = ""
draftLabel = ""
draftAppendEnter = true
}
}