nvrsion: Add: Fix AskUserQuestion rendering on mobile by updating rendering logic in AskUserQuestion.swift, Notifications.swift, AskUserQuestionCardView.swift, and SessionDetailView.swift; adjust ./website/iphone.html “Available on TestFlight” button icon to use the testflight icon; refactor iOS live activity interactivity by handling session name taps in SessionLiveActivity.swift; rework “Remote” tab of the settings panel to “Orbital” with network icon and combine “Remote access” and “Mesh” sections into “Mesh”.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -62,7 +62,11 @@ final class NotificationRouter: NSObject {
|
|||||||
content.title = sessionTitle
|
content.title = sessionTitle
|
||||||
content.body = "\(approval.toolName) wants: \(approval.title)"
|
content.body = "\(approval.toolName) wants: \(approval.title)"
|
||||||
content.sound = .default
|
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
|
? Self.approvalCategory : Self.approvalActionableCategory
|
||||||
content.userInfo = [
|
content.userInfo = [
|
||||||
"sessionID": approval.sessionID.rawValue,
|
"sessionID": approval.sessionID.rawValue,
|
||||||
|
|||||||
@@ -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<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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,7 +221,17 @@ struct SessionDetailView: View {
|
|||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
Group {
|
Group {
|
||||||
if let approval = store.openApprovals.first {
|
if let approval = store.openApprovals.first {
|
||||||
|
// 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)
|
ApprovalCardView(approval: approval)
|
||||||
|
}
|
||||||
} else if let status = summary?.status {
|
} else if let status = summary?.status {
|
||||||
let running = status == .running
|
let running = status == .running
|
||||||
let canCompose = running || status == .awaitingInput || status.isTerminal
|
let canCompose = running || status == .awaitingInput || status.isTerminal
|
||||||
|
|||||||
@@ -109,10 +109,23 @@ private struct LockScreenView: View {
|
|||||||
// MARK: - Shared pieces
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
/// One session: state glyph + title + "project · Backend" + a right-aligned status/diff.
|
/// 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/<id>`, 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 {
|
private struct SessionRow: View {
|
||||||
let line: NucleicSessionAttributes.SessionLine
|
let line: NucleicSessionAttributes.SessionLine
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: ActivityPalette.glyph(line.kind))
|
Image(systemName: ActivityPalette.glyph(line.kind))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|||||||
Reference in New Issue
Block a user