From c9cffa55cab20e254e7f55dba66ed954f071b09e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 02:42:46 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Fix=20AskUserQuestion=20rende?= =?UTF-8?q?ring=20on=20mobile=20by=20updating=20rendering=20logic=20in=20A?= =?UTF-8?q?skUserQuestion.swift,=20Notifications.swift,=20AskUserQuestionC?= =?UTF-8?q?ardView.swift,=20and=20SessionDetailView.swift;=20adjust=20./we?= =?UTF-8?q?bsite/iphone.html=20=E2=80=9CAvailable=20on=20TestFlight?= =?UTF-8?q?=E2=80=9D=20button=20icon=20to=20use=20the=20testflight=20icon;?= =?UTF-8?q?=20refactor=20iOS=20live=20activity=20interactivity=20by=20hand?= =?UTF-8?q?ling=20session=20name=20taps=20in=20SessionLiveActivity.swift;?= =?UTF-8?q?=20rework=20=E2=80=9CRemote=E2=80=9D=20tab=20of=20the=20setting?= =?UTF-8?q?s=20panel=20to=20=E2=80=9COrbital=E2=80=9D=20with=20network=20i?= =?UTF-8?q?con=20and=20combine=20=E2=80=9CRemote=20access=E2=80=9D=20and?= =?UTF-8?q?=20=E2=80=9CMesh=E2=80=9D=20sections=20into=20=E2=80=9CMesh?= =?UTF-8?q?=E2=80=9D.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Notifications.swift | 6 +- .../Views/AskUserQuestionCardView.swift | 216 ++++++++++++++++++ .../Views/SessionDetailView.swift | 12 +- .../SessionLiveActivity.swift | 13 ++ 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/AskUserQuestionCardView.swift diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift index fa8ef92..c12a4b5 100644 --- a/NucleicRemote/NucleicRemote/Notifications.swift +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -62,7 +62,11 @@ final class NotificationRouter: NSObject { content.title = sessionTitle content.body = "\(approval.toolName) wants: \(approval.title)" content.sound = .default - content.categoryIdentifier = approval.risk.isHigh + // AskUserQuestion collects answers, it doesn't gate — a banner Allow would reply with no + // selection (updatedInput: nil) and the CLI reports the question went unanswered. So, like + // a high-risk approval, it uses the action-less category: it must open the app and be + // answered on the picker card. + content.categoryIdentifier = approval.risk.isHigh || approval.toolName == AskUserQuestion.toolName ? Self.approvalCategory : Self.approvalActionableCategory content.userInfo = [ "sessionID": approval.sessionID.rawValue, 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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f4f7752..80f3c37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -221,7 +221,17 @@ struct SessionDetailView: View { private var actionArea: some View { Group { if let approval = store.openApprovals.first { - ApprovalCardView(approval: approval) + // AskUserQuestion isn't a permission gate — it collects the user's answers and + // needs them folded back into `updatedInput`. Render the picker (matching the + // Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would + // reply with no selection and make the CLI report the question went unanswered. + if approval.toolName == AskUserQuestion.toolName, + let questions = AskUserQuestion.questions(from: approval.input) + { + AskUserQuestionCardView(approval: approval, questions: questions) + } else { + ApprovalCardView(approval: approval) + } } else if let status = summary?.status { let running = status == .running let canCompose = running || status == .awaitingInput || status.isTerminal diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 1d74750..deee70b 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -109,10 +109,23 @@ private struct LockScreenView: View { // MARK: - Shared pieces /// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. +/// +/// Each row is its own tap target: wrapped in a `Link` to `nucleic://session/`, so tapping a +/// specific session on the lock screen or expanded Dynamic Island opens *that* session — overriding +/// the activity-wide `widgetURL` (which still governs the header, churn footer, and empty space, and +/// remains the only tap target the compact/minimal presentations can offer). private struct SessionRow: View { let line: NucleicSessionAttributes.SessionLine var body: some View { + if let url = NucleicDeepLink.session(line.id) { + Link(destination: url) { rowContent } + } else { + rowContent + } + } + + private var rowContent: some View { HStack(spacing: 8) { Image(systemName: ActivityPalette.glyph(line.kind)) .font(.footnote)