M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM library as a local package. Builds for the iOS 27 simulator and launches to the pairing screen. - Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp). - Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect, hello/welcome, HostMsg→Event stream). - State: RemoteStore (ObservableObject) — the single on-device projection of host state; IdentityStore persists the device identity (Keychain) + pinned host. - UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff + status-driven action area / composer), ApprovalCardView with Face ID gate on high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR), SettingsView, connection chip. Same status glyphs/semantics as the Mac. Add-iPhone QR display + server start live on the macOS side (follow-up); push / Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite the blanket *.xcodeproj rule. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
Text(approval.input.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(3)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Persistent connectivity indicator (UX_IOS §6). Connectivity is unambiguous: green LAN
|
||||
/// when live, amber while reconnecting, gray when the Mac is unreachable.
|
||||
struct ConnectionChip: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8)
|
||||
Text(store.connectivity.label).font(.footnote.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch store.connectivity {
|
||||
case .connected: .green
|
||||
case .connecting, .reconnecting: .orange
|
||||
case .hostOffline, .unpaired: .gray
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a
|
||||
/// `PairingPayload` and hands it back exactly once.
|
||||
struct PairingScannerView: View {
|
||||
let onScanned: (PairingPayload) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
QRScannerRepresentable { value in handle(value) }
|
||||
.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.8), lineWidth: 3)
|
||||
.frame(width: 240, height: 240)
|
||||
Spacer()
|
||||
if let error {
|
||||
Text(error).foregroundStyle(.white)
|
||||
.padding().background(.red.opacity(0.8), in: Capsule())
|
||||
} else {
|
||||
Text("Point at the QR code on your Mac")
|
||||
.foregroundStyle(.white)
|
||||
.padding().background(.black.opacity(0.5), in: Capsule())
|
||||
}
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan to pair")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ value: String) {
|
||||
guard let payload = try? PairingPayload(qrString: value) else {
|
||||
error = "That isn't a Nucleic pairing code"; return
|
||||
}
|
||||
onScanned(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit bridge for the AVCaptureSession metadata scan.
|
||||
struct QRScannerRepresentable: UIViewControllerRepresentable {
|
||||
let onFound: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> QRScannerController {
|
||||
let controller = QRScannerController()
|
||||
controller.onFound = onFound
|
||||
return controller
|
||||
}
|
||||
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
|
||||
}
|
||||
|
||||
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onFound: ((String) -> Void)?
|
||||
private let session = AVCaptureSession()
|
||||
private var didFind = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.addSublayer(preview)
|
||||
|
||||
Task.detached { [session] in session.startRunning() }
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !didFind,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = object.stringValue else { return }
|
||||
didFind = true
|
||||
onFound?(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One session: a segmented Transcript/Diff read view over a status-driven action area
|
||||
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
|
||||
struct SessionDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let sessionID: SessionID
|
||||
@State private var tab = 0
|
||||
@State private var draft = ""
|
||||
|
||||
private var summary: WireSessionSummary? {
|
||||
store.sessions.first { $0.sessionID == sessionID }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $tab) {
|
||||
Text("Transcript").tag(0)
|
||||
Text("Diff").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if tab == 0 {
|
||||
TranscriptList(events: store.openEvents)
|
||||
} else {
|
||||
DiffSummaryView(diffStat: summary?.diffStat)
|
||||
}
|
||||
|
||||
actionArea
|
||||
}
|
||||
.navigationTitle(summary?.title ?? "Session")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { store.open(sessionID) }
|
||||
.onDisappear { store.closeOpen() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionArea: some View {
|
||||
Divider()
|
||||
if let approval = store.openApprovals.first {
|
||||
ApprovalCardView(approval: approval)
|
||||
.padding()
|
||||
} else if let status = summary?.status, status == .awaitingInput || status.isTerminal {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Send a follow-up…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
Button {
|
||||
store.sendInput(draft, to: sessionID)
|
||||
draft = ""
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.title2)
|
||||
}
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
||||
}
|
||||
.padding()
|
||||
} else if summary?.status == .running {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Agent is working…").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptList: View {
|
||||
let events: [AgentEvent]
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(events, id: \.seq) { event in
|
||||
TranscriptRow(event: event).id(event.seq)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: events.count) {
|
||||
if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffSummaryView: View {
|
||||
let diffStat: DiffStat?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let diff = diffStat, diff.filesChanged > 0 {
|
||||
Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Text("+\(diff.added)").foregroundStyle(.green)
|
||||
Text("−\(diff.removed)").foregroundStyle(.red)
|
||||
}
|
||||
.font(.title3.monospacedDigit())
|
||||
Text("Open the Mac for the full patch.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ContentUnavailableView("No changes yet", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a
|
||||
/// persistent connection chip. Tapping a row pushes detail.
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||
let sorted = store.sessions.sorted {
|
||||
let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status)
|
||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||||
}
|
||||
let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput }
|
||||
let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||||
let done = sorted.filter { $0.status.isTerminal }
|
||||
return [
|
||||
("Needs you", needs), ("Running", running), ("Done", done),
|
||||
].filter { !$0.rows.isEmpty }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.sessions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No sessions", systemImage: "square.stack.3d.up",
|
||||
description: Text(store.connectivity.isLive
|
||||
? "Start a session on your Mac to see it here."
|
||||
: "Waiting to connect to \(store.hostName)…"))
|
||||
} else {
|
||||
List {
|
||||
ForEach(grouped, id: \.title) { group in
|
||||
Section(group.title.uppercased()) {
|
||||
ForEach(group.rows, id: \.sessionID) { summary in
|
||||
NavigationLink {
|
||||
SessionDetailView(sessionID: summary.sessionID)
|
||||
} label: {
|
||||
SessionRow(summary: summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { store.refreshSessions() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ConnectionChip().padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRow: View {
|
||||
let summary: WireSessionSummary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: StatusStyle.symbol(summary.status))
|
||||
.foregroundStyle(StatusStyle.color(summary.status))
|
||||
.font(.title3)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||||
Text("+\(diff.added)/−\(diff.removed)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
LabeledContent("Status", value: store.connectivity.label)
|
||||
if !store.hostName.isEmpty {
|
||||
LabeledContent("Mac", value: store.hostName)
|
||||
}
|
||||
Button("Reconnect") { store.reconnect() }
|
||||
.disabled(!store.isPaired)
|
||||
}
|
||||
|
||||
Section("This device") {
|
||||
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
|
||||
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
|
||||
.font(.footnote.monospaced())
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
if store.isPaired {
|
||||
Button(role: .destructive) { store.unpair() } label: {
|
||||
Label("Unpair this device", systemImage: "minus.circle")
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Code and diffs are end-to-end encrypted. A relay (later) can't read them.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $showScanner) {
|
||||
PairingScannerView { payload in
|
||||
showScanner = false
|
||||
store.pair(with: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.
|
||||
struct PairingIntroView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
||||
Text("Pair with your Mac").font(.title2.weight(.semibold))
|
||||
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 32)
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showScanner) {
|
||||
PairingScannerView { payload in
|
||||
showScanner = false
|
||||
store.pair(with: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Same status glyphs/semantics as the Mac (UX_MACOS §1 / UX_IOS §3) so the two devices read
|
||||
/// identically.
|
||||
enum StatusStyle {
|
||||
static func symbol(_ status: SessionStatus) -> String {
|
||||
switch status {
|
||||
case .idle: "circle"
|
||||
case .provisioning: "circle.dotted"
|
||||
case .running: "circle.lefthalf.filled"
|
||||
case .awaitingApproval: "exclamationmark.triangle.fill"
|
||||
case .awaitingInput: "bubble.left"
|
||||
case .finished: "checkmark.circle.fill"
|
||||
case .interrupted: "stop.circle"
|
||||
case .error: "xmark.octagon.fill"
|
||||
}
|
||||
}
|
||||
|
||||
static func color(_ status: SessionStatus) -> Color {
|
||||
switch status {
|
||||
case .awaitingApproval: .orange
|
||||
case .running, .provisioning: .blue
|
||||
case .finished: .green
|
||||
case .error: .red
|
||||
case .awaitingInput: .purple
|
||||
case .idle, .interrupted: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Attention-first ordering for the session list (UX_IOS §3).
|
||||
static func sortRank(_ status: SessionStatus) -> Int {
|
||||
switch status {
|
||||
case .awaitingApproval: 0
|
||||
case .awaitingInput: 1
|
||||
case .running, .provisioning: 2
|
||||
case .idle: 3
|
||||
case .finished, .interrupted, .error: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Risk {
|
||||
var isHigh: Bool { self == .destructive || self == .network }
|
||||
var label: String { rawValue }
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .destructive, .network: .red
|
||||
case .execute: .orange
|
||||
case .write: .yellow
|
||||
case .readOnly: .secondary
|
||||
case .unknown: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as
|
||||
/// bubbles; tool calls as collapsible rows; everything else compact.
|
||||
struct TranscriptRow: View {
|
||||
let event: AgentEvent
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
bubble(chunk.text, role: .user)
|
||||
case .assistantText(let chunk):
|
||||
bubble(chunk.text, role: .assistant)
|
||||
case .thinking(let chunk):
|
||||
Text(chunk.text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
toolRow(name: call.name, detail: call.input.compactSummary)
|
||||
case .toolResult(let result):
|
||||
DisclosureGroup("Result") {
|
||||
Text(result.content.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
case .approvalRequested(let req):
|
||||
Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
case .runFinished(let finished):
|
||||
Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
case .error(let err):
|
||||
Label(err.message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(.red)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private enum Role { case user, assistant }
|
||||
|
||||
@ViewBuilder
|
||||
private func bubble(_ text: String, role: Role) -> some View {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Text(text)
|
||||
.padding(10)
|
||||
.background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
||||
if role == .assistant { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
|
||||
private func toolRow(name: String, detail: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary)
|
||||
Text(name).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// A one-line gist of a tool input/result for the collapsed row.
|
||||
var compactSummary: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .object(let o):
|
||||
if let cmd = o["command"]?.stringValue { return cmd }
|
||||
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
|
||||
return o.keys.sorted().joined(separator: ", ")
|
||||
case .array(let a): return "[\(a.count) items]"
|
||||
case .number(let n): return String(n)
|
||||
case .bool(let b): return String(b)
|
||||
case .null: return "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user