From 46eb3ccdbe5b5fc950c67b03e016aadac1d33fa4 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Mon, 3 Aug 2026 04:44:56 -0700 Subject: [PATCH] Merge nucleic/amber-breezy-lemur-uno1 into dev --- .../Models/RemoteIntelligence.swift | 76 ++++--- .../NucleicRemote/Views/Composer.swift | 104 ++++----- .../Views/IntelligenceRoute.swift | 206 +++--------------- .../Views/SessionDetailView.swift | 77 ++++--- 4 files changed, 169 insertions(+), 294 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift index 6f8e8fc..383ae5c 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift @@ -56,30 +56,35 @@ extension RemoteStore { // MARK: - The new-chat rail's remembered stop - private static let newChatLevelsKey = "nucleic.newChatIntelligence" + private static let newChatLevelKey = "nucleic.newChatIntelligence" + private static let newChatOrchestraKey = "nucleic.newChatOrchestra" - /// The rail's stop for a new chat in `project`, remembered per project scope exactly as the - /// draft text is. Reaching for the phone to start a chat in the same repo should not silently - /// reset a level the user deliberately chose last time. + /// The rail's stop for a new chat — **one value, not one per project**. How much capability + /// you want is a property of the person and the moment, not of the repo; keying it per project + /// meant the bar jumped every time the picker moved and silently discarded a level just chosen. /// - /// Falls back to the host's default, so a first-ever composer opens where the Mac would. - func newChatIntelligenceLevel(_ project: ProjectID?) -> Int { - let stored = UserDefaults.standard.dictionary(forKey: Self.newChatLevelsKey) as? [String: Int] - guard let level = stored?[newChatIntelligenceKey(project)], - intelligenceCatalog.level(level) != nil - else { return defaultIntelligenceLevel } - return level + /// Falls back to the host's default, so a first-ever composer opens where the Mac would, and + /// re-falls-back if a stored value names a rung this host's ladder doesn't have. + var newChatIntelligenceLevel: Int { + let stored = UserDefaults.standard.integer(forKey: Self.newChatLevelKey) + guard stored != 0, intelligenceCatalog.level(stored) != nil else { + return defaultIntelligenceLevel + } + return stored } - func setNewChatIntelligenceLevel(_ project: ProjectID?, _ level: Int) { - var stored = UserDefaults.standard.dictionary(forKey: Self.newChatLevelsKey) - as? [String: Int] ?? [:] - stored[newChatIntelligenceKey(project)] = level - UserDefaults.standard.set(stored, forKey: Self.newChatLevelsKey) + func setNewChatIntelligenceLevel(_ level: Int) { + UserDefaults.standard.set(level, forKey: Self.newChatLevelKey) } - private func newChatIntelligenceKey(_ project: ProjectID?) -> String { - project?.rawValue ?? "__none__" + /// Whether the new-chat rail sits on the constellation, remembered alongside the stop and for + /// the same reason. + var newChatOrchestra: Bool { + UserDefaults.standard.bool(forKey: Self.newChatOrchestraKey) + } + + func setNewChatOrchestra(_ on: Bool) { + UserDefaults.standard.set(on, forKey: Self.newChatOrchestraKey) } /// Turn Orchestra on for an open chat. It rides the existing effort sentinel rather than a @@ -89,26 +94,47 @@ extension RemoteStore { setSessionEffort(id, modelCatalog.orchestraSentinelOrFallback) } - /// The stop an open chat's rail should show. The host records the level it routed the chat at - /// (`SessionSummary.effort`/`model` are the *result*, not the stop), so until that rides the - /// wire the rail reads back the level whose resolved route matches the session's current pair - /// — which is exactly what the Mac does for a chat that predates routing. + /// The stop an open chat's rail sits at — the level the **host** recorded when it routed the + /// chat, straight off the wire. This is the same number the Mac's own rail reads, which is + /// what keeps the two devices on the same stop. + /// + /// The fallback only covers a chat that was never routed (started before routing, or with a + /// model pinned by hand): there it matches the pair back through the route table, exactly as + /// the Mac's `closestSessionIntelligenceLevel` does. That inference is *only* a fallback — + /// using it for routed chats was the bug that let the two devices disagree, since several + /// stops legitimately share a model. func sessionIntelligenceLevel(_ summary: WireSessionSummary) -> Int { guard routesIntelligence else { return defaultIntelligenceLevel } + if let routed = summary.routedLevel, intelligenceCatalog.level(routed) != nil { + return routed + } if modelCatalog.isOrchestra(summary.effort) { return intelligenceCatalog.levels.last?.rawValue ?? defaultIntelligenceLevel } guard let model = summary.model, let effort = summary.effort else { return defaultIntelligenceLevel } - // Prefer an exact (model, effort) match on the chat's own purpose row; fall back to a - // model-only match before giving up, so a chat whose effort was nudged by hand still puts - // the rail somewhere truthful rather than snapping it to the default. let candidates = intelligenceCatalog.routes.filter { $0.model == model } if let exact = candidates.first(where: { $0.effort == effort }) { return exact.level } return candidates.first?.level ?? defaultIntelligenceLevel } + /// What moving an open chat's rail to `level` would resolve to — predicted with the purpose + /// the **host** routed this chat under, not with a fresh classification of the composer draft. + /// + /// That distinction is the whole fix for the model mismatch: the host re-routes an existing + /// chat against its recorded purpose (moving the rail changes the budget, not what the chat is + /// for), so classifying the follow-up draft here instead produced a different row of the matrix + /// and named a model the Mac never picked. A chat with no recorded purpose falls back to the + /// conservative `general` row — which is also what the host falls back to. + func sessionIntelligenceRoute( + _ summary: WireSessionSummary, level: Int + ) -> WireIntelligenceCatalog.Route? { + guard routesIntelligence else { return nil } + let purpose = summary.routedPurpose.flatMap(PromptPurpose.init(rawValue:)) ?? .general + return intelligenceCatalog.route(purpose: purpose, level: level) + } + /// A stand-in ladder for demo mode, so the rail, its constellation and the route line are all /// exercisable without a paired Mac (App Review runs the app this way — see the demo seed). /// Deliberately a *plausible* slice of the real matrix rather than a uniform one: a flat table diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index b047c61..c1f561d 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -53,9 +53,6 @@ struct StartChatComposer: View { /// The stop under the finger mid-drag, so the route line follows it before the binding /// commits on release. `nil` between gestures. @State private var intelligencePreview: Int? - /// A concrete pair pinned from the route line, which suspends routing for this chat only. - @State private var pinnedModel: String? - @State private var pinnedEffort: String? // Advanced options (the Mac new-session sheet's base branch + worktree fields). Now reached // from the `⋯` menu rather than a disclosed row — they're set once per project, not per chat. @State private var showOptions = false @@ -114,17 +111,25 @@ struct StartChatComposer: View { // Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder. .onAppear(perform: syncToProject) .onChange(of: projectID) { _, _ in syncToProject() } + // Restore the rail once the host's ladder is in hand. Keyed on the catalog because it + // arrives with the welcome, often after this view first appears — seeding on `onAppear` + // alone would clamp a remembered stop against an empty ladder and fall back to the default. + .task(id: store.intelligenceCatalog) { + guard store.routesIntelligence else { return } + intelligence = store.newChatIntelligenceLevel + orchestra = store.newChatOrchestra + } // Orchestra is a rail position on screen but the effort sentinel on the wire; keep the two // in step here so the start path below needs no special case. .onChange(of: orchestra) { _, on in effort = on ? store.modelCatalog.orchestraSentinelOrFallback : (store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback) + store.setNewChatOrchestra(on) } - // Remember the stop per project scope, exactly as the draft text is remembered: coming - // back to the same repo shouldn't silently reset a level the user chose deliberately. + // One remembered stop for the composer, not one per project — see `newChatIntelligenceLevel`. .onChange(of: intelligence) { _, level in - store.setNewChatIntelligenceLevel(lockedProject?.id ?? projectID, level) + store.setNewChatIntelligenceLevel(level) } // Switching to a model with a lower effort cap can't leave an unsupported level selected. .onChange(of: model) { _, newModel in @@ -260,9 +265,12 @@ struct StartChatComposer: View { // MARK: - Row 3: how much intelligence - /// The rail and its route line — or, on a host that doesn't route, the model and effort menus - /// it replaces. Placed last so it sits closest to the thumb: it's the control most likely to - /// be adjusted in the moment before sending. + /// The rail and its (static) route line — or, on a host that doesn't route, the model and + /// effort menus it replaces. Placed last so it sits closest to the thumb: it's the control + /// most likely to be adjusted in the moment before sending. + /// + /// With the rail on, the rail is the *only* control here — the route line below it is a + /// readout, and manual model/effort selection is off. The two are alternatives, not layers. @ViewBuilder private var intelligenceRow: some View { if store.routesIntelligence { @@ -271,41 +279,26 @@ struct StartChatComposer: View { level: $intelligence, orchestra: $orchestra, catalog: store.intelligenceCatalog, - enabled: store.canControl && pinnedModel == nil, - orchestraAvailable: controlled, + enabled: store.canControl, + // Always offered. Orchestra used to be gated on the project being under + // Nucleic Control, which meant the constellation appeared and vanished as the + // project picker moved — the rail changing shape under the user's thumb for a + // reason nothing on screen explained. + orchestraAvailable: store.intelligenceCatalog.orchestraAvailable, composerText: draft, routeDescription: store.intelligenceRouteDescription( for: draft, level: previewLevel), - unavailableReason: railUnavailableReason, + unavailableReason: store.canControl ? nil : "This device is view-only.", onPreviewChanged: { intelligencePreview = $0 }) IntelligenceRouteLine( level: previewLevel, orchestra: orchestra, catalog: store.intelligenceCatalog, - route: previewRoute, - orchestraAvailable: controlled, - enabled: store.canControl, - onSelect: { level, wantsOrchestra in - pinnedModel = nil - pinnedEffort = nil - intelligence = level - orchestra = wantsOrchestra - }, - onSelectRoute: { model, effort in - // A deliberate pin suspends routing for this chat only — the rail stays on - // screen at the stop it was, dimmed, and the route line says it's pinned. - pinnedModel = model - pinnedEffort = effort - orchestra = false - }, - modelCatalog: store.modelCatalog, - manualSelection: pinnedModel.flatMap { model in - pinnedEffort.map { (model: model, effort: $0) } - }, - onResumeRouting: { - pinnedModel = nil - pinnedEffort = nil - }) + model: previewRoute?.model, + effort: previewRoute?.effort, + unavailableReason: previewRoute?.isAvailable == false + ? "at its usage limit" : nil, + modelCatalog: store.modelCatalog) } } else { // No routing on this host (older Mac, or the Settings toggle off) — the pair of menus @@ -327,18 +320,7 @@ struct StartChatComposer: View { store.intelligenceRoute(for: draft, level: previewLevel) } - private var orchestraActive: Bool { controlled && (orchestra || MobileEfforts.isOrchestra(effort)) } - - /// Why the rail is inert, when it is. Dimming alone says "unavailable" but not why, and a - /// phone has no tooltip to fill the gap — so this rides the accessibility value and the - /// route line both. - private var railUnavailableReason: String? { - if !store.canControl { return "This device is view-only." } - if pinnedModel != nil { - return "A model is pinned for this chat. Choose Automatic Routing to use the rail." - } - return nil - } + private var orchestraActive: Bool { orchestra || MobileEfforts.isOrchestra(effort) } private var canStart: Bool { selected != nil && store.canControl @@ -347,15 +329,15 @@ struct StartChatComposer: View { /// 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 pinned pair - /// — or a host that doesn't route — sends the concrete values instead. + /// 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. private func start() { guard let project = selected else { return } - let routed = store.routesIntelligence && pinnedModel == nil && !orchestra - let sentModel = routed ? nil : (pinnedModel ?? model) + let routed = store.routesIntelligence && !orchestra + let sentModel = routed ? nil : model let sentEffort = orchestra ? store.modelCatalog.orchestraSentinelOrFallback - : (routed ? nil : (pinnedEffort ?? effort)) + : (routed ? nil : effort) let sentLevel = store.routesIntelligence ? intelligence : nil if runOnMesh && meshAvailable { @@ -378,17 +360,15 @@ struct StartChatComposer: View { onStart?() } - /// Sync the composer to the freshly-selected project: show its default branch as the base, - /// restore the rail's remembered stop for this scope, and resolve a carried-over Orchestra - /// selection down to a real level when the new project isn't under Nucleic Control (Orchestra - /// can't run there — mirrors the Mac's `effectiveEffort`). + /// Sync the composer to the freshly-selected project. + /// + /// Only the base branch is project-derived. The rail is deliberately *not* touched here: how + /// much capability you want is a property of the person and the moment, not of the repo, and + /// re-seeding it per project meant the bar visibly jumped every time the picker moved — and + /// silently downgraded a level you'd just chosen. Orchestra likewise survives the switch now + /// that it's offered everywhere. private func syncToProject() { baseBranch = selected?.defaultBranch ?? "" - intelligence = store.newChatIntelligenceLevel(lockedProject?.id ?? projectID) - if !controlled { orchestra = false } - if MobileEfforts.isOrchestra(effort), !controlled { - effort = store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback - } } } diff --git a/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift index c9dea59..d1eec12 100644 --- a/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift +++ b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift @@ -1,47 +1,36 @@ import SwiftUI import NucleicProtocol -/// The line beneath the Intelligence rail: what the current stop actually resolves to, and the -/// rail's non-gestural twin. +/// The line beneath the Intelligence rail: the stop's name and what it resolves to. /// -/// On the Mac the same information sits in a fixed-footprint subtext under the slider, with a -/// transparent menu laid over the resolved words. The phone keeps that idea and leans on it -/// harder, because it has to carry three jobs the Mac spread across hover, tooltips, and a wider -/// composer: +/// **A readout, not a control.** The Mac lays a transparent menu over the same subtext so the +/// resolved words double as a model/effort picker. That doesn't earn its place here: with the rail +/// on, the rail *is* the control, and a second way to set the same thing — one that silently +/// suspends routing when used — is a contradiction on a surface this small. So this is plain text. +/// Manual model and effort selection remains available exactly where it always was, in the chat's +/// ⋯ menu, and only on a host that isn't routing. /// -/// 1. **Name the stop.** The Mac shows the level in a tooltip on hover. There is no hover here, -/// so the stop's name leads this line — "Balanced · Sonnet 5 · High" — and the rail alone -/// never has to be read for it. -/// 2. **Be the whole control, without a gesture.** Tapping opens a menu of every stop (with the -/// one-sentence blurb the host projected), Orchestra where it's offered, and the concrete -/// model/effort pickers. Anyone who can't drag a thumb along a 10pt bar — or simply doesn't -/// want to — never has to. -/// 3. **Explain itself.** The router's reason ("Debugging · Max — …") is a menu footer rather -/// than a tooltip, since a phone has nowhere to hover. +/// It still carries three facts the Mac spreads across hover and tooltips, because a phone has +/// neither: the stop's **name** (the rail alone never has to be read for it), the concrete +/// **model · effort** it lands on, and — when quota has closed a lane — the fact that this stop +/// currently can't run. /// -/// The footprint is fixed so a model-name change can't shift the composer's rows under a -/// thumb that's mid-reach for the send button. +/// The footprint is fixed so a model-name change can't shift the composer's rows under a thumb +/// that's mid-reach for the send button. struct IntelligenceRouteLine: View { /// The stop being described — the one under the finger during a drag, else the committed one. var level: Int var orchestra: Bool var catalog: WireIntelligenceCatalog - /// The resolved route for this stop. `nil` while nothing has been classified yet (an empty - /// draft), in which case the line names the stop alone. - var route: WireIntelligenceCatalog.Route? - var orchestraAvailable: Bool - var enabled: Bool = true - /// Applied when the user picks a stop from the menu rather than moving the rail. - var onSelect: (_ level: Int, _ orchestra: Bool) -> Void - /// Applied when they pin a concrete pair instead, leaving automatic routing for this chat. - /// `nil` hides the model/effort section (an in-session composer whose backend is fixed passes - /// its own narrowed menu instead). - var onSelectRoute: ((_ model: String, _ effort: String) -> Void)? = nil - /// The catalog behind the manual pickers. + /// The pair this stop lands on. For an open chat at rest that's the session's *actual* model + /// and effort (ground truth from the host); mid-drag, and in the new-chat composer, it's the + /// route the host would resolve. `nil` shows the stop alone — an empty draft has nothing to + /// route yet, and saying nothing beats promising a model. + var model: String? + var effort: String? + /// The router's own sentence, shown in place of the pair when this stop can't currently run. + var unavailableReason: String? var modelCatalog: WireModelCatalog = .empty - /// Set while a manual pair is pinned, so the line can say so and offer the way back. - var manualSelection: (model: String, effort: String)? = nil - var onResumeRouting: (() -> Void)? = nil /// Tracks its own text style, so at accessibility sizes the row grows with the model name it /// exists to show rather than clipping it. @@ -54,158 +43,33 @@ struct IntelligenceRouteLine: View { ? (IntelligenceLevel(rawValue: level)?.displayName ?? "") : projected } - /// "Balanced · Sonnet 5 · High", or just the stop before anything is classified. Orchestra - /// states its mode rather than a pair — the supervisor model isn't the interesting fact there. + /// "Balanced · Sonnet 5 · High", or just the stop before anything resolves. Orchestra states + /// its mode rather than a pair — the supervisor model isn't the interesting fact there. private var summary: String { if orchestra { return "\(stopName) · parallel subagents" } - guard let pair = manualSelection ?? route.map({ ($0.model, $0.effort) }) else { - return stopName - } - let model = modelCatalog.displayName(pair.model) - let effort = modelCatalog.effortDisplayName(pair.effort).localizedCapitalized - return "\(stopName) · \(model) · \(effort)" + if let unavailableReason { return "\(stopName) · \(unavailableReason)" } + guard let model, let effort else { return stopName } + return "\(stopName) · \(modelCatalog.displayName(model)) · " + + modelCatalog.effortDisplayName(effort).localizedCapitalized } - private var isUnavailable: Bool { route?.isAvailable == false } - var body: some View { - Menu { - menuContent - } label: { - HStack(spacing: 4) { - Text(summary) - .lineLimit(1) - .truncationMode(.middle) - if manualSelection != nil { - // A pinned pair is a deliberate exception to routing, so it says so rather - // than looking like an ordinary resolved route. - Image(systemName: "pin.fill").font(.caption2) - } - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.tertiary) - } + Text(summary) .font(.caption.weight(.medium)) .foregroundStyle(lineStyle) + .lineLimit(1) + .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: rowHeight) - .contentShape(Rectangle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .disabled(!enabled) - .accessibilityLabel("Intelligence route") - .accessibilityValue(Text(summary)) - .accessibilityHint(Text("Choose a level, or pin a model and effort for this chat.")) + // One element with the rail, not a second stop in the VoiceOver order: this restates + // what the rail's own value already says, and a swipe onto a duplicate readout is pure + // friction. The rail is the thing to focus. + .accessibilityHidden(true) } private var lineStyle: AnyShapeStyle { - if isUnavailable { return AnyShapeStyle(.orange) } + if unavailableReason != nil { return AnyShapeStyle(.orange) } if orchestra { return AnyShapeStyle(Palette.orchestra) } return AnyShapeStyle(.secondary) } - - @ViewBuilder - private var menuContent: some View { - // The ladder, each stop carrying the sentence the host wrote for it. This is the only - // place the levels get to explain themselves on a phone — the Mac says it in hover help. - Section("Intelligence") { - ForEach(catalog.levels.sorted { $0.rawValue > $1.rawValue }) { stop in - Button { - onSelect(stop.rawValue, false) - Haptics.detent() - } label: { - let selected = !orchestra && stop.rawValue == level - Label { - Text(stop.displayName) - Text(stop.blurb) - } icon: { - if selected { Image(systemName: "checkmark") } - } - } - } - if orchestraAvailable { - Button { - onSelect(catalog.levels.last?.rawValue ?? level, true) - Haptics.orchestraEngaged() - } label: { - Label { - Text(catalog.orchestraDisplayName) - Text(catalog.orchestraBlurb) - } icon: { - Image(systemName: orchestra ? "checkmark" : "sparkles") - } - } - } - } - - if let onSelectRoute, !orchestra, !modelCatalog.groups.isEmpty { - Section("Or pin a model") { - modelSection(onSelectRoute) - effortSection(onSelectRoute) - if manualSelection != nil, let onResumeRouting { - Button("Use Automatic Routing", systemImage: "sparkles") { - onResumeRouting() - } - } - } - } - - // Why this route — the explanation a tooltip carries on the Mac. A menu has no - // non-interactive text, so it rides as a disabled item: greyed, unselectable, and - // reliably rendered (an empty `Section` header can be dropped entirely). - if let route, !orchestra { - Section { - Button(route.reason) {}.disabled(true) - } - } - } - - @ViewBuilder - private func modelSection(_ apply: @escaping (String, String) -> Void) -> some View { - let current = manualSelection ?? route.map { ($0.model, $0.effort) } - Menu("Model: \(modelCatalog.displayName(current?.model))") { - ForEach(Array(modelCatalog.groups.enumerated()), id: \.offset) { index, group in - if index > 0 { Divider() } - ForEach(group) { item in - Button { - apply(item.sku, clamped(current?.effort ?? item.efforts.last ?? "", to: item)) - } label: { - if item.sku == current?.model { - Label(item.displayName, systemImage: "checkmark") - } else { - Text(item.displayName) - } - } - } - } - } - } - - @ViewBuilder - private func effortSection(_ apply: @escaping (String, String) -> Void) -> some View { - let current = manualSelection ?? route.map { ($0.model, $0.effort) } - let model = current?.model - let noun = modelCatalog.effortNoun(forModel: model) - Menu("\(noun): \(modelCatalog.effortDisplayName(current?.effort ?? "").localizedCapitalized)") { - ForEach(modelCatalog.offeredEfforts(forModel: model), id: \.self) { effort in - Button { - apply(model ?? modelCatalog.fallbackModel, effort) - } label: { - if effort == current?.effort { - Label(modelCatalog.effortDisplayName(effort), systemImage: "checkmark") - } else { - Text(modelCatalog.effortDisplayName(effort)) - } - } - } - } - } - - /// Keep a carried-over effort inside what the newly-picked model supports (Codex tops out at - /// xhigh, the ACP wrappers expose only Auto), so switching model can't strand an unrunnable - /// pair — the phone's mirror of the host's `EffortLadder.clampedEffort`. - private func clamped(_ effort: String, to model: WireModelCatalog.Model) -> String { - model.efforts.contains(effort) ? effort : (model.efforts.last ?? effort) - } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 53dedf8..061b1ef 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -406,7 +406,8 @@ struct SessionDetailView: View { set: { on in if on { store.setSessionOrchestra(sessionID) } }), catalog: store.intelligenceCatalog, enabled: store.connectivity.isLive && !summary.archived, - orchestraAvailable: controlled, + // Always offered — no Nucleic Control gate. See the new-chat composer. + orchestraAvailable: store.intelligenceCatalog.orchestraAvailable, composerText: draft, routeDescription: sessionRouteDescription, unavailableReason: summary.archived @@ -417,32 +418,28 @@ struct SessionDetailView: View { level: level, orchestra: sessionOrchestra, catalog: store.intelligenceCatalog, - route: store.intelligenceRoute(for: draft, level: level), - orchestraAvailable: controlled, - enabled: store.connectivity.isLive && !summary.archived, - onSelect: { next, wantsOrchestra in - if wantsOrchestra { - store.setSessionOrchestra(sessionID) - } else { - pendingIntelligence = next - store.setSessionIntelligence(sessionID, level: next) - } - }, - // An existing chat's backend is fixed at creation, so a manual pin here goes - // through the ordinary per-session model/effort verbs — which already refuse a - // cross-backend model — rather than the rail's routing path. - onSelectRoute: { model, effort in - pendingIntelligence = nil - store.setSessionModel(sessionID, model) - store.setSessionEffort(sessionID, effort) - }, - modelCatalog: sessionModelCatalog(summary)) + model: sessionPreviewPair(summary, level: level)?.model, + effort: sessionPreviewPair(summary, level: level)?.effort, + modelCatalog: store.modelCatalog) } - // The host's answer is authoritative: once the summary reflects a model/effort, drop - // the optimistic stop so the rail reads back what actually happened — including when - // the host *refused* the move (a quota-blocked stop), where the rail springs back. + // The host's answer is authoritative: drop the optimistic stop as soon as one lands, + // so the rail reads back what actually happened. + // + // Keyed on the recorded *level* as well as the pair: adjacent stops legitimately + // resolve to the same model and effort (Deep and Max share a cell on several purpose + // rows), so watching the pair alone would leave the optimistic value latched on + // exactly the moves that changed nothing visible — where it would then shadow the + // next change made from another device. + .onChange(of: summary.routedLevel) { _, _ in pendingIntelligence = nil } .onChange(of: summary.model) { _, _ in pendingIntelligence = nil } .onChange(of: summary.effort) { _, _ in pendingIntelligence = nil } + // A refused move — every candidate for that stop is inside a reached quota window — + // changes nothing at all; the host answers with an error instead. Without this the + // rail would stay parked on a stop the chat never reached, so it springs back here + // and the error banner says why. + .onChange(of: store.lastError) { _, error in + if error?.sessionID == sessionID { pendingIntelligence = nil } + } } } @@ -455,18 +452,26 @@ struct SessionDetailView: View { + store.modelCatalog.effortDisplayName(effort) } - /// The model catalog narrowed to this chat's backend, so the route line's manual picker can't - /// offer a provider the session can never switch to. - private func sessionModelCatalog(_ summary: WireSessionSummary) -> WireModelCatalog { - let models = store.modelCatalog.models(for: summary.backend) - guard !models.isEmpty else { return store.modelCatalog } - return WireModelCatalog( - groups: [models], - effortDisplayNames: store.modelCatalog.effortDisplayNames, - orchestraSentinel: store.modelCatalog.orchestraSentinel, - orchestraRequiresControlNote: store.modelCatalog.orchestraRequiresControlNote, - fallbackModel: summary.model ?? models[0].sku, - fallbackEffort: store.modelCatalog.fallbackEffort) + /// The pair the route line names for an open chat. + /// + /// At rest that is the session's **actual** model and effort as the host broadcast them — a + /// fact, not a prediction, and the same fact the Mac's own composer shows. Re-predicting it + /// here was the mismatch: this device classified the follow-up *draft* while the host routes + /// on what the chat is *for*, so the two named different models for the same stop. + /// + /// Only mid-drag does it predict, and then through the session's recorded purpose + /// (`sessionIntelligenceRoute`) — so the number that appears under the thumb is the one the + /// host will actually resolve on release. + private func sessionPreviewPair( + _ summary: WireSessionSummary, level: Int + ) -> (model: String, effort: String)? { + if intelligencePreview != nil || pendingIntelligence != nil { + if let route = store.sessionIntelligenceRoute(summary, level: level) { + return (route.model, route.effort) + } + } + guard let model = summary.model, let effort = summary.effort else { return nil } + return (model, effort) } /// "View changes" with the live +/− counts when the host projects them, so the menu hints