diff --git a/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift new file mode 100644 index 0000000..18e55c5 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift @@ -0,0 +1,216 @@ +import SwiftUI +import NucleicProtocol + +/// Answer UI for Claude Code's built-in `AskUserQuestion` tool — the mobile mirror of +/// the Mac's `AskUserQuestionBar`. Unlike a permission gate, this *collects* the user's +/// choice per question and folds the selections into the approval reply's `updatedInput` +/// (see `AskUserQuestion`); replying with a plain allow makes the CLI report "the user +/// did not answer the questions", which is exactly the bug the generic `ApprovalCardView` +/// produced here before. +/// +/// Questions are shown one at a time as a wizard — Back/Next between them, Submit on the +/// last — so a multi-question ask reads as a focused sequence rather than one long form. +struct AskUserQuestionCardView: View { + @EnvironmentObject var store: RemoteStore + let approval: ApprovalRequest + let questions: [AskUserQuestion.Question] + + /// Selected option labels per question text. + @State private var selected: [String: Set] = [:] + /// Whether the free-text "Other" answer is active per question text. + @State private var otherActive: [String: Bool] = [:] + /// The free-text "Other" answer per question text. + @State private var otherText: [String: String] = [:] + /// Index of the question currently shown (the wizard step). + @State private var step = 0 + + private var submitEnabled: Bool { store.connectivity.isLive } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 6) { + Image(systemName: "questionmark.bubble.fill").foregroundStyle(Palette.attention) + Text(questions.count > 1 ? "Questions" : "Question") + .font(.subheadline.weight(.semibold)) + } + + if let question = currentQuestion { + VStack(alignment: .leading, spacing: 8) { + if questions.count > 1 { + Text("Question \(step + 1) of \(questions.count)") + .font(.caption).foregroundStyle(.secondary) + } + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption).bold().foregroundStyle(.secondary) + } + Text(question.question).font(.headline) + ForEach(question.options) { option in + optionRow(question, label: option.label, description: option.description) + } + otherRow(question) + } + } + + HStack(spacing: 10) { + Button(role: .destructive) { + respond(.deny(reason: nil)) + } label: { + Text("Deny").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) + + if step > 0 { + Button("Back") { step -= 1 } + .buttonStyle(.bordered) + } + + if isLastStep { + Button { + submit() + } label: { + Text("Submit").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!allAnswered || !submitEnabled) + .keyboardShortcut(.defaultAction) + } else { + Button { + step += 1 + } label: { + Text("Next").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!currentAnswered) + .keyboardShortcut(.defaultAction) + } + } + } + .padding() + .glassSurface(cornerRadius: 20) + } + + // MARK: - Wizard state + + /// The question for the active step, or `nil` if the index is somehow out of range. + private var currentQuestion: AskUserQuestion.Question? { + questions.indices.contains(step) ? questions[step] : nil + } + + private var isLastStep: Bool { step >= questions.count - 1 } + + /// Whether the question on the current step has at least one answer — gates `Next`. + private var currentAnswered: Bool { + guard let currentQuestion else { return false } + return !answers(for: currentQuestion).isEmpty + } + + private var allAnswered: Bool { + questions.allSatisfy { !answers(for: $0).isEmpty } + } + + // MARK: - Rows + + @ViewBuilder + private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?) + -> some View + { + let isOn = selected[question.question]?.contains(label) ?? false + Button { toggle(question, label) } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.body) + .fixedSize(horizontal: false, vertical: true) + if let description, !description.isEmpty { + // Let the subtext wrap freely — inside a Button label with a trailing + // Spacer, an unconstrained Text truncates to one line and hides the rest. + Text(description).font(.subheadline).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func otherRow(_ question: AskUserQuestion.Question) -> some View { + let isOn = otherActive[question.question] ?? false + HStack(alignment: .center, spacing: 8) { + Button { toggleOther(question) } label: { + Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) + .foregroundStyle(isOn ? Palette.attention : .secondary) + } + .buttonStyle(.plain) + TextField( + "Other…", + text: Binding( + get: { otherText[question.question] ?? "" }, + set: { newValue in + otherText[question.question] = newValue + if !newValue.isEmpty { activateOther(question) } + }) + ) + .textFieldStyle(.roundedBorder) + } + } + + private func symbol(multiSelect: Bool, on: Bool) -> String { + if multiSelect { return on ? "checkmark.square.fill" : "square" } + return on ? "largecircle.fill.circle" : "circle" + } + + // MARK: - Selection + + private func toggle(_ question: AskUserQuestion.Question, _ label: String) { + var set = selected[question.question] ?? [] + if question.multiSelect { + if set.contains(label) { set.remove(label) } else { set.insert(label) } + } else { + set = set.contains(label) ? [] : [label] + otherActive[question.question] = false + } + selected[question.question] = set + } + + private func toggleOther(_ question: AskUserQuestion.Question) { + let next = !(otherActive[question.question] ?? false) + otherActive[question.question] = next + if next, !question.multiSelect { selected[question.question] = [] } + } + + private func activateOther(_ question: AskUserQuestion.Question) { + otherActive[question.question] = true + if !question.multiSelect { selected[question.question] = [] } + } + + /// Chosen labels for a question: picked options plus any non-empty "Other". + private func answers(for question: AskUserQuestion.Question) -> [String] { + var labels = Array(selected[question.question] ?? []) + if otherActive[question.question] ?? false { + let custom = (otherText[question.question] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !custom.isEmpty { labels.append(custom) } + } + return labels + } + + // MARK: - Submit + + private func submit() { + var selections: [String: [String]] = [:] + for question in questions { selections[question.question] = answers(for: question) } + let updatedInput = AskUserQuestion.updatedInput( + original: approval.input, selections: selections) + respond(.allow(updatedInput: updatedInput)) + } + + private func respond(_ decision: Decision) { + store.respond(approval, decision) + } +}