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
|
// 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)) · "
|
||||||
|
+ modelCatalog.effortDisplayName(effort).localizedCapitalized
|
||||||
}
|
}
|
||||||
let model = modelCatalog.displayName(pair.model)
|
|
||||||
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 {
|
|
||||||
menuContent
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(summary)
|
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)
|
|
||||||
}
|
}
|
||||||
},
|
// The host's answer is authoritative: drop the optimistic stop as soon as one lands,
|
||||||
// An existing chat's backend is fixed at creation, so a manual pin here goes
|
// so the rail reads back what actually happened.
|
||||||
// through the ordinary per-session model/effort verbs — which already refuse a
|
//
|
||||||
// cross-backend model — rather than the rail's routing path.
|
// Keyed on the recorded *level* as well as the pair: adjacent stops legitimately
|
||||||
onSelectRoute: { model, effort in
|
// resolve to the same model and effort (Deep and Max share a cell on several purpose
|
||||||
pendingIntelligence = nil
|
// rows), so watching the pair alone would leave the optimistic value latched on
|
||||||
store.setSessionModel(sessionID, model)
|
// exactly the moves that changed nothing visible — where it would then shadow the
|
||||||
store.setSessionEffort(sessionID, effort)
|
// next change made from another device.
|
||||||
},
|
.onChange(of: summary.routedLevel) { _, _ in pendingIntelligence = nil }
|
||||||
modelCatalog: sessionModelCatalog(summary))
|
|
||||||
}
|
|
||||||
// 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.
|
|
||||||
.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
|
||||||
|
|||||||
Reference in New Issue
Block a user