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:
2026-06-13 01:12:55 -07:00
co-authored by Claude Opus 4.8
commit 6f24b42004
20 changed files with 1494 additions and 0 deletions
@@ -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()
}
}