Merge nucleic/amber-breezy-lemur-uno1 into dev

This commit is contained in:
2026-08-03 04:44:56 -07:00
parent 40d4c53a76
commit 46eb3ccdbe
4 changed files with 169 additions and 294 deletions
@@ -56,30 +56,35 @@ extension RemoteStore {
// MARK: - The new-chat rail's remembered stop // 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 /// The rail's stop for a new chat — **one value, not one per project**. How much capability
/// draft text is. Reaching for the phone to start a chat in the same repo should not silently /// you want is a property of the person and the moment, not of the repo; keying it per project
/// reset a level the user deliberately chose last time. /// 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. /// Falls back to the host's default, so a first-ever composer opens where the Mac would, and
func newChatIntelligenceLevel(_ project: ProjectID?) -> Int { /// re-falls-back if a stored value names a rung this host's ladder doesn't have.
let stored = UserDefaults.standard.dictionary(forKey: Self.newChatLevelsKey) as? [String: Int] var newChatIntelligenceLevel: Int {
guard let level = stored?[newChatIntelligenceKey(project)], let stored = UserDefaults.standard.integer(forKey: Self.newChatLevelKey)
intelligenceCatalog.level(level) != nil guard stored != 0, intelligenceCatalog.level(stored) != nil else {
else { return defaultIntelligenceLevel } return defaultIntelligenceLevel
return level }
return stored
} }
func setNewChatIntelligenceLevel(_ project: ProjectID?, _ level: Int) { func setNewChatIntelligenceLevel(_ level: Int) {
var stored = UserDefaults.standard.dictionary(forKey: Self.newChatLevelsKey) UserDefaults.standard.set(level, forKey: Self.newChatLevelKey)
as? [String: Int] ?? [:]
stored[newChatIntelligenceKey(project)] = level
UserDefaults.standard.set(stored, forKey: Self.newChatLevelsKey)
} }
private func newChatIntelligenceKey(_ project: ProjectID?) -> String { /// Whether the new-chat rail sits on the constellation, remembered alongside the stop and for
project?.rawValue ?? "__none__" /// 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 /// 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) setSessionEffort(id, modelCatalog.orchestraSentinelOrFallback)
} }
/// The stop an open chat's rail should show. The host records the level it routed the chat at /// The stop an open chat's rail sits at — the level the **host** recorded when it routed the
/// (`SessionSummary.effort`/`model` are the *result*, not the stop), so until that rides the /// chat, straight off the wire. This is the same number the Mac's own rail reads, which is
/// wire the rail reads back the level whose resolved route matches the session's current pair /// what keeps the two devices on the same stop.
/// — which is exactly what the Mac does for a chat that predates routing. ///
/// 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 { func sessionIntelligenceLevel(_ summary: WireSessionSummary) -> Int {
guard routesIntelligence else { return defaultIntelligenceLevel } guard routesIntelligence else { return defaultIntelligenceLevel }
if let routed = summary.routedLevel, intelligenceCatalog.level(routed) != nil {
return routed
}
if modelCatalog.isOrchestra(summary.effort) { if modelCatalog.isOrchestra(summary.effort) {
return intelligenceCatalog.levels.last?.rawValue ?? defaultIntelligenceLevel return intelligenceCatalog.levels.last?.rawValue ?? defaultIntelligenceLevel
} }
guard let model = summary.model, let effort = summary.effort else { guard let model = summary.model, let effort = summary.effort else {
return defaultIntelligenceLevel 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 } let candidates = intelligenceCatalog.routes.filter { $0.model == model }
if let exact = candidates.first(where: { $0.effort == effort }) { return exact.level } if let exact = candidates.first(where: { $0.effort == effort }) { return exact.level }
return candidates.first?.level ?? defaultIntelligenceLevel 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 /// 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). /// 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 /// Deliberately a *plausible* slice of the real matrix rather than a uniform one: a flat table
@@ -53,9 +53,6 @@ struct StartChatComposer: View {
/// The stop under the finger mid-drag, so the route line follows it before the binding /// The stop under the finger mid-drag, so the route line follows it before the binding
/// commits on release. `nil` between gestures. /// commits on release. `nil` between gestures.
@State private var intelligencePreview: Int? @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 // 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. // from the `⋯` menu rather than a disclosed row — they're set once per project, not per chat.
@State private var showOptions = false @State private var showOptions = false
@@ -114,17 +111,25 @@ struct StartChatComposer: View {
// Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder. // Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder.
.onAppear(perform: syncToProject) .onAppear(perform: syncToProject)
.onChange(of: projectID) { _, _ in 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 // 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. // in step here so the start path below needs no special case.
.onChange(of: orchestra) { _, on in .onChange(of: orchestra) { _, on in
effort = on effort = on
? store.modelCatalog.orchestraSentinelOrFallback ? store.modelCatalog.orchestraSentinelOrFallback
: (store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback) : (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 // One remembered stop for the composer, not one per project — see `newChatIntelligenceLevel`.
// back to the same repo shouldn't silently reset a level the user chose deliberately.
.onChange(of: intelligence) { _, level in .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. // Switching to a model with a lower effort cap can't leave an unsupported level selected.
.onChange(of: model) { _, newModel in .onChange(of: model) { _, newModel in
@@ -260,9 +265,12 @@ struct StartChatComposer: View {
// MARK: - Row 3: how much intelligence // 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 /// The rail and its (static) route line — or, on a host that doesn't route, the model and
/// it replaces. Placed last so it sits closest to the thumb: it's the control most likely to /// effort menus it replaces. Placed last so it sits closest to the thumb: it's the control
/// be adjusted in the moment before sending. /// 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 @ViewBuilder
private var intelligenceRow: some View { private var intelligenceRow: some View {
if store.routesIntelligence { if store.routesIntelligence {
@@ -271,41 +279,26 @@ struct StartChatComposer: View {
level: $intelligence, level: $intelligence,
orchestra: $orchestra, orchestra: $orchestra,
catalog: store.intelligenceCatalog, catalog: store.intelligenceCatalog,
enabled: store.canControl && pinnedModel == nil, enabled: store.canControl,
orchestraAvailable: controlled, // 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, composerText: draft,
routeDescription: store.intelligenceRouteDescription( routeDescription: store.intelligenceRouteDescription(
for: draft, level: previewLevel), for: draft, level: previewLevel),
unavailableReason: railUnavailableReason, unavailableReason: store.canControl ? nil : "This device is view-only.",
onPreviewChanged: { intelligencePreview = $0 }) onPreviewChanged: { intelligencePreview = $0 })
IntelligenceRouteLine( IntelligenceRouteLine(
level: previewLevel, level: previewLevel,
orchestra: orchestra, orchestra: orchestra,
catalog: store.intelligenceCatalog, catalog: store.intelligenceCatalog,
route: previewRoute, model: previewRoute?.model,
orchestraAvailable: controlled, effort: previewRoute?.effort,
enabled: store.canControl, unavailableReason: previewRoute?.isAvailable == false
onSelect: { level, wantsOrchestra in ? "at its usage limit" : nil,
pinnedModel = nil modelCatalog: store.modelCatalog)
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
})
} }
} else { } else {
// No routing on this host (older Mac, or the Settings toggle off) — the pair of menus // 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) store.intelligenceRoute(for: draft, level: previewLevel)
} }
private var orchestraActive: Bool { controlled && (orchestra || MobileEfforts.isOrchestra(effort)) } private var orchestraActive: Bool { 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 canStart: Bool { private var canStart: Bool {
selected != nil && store.canControl 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: /// 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 /// 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 /// never pins a worse model than the Mac would have (see `RemoteIntelligence`). A host that
/// — or a host that doesn't route — sends the concrete values instead. /// doesn't route sends the concrete values from its manual menus instead.
private func start() { private func start() {
guard let project = selected else { return } guard let project = selected else { return }
let routed = store.routesIntelligence && pinnedModel == nil && !orchestra let routed = store.routesIntelligence && !orchestra
let sentModel = routed ? nil : (pinnedModel ?? model) let sentModel = routed ? nil : model
let sentEffort = orchestra let sentEffort = orchestra
? store.modelCatalog.orchestraSentinelOrFallback ? store.modelCatalog.orchestraSentinelOrFallback
: (routed ? nil : (pinnedEffort ?? effort)) : (routed ? nil : effort)
let sentLevel = store.routesIntelligence ? intelligence : nil let sentLevel = store.routesIntelligence ? intelligence : nil
if runOnMesh && meshAvailable { if runOnMesh && meshAvailable {
@@ -378,17 +360,15 @@ struct StartChatComposer: View {
onStart?() onStart?()
} }
/// Sync the composer to the freshly-selected project: show its default branch as the base, /// Sync the composer to the freshly-selected project.
/// 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 /// Only the base branch is project-derived. The rail is deliberately *not* touched here: how
/// can't run there — mirrors the Mac's `effectiveEffort`). /// 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() { private func syncToProject() {
baseBranch = selected?.defaultBranch ?? "" 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
}
} }
} }
@@ -1,47 +1,36 @@
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
/// The line beneath the Intelligence rail: what the current stop actually resolves to, and the /// The line beneath the Intelligence rail: the stop's name and what it resolves to.
/// rail's non-gestural twin.
/// ///
/// On the Mac the same information sits in a fixed-footprint subtext under the slider, with a /// **A readout, not a control.** The Mac lays a transparent menu over the same subtext so the
/// transparent menu laid over the resolved words. The phone keeps that idea and leans on it /// resolved words double as a model/effort picker. That doesn't earn its place here: with the rail
/// harder, because it has to carry three jobs the Mac spread across hover, tooltips, and a wider /// on, the rail *is* the control, and a second way to set the same thing — one that silently
/// composer: /// 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, /// It still carries three facts the Mac spreads across hover and tooltips, because a phone has
/// so the stop's name leads this line — "Balanced · Sonnet 5 · High" — and the rail alone /// neither: the stop's **name** (the rail alone never has to be read for it), the concrete
/// never has to be read for it. /// **model · effort** it lands on, and — when quota has closed a lane — the fact that this stop
/// 2. **Be the whole control, without a gesture.** Tapping opens a menu of every stop (with the /// currently can't run.
/// 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.
/// ///
/// The footprint is fixed so a model-name change can't shift the composer's rows under a /// The footprint is fixed so a model-name change can't shift the composer's rows under a thumb
/// thumb that's mid-reach for the send button. /// that's mid-reach for the send button.
struct IntelligenceRouteLine: View { struct IntelligenceRouteLine: View {
/// The stop being described — the one under the finger during a drag, else the committed one. /// The stop being described — the one under the finger during a drag, else the committed one.
var level: Int var level: Int
var orchestra: Bool var orchestra: Bool
var catalog: WireIntelligenceCatalog var catalog: WireIntelligenceCatalog
/// The resolved route for this stop. `nil` while nothing has been classified yet (an empty /// The pair this stop lands on. For an open chat at rest that's the session's *actual* model
/// draft), in which case the line names the stop alone. /// and effort (ground truth from the host); mid-drag, and in the new-chat composer, it's the
var route: WireIntelligenceCatalog.Route? /// route the host would resolve. `nil` shows the stop alone — an empty draft has nothing to
var orchestraAvailable: Bool /// route yet, and saying nothing beats promising a model.
var enabled: Bool = true var model: String?
/// Applied when the user picks a stop from the menu rather than moving the rail. var effort: String?
var onSelect: (_ level: Int, _ orchestra: Bool) -> Void /// The router's own sentence, shown in place of the pair when this stop can't currently run.
/// Applied when they pin a concrete pair instead, leaving automatic routing for this chat. var unavailableReason: String?
/// `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.
var modelCatalog: WireModelCatalog = .empty 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 /// Tracks its own text style, so at accessibility sizes the row grows with the model name it
/// exists to show rather than clipping it. /// exists to show rather than clipping it.
@@ -54,158 +43,33 @@ struct IntelligenceRouteLine: View {
? (IntelligenceLevel(rawValue: level)?.displayName ?? "") : projected ? (IntelligenceLevel(rawValue: level)?.displayName ?? "") : projected
} }
/// "Balanced · Sonnet 5 · High", or just the stop before anything is classified. Orchestra /// "Balanced · Sonnet 5 · High", or just the stop before anything resolves. Orchestra states
/// states its mode rather than a pair — the supervisor model isn't the interesting fact there. /// its mode rather than a pair — the supervisor model isn't the interesting fact there.
private var summary: String { private var summary: String {
if orchestra { return "\(stopName) · parallel subagents" } if orchestra { return "\(stopName) · parallel subagents" }
guard let pair = manualSelection ?? route.map({ ($0.model, $0.effort) }) else { if let unavailableReason { return "\(stopName) · \(unavailableReason)" }
return stopName guard let model, let effort else { return stopName }
} return "\(stopName) · \(modelCatalog.displayName(model)) · "
let model = modelCatalog.displayName(pair.model) + modelCatalog.effortDisplayName(effort).localizedCapitalized
let effort = modelCatalog.effortDisplayName(pair.effort).localizedCapitalized
return "\(stopName) · \(model) · \(effort)"
} }
private var isUnavailable: Bool { route?.isAvailable == false }
var body: some View { var body: some View {
Menu { Text(summary)
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)
}
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(lineStyle) .foregroundStyle(lineStyle)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.frame(height: rowHeight) .frame(height: rowHeight)
.contentShape(Rectangle()) // 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
.menuStyle(.button) // friction. The rail is the thing to focus.
.buttonStyle(.plain) .accessibilityHidden(true)
.disabled(!enabled)
.accessibilityLabel("Intelligence route")
.accessibilityValue(Text(summary))
.accessibilityHint(Text("Choose a level, or pin a model and effort for this chat."))
} }
private var lineStyle: AnyShapeStyle { private var lineStyle: AnyShapeStyle {
if isUnavailable { return AnyShapeStyle(.orange) } if unavailableReason != nil { return AnyShapeStyle(.orange) }
if orchestra { return AnyShapeStyle(Palette.orchestra) } if orchestra { return AnyShapeStyle(Palette.orchestra) }
return AnyShapeStyle(.secondary) 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)
}
} }
@@ -406,7 +406,8 @@ struct SessionDetailView: View {
set: { on in if on { store.setSessionOrchestra(sessionID) } }), set: { on in if on { store.setSessionOrchestra(sessionID) } }),
catalog: store.intelligenceCatalog, catalog: store.intelligenceCatalog,
enabled: store.connectivity.isLive && !summary.archived, 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, composerText: draft,
routeDescription: sessionRouteDescription, routeDescription: sessionRouteDescription,
unavailableReason: summary.archived unavailableReason: summary.archived
@@ -417,32 +418,28 @@ struct SessionDetailView: View {
level: level, level: level,
orchestra: sessionOrchestra, orchestra: sessionOrchestra,
catalog: store.intelligenceCatalog, catalog: store.intelligenceCatalog,
route: store.intelligenceRoute(for: draft, level: level), model: sessionPreviewPair(summary, level: level)?.model,
orchestraAvailable: controlled, effort: sessionPreviewPair(summary, level: level)?.effort,
enabled: store.connectivity.isLive && !summary.archived, modelCatalog: store.modelCatalog)
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))
} }
// The host's answer is authoritative: once the summary reflects a model/effort, drop // The host's answer is authoritative: drop the optimistic stop as soon as one lands,
// the optimistic stop so the rail reads back what actually happened — including when // so the rail reads back what actually happened.
// the host *refused* the move (a quota-blocked stop), where the rail springs back. //
// 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.model) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.effort) { _, _ 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) + store.modelCatalog.effortDisplayName(effort)
} }
/// The model catalog narrowed to this chat's backend, so the route line's manual picker can't /// The pair the route line names for an open chat.
/// offer a provider the session can never switch to. ///
private func sessionModelCatalog(_ summary: WireSessionSummary) -> WireModelCatalog { /// At rest that is the session's **actual** model and effort as the host broadcast them — a
let models = store.modelCatalog.models(for: summary.backend) /// fact, not a prediction, and the same fact the Mac's own composer shows. Re-predicting it
guard !models.isEmpty else { return store.modelCatalog } /// here was the mismatch: this device classified the follow-up *draft* while the host routes
return WireModelCatalog( /// on what the chat is *for*, so the two named different models for the same stop.
groups: [models], ///
effortDisplayNames: store.modelCatalog.effortDisplayNames, /// Only mid-drag does it predict, and then through the session's recorded purpose
orchestraSentinel: store.modelCatalog.orchestraSentinel, /// (`sessionIntelligenceRoute`) — so the number that appears under the thumb is the one the
orchestraRequiresControlNote: store.modelCatalog.orchestraRequiresControlNote, /// host will actually resolve on release.
fallbackModel: summary.model ?? models[0].sku, private func sessionPreviewPair(
fallbackEffort: store.modelCatalog.fallbackEffort) _ 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 /// "View changes" with the live +/− counts when the host projects them, so the menu hints