Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift
T
abkslmandClaude Opus 4.8 3954ff13b7 Wrap approval detail and size the box to its content
The first pass put the detail in a ScrollView with fixedSize(horizontal:)
and a fixed maxHeight, so the command was forced onto one long line that
scrolled horizontally and the box always claimed full height — a tall box
with a lot of empty space.

Replace it with an ApprovalDetailBox (macOS + iOS) that wraps the text and
measures its content height via a PreferenceKey, sizing the box to fit and
only scrolling vertically once the content exceeds the max height. No more
horizontal scroll; short commands sit in a snug box.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-16 02:00:05 -07:00

147 lines
5.8 KiB
Swift

import SwiftUI
import LocalAuthentication
import NucleicProtocol
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
/// requires Face ID/Touch ID before Allow is enabled — deliberate friction mirroring the Mac.
/// First-responder-wins: if the Mac (or another phone) answers first, the host's
/// `approvalResolved` removes this card.
struct ApprovalCardView: View {
@EnvironmentObject var store: RemoteStore
let approval: ApprovalRequest
@State private var biometricPassed = false
@State private var authError: String?
@State private var showAlwaysMenu = false
private var requiresBiometric: Bool { approval.risk.isHigh }
private var allowEnabled: Bool {
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Permission requested").font(.subheadline.weight(.semibold))
}
HStack(spacing: 6) {
Text(approval.toolName).font(.caption.weight(.bold))
Text(approval.risk.label)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(approval.risk.color.opacity(0.2), in: Capsule())
.foregroundStyle(approval.risk.color)
}
// Full, untruncated content — the command wraps and the box fits its
// content, only scrolling vertically once it's genuinely tall, so the user
// can read exactly what they are granting without a giant half-empty box.
if !approval.input.approvalDetail.isEmpty {
ApprovalDetailBox(text: approval.input.approvalDetail)
}
if requiresBiometric && !biometricPassed {
Button {
authenticate()
} label: {
Label("Face ID required to allow", systemImage: "faceid").font(.caption)
}
.buttonStyle(.bordered)
}
if let authError {
Text(authError).font(.caption2).foregroundStyle(.red)
}
HStack(spacing: 10) {
Button(role: .destructive) {
store.respond(approval, .deny(reason: nil))
} label: {
Text("Deny").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
store.respond(approval, .allow())
} label: {
Text("Allow").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!allowEnabled)
}
if !store.capabilities.allowAlwaysScopes.isEmpty {
Menu("Allow always…") {
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
Button(alwaysLabel(scope)) {
store.respond(approval, .allowAlways(scope))
}
.disabled(!allowEnabled)
}
}
.font(.caption)
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
}
private func alwaysLabel(_ scope: AlwaysScope) -> String {
switch scope {
case .session: "This command, this session"
case .toolName: "Any \(approval.toolName), this session"
case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
}
}
private func authenticate() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
authError = "Biometrics unavailable"; return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Approve a \(approval.risk.label) action"
) { success, evalError in
Task { @MainActor in
if success { biometricPassed = true; authError = nil }
else { authError = evalError?.localizedDescription ?? "Authentication failed" }
}
}
}
}
/// The full content of an approval, wrapped and selectable. The box sizes to its
/// content and only begins scrolling once the content exceeds `maxHeight`, so a short
/// command sits in a snug box while a large paste stays bounded. The text wraps; it
/// never scrolls horizontally.
private struct ApprovalDetailBox: View {
let text: String
private let maxHeight: CGFloat = 200
@State private var contentHeight: CGFloat = 0
var body: some View {
ScrollView(.vertical) {
Text(text)
.font(.caption.monospaced())
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(GeometryReader { geo in
Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height)
})
}
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
.onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 }
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
}
}
private struct ApprovalDetailHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}