From d4734e5872000933f48bb15c33563a6178a36b30 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 21:18:51 +0000 Subject: [PATCH] Phone-initiated project creation: projectCreated reply + iOS Add Project UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The createProject verb landed host-side but nothing sent it. This adds the client half (CLOUD_RUNTIME §6 "phone-initiated project creation"): Wire: WireCreateProjectRequest gains an optional requestID; HostMsg.projectCreated (WireProjectCreated: requestID echo + projectID/name on success, error message on failure) answers the requesting connection — additive (.unknown fallback on old clients) with the SyncClient.Event case + messageLoop branch per the §11.2 checklist. SyncHostBridge.createProject now returns the outcome; ConnectionHandler folds a thrown WireError into a failed outcome so the asking UI always settles. iOS: Projects tab gains an Add Project sheet (git URL + optional name/branch, host picker when several live hosts advertise canCreateProjects), driven by RemoteStore.addProject state — creating → created/failed, correlated by requestID so a stale reply can't settle a newer request. The project row itself arrives via the dashboard push. Verified: macOS nucleicd/NucleicApp builds + protocol/sync suites green; iOS xcodebuild green; Linux nucleicd build + protocol suite green (167 tests, incl. new round-trip/legacy-tolerance coverage). Co-Authored-By: Claude Fable 5 --- .../NucleicRemote/Models/HostConnection.swift | 7 ++ .../NucleicRemote/Models/RemoteStore.swift | 71 +++++++++++ .../NucleicRemote/Views/ProjectsView.swift | 111 +++++++++++++++++- 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index f5da36c..136f7aa 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -113,6 +113,9 @@ final class HostConnection { var macPairRequested: (WireMacPairRequest) -> Void = { _ in } /// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt. var macPairResolved: (String) -> Void = { _ in } + /// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by + /// requestID — settle the Add Project sheet (the project row rides the dashboard push). + var projectCreated: (WireProjectCreated) -> Void = { _ in } } private let callbacks: Callbacks @@ -640,6 +643,10 @@ final class HostConnection { case .macPairResolved(let deviceID, _): // Answered on this Mac or another device (or timed out) — dismiss the prompt. callbacks.macPairResolved(deviceID) + case .projectCreated(let outcome): + // The host settled a createProject we sent — hand it up so the Add Project sheet + // resolves (success or failure). Correlation by requestID happens in RemoteStore. + callbacks.projectCreated(outcome) case .intelligenceRequest, .credentialNeeded, .credentialUpdate: // Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating // intelligence work or asking for / mirroring sealed credentials. Inert here until the diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 6594893..3df9002 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -120,6 +120,22 @@ final class RemoteStore: ObservableObject { /// its pairing window. private var pairingMintHostID: String? + /// "Add a project" (Projects tab): the phone asks a connected host advertising + /// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a + /// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as + /// `HostMsg.projectCreated`, correlated by the request id below; the project row itself + /// rides the dashboard push. + enum AddProjectState: Equatable { + case idle + case creating // waiting on the host's `projectCreated` reply + case created(String) // success — the new project's display name + case failed(String) // the host's human-readable failure (clone error, bad URL, …) + } + @Published private(set) var addProject: AddProjectState = .idle + /// The in-flight request's id — replies are matched on it, so a late/stale `projectCreated` + /// (the user dismissed the sheet and started another) can't settle the wrong request. + private var createProjectRequestID: String? + /// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac /// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from /// the phone. The "add a device" sheet renders an allow/deny dialog for it. @@ -887,6 +903,18 @@ final class RemoteStore: ObservableObject { self.pendingMacPairRequest = nil self.pendingMacPairHostID = nil } + cb.projectCreated = { [weak self] outcome in + guard let self else { return } + // Only the reply to the request in flight settles the sheet — a stale one (the user + // dismissed and retried, or another device's create) is dropped. + guard let pending = self.createProjectRequestID, outcome.requestID == pending else { return } + self.createProjectRequestID = nil + if let error = outcome.error { + self.addProject = .failed(error) + } else { + self.addProject = .created(outcome.name ?? "Project") + } + } return cb } @@ -1400,6 +1428,49 @@ final class RemoteStore: ObservableObject { /// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac. private static let demoPairingCode = "nucleic://pair?d=demo" + // MARK: - Add a project (CLOUD_RUNTIME §4.3) + + /// Hosts that can register a project right now (live + advertising `canCreateProjects`) — + /// the Add Project sheet's destination picker. Name-sorted for a stable picker. + var projectCreationHosts: [(hostID: String, name: String)] { + connections.values + .filter { $0.connectivity.isLive && $0.capabilities.canCreateProjects } + .map { ($0.hostID, $0.hostName) } + .sorted { $0.name < $1.name } + } + + /// Whether the Add Project affordance should appear: a capable host is reachable (or demo). + var canCreateProject: Bool { demoMode || !projectCreationHosts.isEmpty } + + /// Ask `hostID` (or the first capable host) to clone `gitURL` and register it as a project. + /// The outcome arrives asynchronously as `.created`/`.failed` via the host connection; the + /// new project row follows on the dashboard push. + func createProject(gitURL: String, name: String?, branch: String?, onHost hostID: String?) { + if demoMode { + addProject = .created(name?.isEmpty == false ? name! : "Project") + return + } + let conn = hostID.flatMap { connections[$0] } + ?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canCreateProjects } + guard let conn, conn.connectivity.isLive, conn.capabilities.canCreateProjects else { + addProject = .failed("No connected host can add projects right now.") + return + } + let requestID = UUID().uuidString + createProjectRequestID = requestID + addProject = .creating + conn.send(.createProject(WireCreateProjectRequest( + gitURL: gitURL, branch: branch?.isEmpty == false ? branch : nil, + name: name?.isEmpty == false ? name : nil, requestID: requestID))) + } + + /// The Add Project sheet closed — drop any in-flight correlation (a late reply is ignored) + /// and reset the state for the next open. + func resetAddProject() { + createProjectRequestID = nil + addProject = .idle + } + /// Record that the user looked at this session now (clears its unseen-completion wash). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 23f85e9..49b6983 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -5,13 +5,23 @@ import NucleicProtocol /// counts; tap through to that project's sessions + a scoped composer. struct ProjectsView: View { @EnvironmentObject var store: RemoteStore + @State private var showAddProject = false var body: some View { NavigationStack { Group { if store.dashboard.projects.isEmpty { - ContentUnavailableView("No projects", systemImage: "folder", - description: Text("Add a project on the Mac to see it here.")) + ContentUnavailableView { + Label("No projects", systemImage: "folder") + } description: { + Text(store.canCreateProject + ? "Add a project here, or on the Mac." + : "Add a project on the Mac to see it here.") + } actions: { + if store.canCreateProject { + Button("Add Project") { showAddProject = true } + } + } } else { List(store.dashboard.projects) { project in NavigationLink { @@ -40,10 +50,107 @@ struct ProjectsView: View { } .navigationTitle("Projects") .refreshable { store.refreshSessions() } + .toolbar { + // "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host — + // how a fresh Antimatter runner gets its first project. Hidden when no live + // host advertises `canCreateProjects` (an older Mac would reject the verb). + if store.canCreateProject { + ToolbarItem(placement: .primaryAction) { + Button { showAddProject = true } label: { + Label("Add Project", systemImage: "plus") + } + } + } + } + .sheet(isPresented: $showAddProject) { + AddProjectSheet() + } } } } +/// Clone-and-register a project on a connected host: git URL (+ optional name/branch) and, +/// with more than one capable host, a destination picker. The request settles asynchronously +/// (`RemoteStore.addProject`); the sheet shows progress, the failure message, or dismisses on +/// success — the new project row arrives via the dashboard push. +private struct AddProjectSheet: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var name = "" + @State private var branch = "" + @State private var hostID: String? + + private var hosts: [(hostID: String, name: String)] { store.projectCreationHosts } + private var creating: Bool { store.addProject == .creating } + private var canSubmit: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !creating + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Git URL", text: $gitURL, prompt: Text("https://github.com/you/repo")) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } footer: { + Text("The host clones this repository and manages the checkout itself.") + } + Section("Options") { + TextField("Name (optional)", text: $name) + TextField("Branch (optional)", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + if hosts.count > 1 { + Section("Create on") { + Picker("Host", selection: $hostID) { + ForEach(hosts, id: \.hostID) { host in + Text(host.name).tag(Optional(host.hostID)) + } + } + } + } + if case .failed(let message) = store.addProject { + Section { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + } + .navigationTitle("Add Project") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(creating) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() }.disabled(creating) + } + ToolbarItem(placement: .confirmationAction) { + if creating { + ProgressView() + } else { + Button("Create") { + store.createProject( + gitURL: gitURL.trimmingCharacters(in: .whitespacesAndNewlines), + name: name, branch: branch, onHost: hostID) + } + .disabled(!canSubmit) + } + } + } + .onAppear { if hostID == nil { hostID = hosts.first?.hostID } } + .onChange(of: store.addProject) { _, state in + if case .created = state { dismiss() } + } + .onDisappear { store.resetAddProject() } + } + .presentationDetents([.medium]) + } +} + struct ProjectDetailView: View { @EnvironmentObject var store: RemoteStore @Environment(\.horizontalSizeClass) private var sizeClass