From f80b1952c36cc26d8a84a7ffc9707b7a2214ac01 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Mon, 3 Aug 2026 23:56:01 -0700 Subject: [PATCH] Merge nucleic/warm-quartz-marten-mswp into dev --- .../Models/RemoteIntelligence.swift | 14 +++---- .../NucleicRemote/Models/RemoteStore.swift | 8 ++-- .../NucleicRemote/Views/Composer.swift | 40 ++++++++++++++----- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift index 383ae5c..3a5a09d 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift @@ -6,18 +6,16 @@ import NucleicProtocol /// The division of labor with the Mac is deliberate and worth stating once here, because it's /// what makes a rail on a thin remote client honest: /// -/// - **The host routes.** The matrix, the connected providers, the Settings pin, live quota and -/// provider incidents live on the Mac. A send carries the rail's *stop*, never a concrete pair -/// (`StartChatRequest.intelligence`, `ClientMsg.setSessionIntelligence`), and the host resolves -/// it with its own classifier. So the phone can never pin a worse model than the Mac would. +/// - **The host publishes the routes.** The matrix, connected providers, Settings pin, live quota, +/// and provider incidents live on the Mac. The phone only chooses among the concrete routes in +/// that projection; it does not recreate the matrix. /// - **The phone previews.** It runs the shared `HeuristicPurposeClassifier` — the same code the /// host runs, moved into NucleicProtocol precisely so both sides classify identically — over /// the draft, and looks the answer up in the host's projected route table. Sub-millisecond, no /// round trip, so the route line can follow every keystroke on a cellular link. -/// - **The preview can be wrong, and says so by being a preview.** The host's send-time -/// classification runs a fuller stack; when the two disagree, the transcript's routing note -/// records what actually ran. That's the same relationship the Mac's own pre-send preview has -/// with its send-time resolve. +/// - **The displayed pair is the launch authority.** A new-chat send carries the stop *and* the +/// projected model/effort pair. The host preserves that explicit pair rather than classifying a +/// second time, so the session cannot start on a model the route line never showed. extension RemoteStore { /// Whether the context host offers the rail at all. False keeps the composers on their manual diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index cb33774..e4d42a0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -2741,10 +2741,10 @@ final class RemoteStore: ObservableObject { // MARK: - Control intents (control scope; the same actions the Mac can take) - /// Start a chat on the owning Mac. `intelligence` is the rail's stop: pass it and leave - /// `model` nil to have the host classify the opening message and route it (the composer's - /// normal path); pass a concrete `model`/`effort` instead when the user pinned one. Sending - /// both leaves the explicit pair in charge — see `AppStore.remoteStartSelection`. + /// Start a chat on the owning Mac. The routed composer passes `intelligence` together with + /// the concrete model/effort displayed from the host's projected route table: the stop keeps + /// the rail positioned, while the explicit pair guarantees the session runs what was shown. + /// Older clients may still pass only the stop and let the host resolve it. func startChat(in projectID: ProjectID, message: String, model: String? = nil, effort: String? = nil, baseBranch: String? = nil, useWorktree: Bool = true, auto: Bool? = nil, diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index c1f561d..d43b974 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -287,7 +287,7 @@ struct StartChatComposer: View { orchestraAvailable: store.intelligenceCatalog.orchestraAvailable, composerText: draft, routeDescription: store.intelligenceRouteDescription( - for: draft, level: previewLevel), + for: routingPrompt, level: previewLevel), unavailableReason: store.canControl ? nil : "This device is view-only.", onPreviewChanged: { intelligencePreview = $0 }) IntelligenceRouteLine( @@ -317,27 +317,47 @@ struct StartChatComposer: View { private var previewLevel: Int { intelligencePreview ?? intelligence } private var previewRoute: WireIntelligenceCatalog.Route? { - store.intelligenceRoute(for: draft, level: previewLevel) + store.intelligenceRoute(for: routingPrompt, level: previewLevel) + } + + /// The route at the committed stop, distinct from the under-finger preview. This exact pair is + /// copied into the start request so the host cannot classify again and run something other + /// than the model and effort named by the route line. + private var committedRoute: WireIntelligenceCatalog.Route? { + store.intelligenceRoute(for: routingPrompt, level: intelligence) + } + + /// Attachment-only starts still need a visible concrete route. The host ultimately adds file + /// references to the opening prompt; filenames give the shared classifier equivalent context + /// before launch and, critically, are used by both the route line and the committed request. + private var routingPrompt: String { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.isEmpty, !attachments.isEmpty else { return draft } + return "Work with the attached files: " + attachments.map(\.filename).joined(separator: ", ") } private var orchestraActive: Bool { orchestra || MobileEfforts.isOrchestra(effort) } private var canStart: Bool { - selected != nil && store.canControl - && !(draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) + guard selected != nil, store.canControl, + !(draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) + else { return false } + guard store.routesIntelligence, !orchestra else { return true } + return committedRoute?.isAvailable == true } - /// Start the chat. When the rail is driving, the request carries the **stop** and no model: - /// the host classifies the real prompt with its own stack and picks the pair, so the phone - /// never pins a worse model than the Mac would have (see `RemoteIntelligence`). A host that - /// doesn't route sends the concrete values from its manual menus instead. + /// Start the chat. When the rail is driving, the request carries both its stop and the exact + /// concrete pair shown on screen. The stop preserves the rail position; the pair is the launch + /// authority, preventing a second host-side classification from contradicting the route line. private func start() { guard let project = selected else { return } let routed = store.routesIntelligence && !orchestra - let sentModel = routed ? nil : model + let route = routed ? committedRoute : nil + guard !routed || route?.isAvailable == true else { return } + let sentModel = routed ? route?.model : model let sentEffort = orchestra ? store.modelCatalog.orchestraSentinelOrFallback - : (routed ? nil : effort) + : (routed ? route?.effort : effort) let sentLevel = store.routesIntelligence ? intelligence : nil if runOnMesh && meshAvailable {