import SessionCore import SwiftUI /// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto / /// Approve+Review / Keep Planning — mapping mirrors public/tabs.ts:345-347 /// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default / /// reject; never raw `auto`, and NO allowAutoMode gating — plan §7 T-iOS-14). /// /// Render-only: state lives in `GateViewModel`; every tap reports the /// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap /// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15. struct PlanGateSheet: View { /// Mirror of public/tabs.ts:326 plan-gate headline. static let title = "Claude finished planning — how should it proceed?" let gate: GateState let onDecide: (GateState.Affordance, Int) -> Void private enum Copy { /// Chinese lead-in above the (web-mirrored) English question. static let heading = "计划已就绪" } var body: some View { VStack(alignment: .leading, spacing: DS.Space.lg16) { VStack(alignment: .leading, spacing: DS.Space.xs4) { Text(Copy.heading) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) Text(Self.title) .font(DS.Typography.headline) .foregroundStyle(DS.Palette.textPrimary) .fixedSize(horizontal: false, vertical: true) } ScrollView { VStack(spacing: DS.Space.sm8) { ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in choiceButton(spec) } } } .scrollBounceBehavior(.basedOnSize) } .padding(DS.Space.xl20) .frame(maxWidth: .infinity, alignment: .leading) .presentationDetents([.medium]) .accessibilityElement(children: .contain) } /// One full-width choice Card: semantic icon + label + the plain-language /// caption of what the wire mode actually does + a disclosure chevron. The /// whole card is the (large) tap target. private func choiceButton(_ spec: GateChoiceSpec) -> some View { Button { onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate } label: { Card(padding: DS.Space.md12) { HStack(spacing: DS.Space.md12) { Image(systemName: symbol(for: spec.affordance)) .font(DS.Typography.title) .foregroundStyle(iconColor(for: spec.affordance)) .frame(width: DS.Space.xxl24) VStack(alignment: .leading, spacing: DS.Space.xs2) { Text(spec.label) .font(DS.Typography.body.weight(.semibold)) .foregroundStyle(DS.Palette.textPrimary) Text(Self.caption(for: spec.affordance)) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: DS.Space.sm8) Image(systemName: "chevron.right") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textTertiary) } .frame(maxWidth: .infinity, alignment: .leading) } } .buttonStyle(.plain) .accessibilityIdentifier("plan.decision.\(spec.affordance)") } /// Secondary line under each choice: what the wire mode actually does. static func caption(for affordance: GateState.Affordance) -> String { switch affordance { case .approveAuto: return "执行计划,编辑自动接受(acceptEdits)" case .approveReview: return "执行计划,每次编辑需确认(default)" case .keepPlanning: return "继续规划,不执行" case .approve: return "允许本次工具调用" case .reject: return "拒绝本次工具调用" } } /// Distinct SF Symbol per choice (shape carries meaning, not just color): /// Auto = a bolt (fast, hands-off), Review = a checkmark (approve, confirm /// each edit), Keep Planning = a pencil (stay drafting). private func symbol(for affordance: GateState.Affordance) -> String { switch affordance { case .approveAuto: return "bolt.fill" case .approveReview: return "checkmark.circle" case .keepPlanning: return "pencil.and.outline" case .approve: return "checkmark.circle" case .reject: return "xmark.circle" } } /// Both approve paths wear the accent (this is the encouraged action); Keep /// Planning is a quiet secondary; a bare reject (tool-shaped, unused here) /// stays the stuck red. private func iconColor(for affordance: GateState.Affordance) -> Color { switch affordance { case .approveAuto, .approveReview, .approve: return DS.Palette.accent case .keepPlanning: return DS.Palette.textSecondary case .reject: return DS.Palette.statusStuck } } }