diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/AskUserQuestionResultCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/AskUserQuestionResultCard.swift new file mode 100644 index 0000000..9090edf --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/AskUserQuestionResultCard.swift @@ -0,0 +1,85 @@ +import SwiftUI +import NucleicProtocol + +/// Pretty rendering of a *completed* `AskUserQuestion` tool call — the phone echo of the Mac's +/// `AskUserQuestionResultCard`. Once the user has answered, the raw "AskUserQuestion {…}" card reads +/// as opaque JSON; instead we show each question with the option the user chose (and that option's +/// description when the answer matches a known label — free-text "Other" answers won't, so they show +/// none). The answers are recovered from the persisted tool result (`AskUserQuestion.answers(in:for:)`), +/// so the card survives transcript replay. Mirrors the answer picker's look — the prompt set quiet, +/// the chosen answer emphasized — so the answered card reads as the settled form of the picker. +struct AskUserQuestionResultCard: View { + let questions: [AskUserQuestion.Question] + /// Chosen option labels keyed by question text (more than one for a multi-select). + let answers: [String: [String]] + + private var accent: Color { Palette.accent } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "questionmark.bubble") + .font(.caption).foregroundStyle(accent).frame(width: 15) + Text("You answered") + .font(.callout.weight(.medium)) + Spacer(minLength: 0) + } + ForEach(Array(questions.enumerated()), id: \.offset) { index, question in + if index > 0 { Divider().overlay(accent.opacity(0.12)) } + questionRow(question) + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.06)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + @ViewBuilder + private func questionRow(_ question: AskUserQuestion.Question) -> some View { + VStack(alignment: .leading, spacing: 5) { + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + } + Text(question.question) + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let chosen = answers[question.question], !chosen.isEmpty { + ForEach(Array(chosen.enumerated()), id: \.offset) { _, label in + answerRow(label, description: description(of: label, in: question)) + } + } else { + // An answered card only shows for questions that parsed an answer; this is the + // honest fallback if one question in a multi-question ask somehow didn't. + Text("No answer recorded") + .font(.callout).italic().foregroundStyle(.tertiary) + } + } + } + + private func answerRow(_ label: String, description: String?) -> some View { + HStack(alignment: .top, spacing: 7) { + Image(systemName: "checkmark.circle.fill") + .font(.caption).foregroundStyle(accent) + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + if let description, !description.isEmpty { + Text(description) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + } + + /// The chosen option's description, when the answer matches a known option label. + private func description(of label: String, in question: AskUserQuestion.Question) -> String? { + question.options.first { $0.label == label }?.description + } +}