feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
251
ios/App/WebTerm/Components/QuickReply.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
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 = "管理常用语"
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
static let chipSpacing: CGFloat = 6
|
||||
static let rowHorizontalPadding: CGFloat = 8
|
||||
static let rowVerticalPadding: CGFloat = 6
|
||||
}
|
||||
|
||||
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: Metrics.chipSpacing) {
|
||||
ForEach(store.allChips) { chip in
|
||||
chipButton(chip)
|
||||
}
|
||||
managePhrasesButton
|
||||
}
|
||||
.padding(.horizontal, Metrics.rowHorizontalPadding)
|
||||
.padding(.vertical, Metrics.rowVerticalPadding)
|
||||
}
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.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)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private var managePhrasesButton: some View {
|
||||
Button {
|
||||
isPanelPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.small)
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(store.userChips) { chip in
|
||||
Button {
|
||||
beginEditing(chip)
|
||||
} label: {
|
||||
chipRow(chip)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.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) {
|
||||
// Text(verbatim:) — stored strings are boundary data (SEC-L3 mirror).
|
||||
Text(verbatim: chip.label)
|
||||
.lineLimit(1)
|
||||
Text(verbatim: chip.appendEnter
|
||||
? chip.text + Copy.enterSuffixSymbol
|
||||
: chip.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user