Merge nucleic/amber-breezy-lemur-uno1 into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user