Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift
T
abkslmandNucleic bc5b9928f8 Truncation Logic Refinement
Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434
Co-authored-by: Nucleic <[email protected]>
2026-07-06 04:22:41 -07:00

234 lines
9.7 KiB
Swift

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]
/// The transcript's on-screen height, so a long question (many options / long descriptions) can
/// scroll inside a bounded region instead of growing the card past the screen and pushing the
/// Deny / Back / Next / Submit buttons out of reach. 0 until measured.
var availableHeight: CGFloat = 0
/// Selected option labels per question text.
@State private var selected: [String: Set<String>] = [:]
/// 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 {
BoundedCardScroll(maxHeight: middleMaxHeight) {
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)
// A hard ceiling on the whole card so it can never exceed the screen; `nil` (before the
// height is measured) leaves it unconstrained.
.frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom)
}
/// The height the scrolling question region may take before it starts scrolling — the screen
/// minus a reserve for the header and the button row, so those always have room. Falls back to
/// a roomy default before the height is measured.
private var middleMaxHeight: CGFloat {
let base = availableHeight > 0 ? availableHeight - 12 : 640
return max(160, base - 170)
}
// 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)
}
}