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 } }