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,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user