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

This commit is contained in:
2026-08-03 01:16:37 -07:00
parent 2605c17492
commit 82381f7e49
7 changed files with 1635 additions and 144 deletions
@@ -28,6 +28,11 @@ final class HostConnection {
private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
private(set) var grantedScope: DeviceScope = .approve
private(set) var modelCatalog: WireModelCatalog = .empty
/// This host's Intelligence ladder + currently-resolved routes (SYNC §5.2), from
/// `Welcome.intelligence` and refreshed by `HostMsg.intelligenceCatalog` as its provider/quota
/// posture moves. `.empty` until a routing host welcomes us — the composers then keep their
/// manual model/effort pickers.
private(set) var intelligenceCatalog: WireIntelligenceCatalog = .empty
private(set) var dashboard = DashboardSnapshot.empty
private(set) var meshPeers: [PeerSummary] = []
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
@@ -626,6 +631,7 @@ final class HostConnection {
capabilities = welcome.capabilities
grantedScope = welcome.grantedScope
modelCatalog = welcome.modelCatalog
intelligenceCatalog = welcome.intelligence
// Adopt the host's account-level synced settings (relay-approvals opt-in, …). A host
// that predates the field omits it, and we keep whatever we already had.
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
@@ -879,6 +885,11 @@ final class HostConnection {
case .settings(let settings):
// The account-level synced settings changed on the host — adopt the new truth.
callbacks.settingsChanged(settings)
case .intelligenceCatalog(let catalog):
// The host re-resolved its routes (a provider came or went, a quota window moved) —
// the rail's preview follows, so it never names a model this Mac can't reach.
intelligenceCatalog = catalog
callbacks.didUpdate()
case .agentLoginChallenge(let challenge):
// Remote agent sign-in: the host built the consent URL for an attempt this phone
// began — up to RemoteStore's flow (browser + redirect capture).
@@ -0,0 +1,152 @@
import Foundation
import NucleicProtocol
/// The Intelligence rail's state and preview on the phone (SYNC_PROTOCOL §5.2).
///
/// The division of labor with the Mac is deliberate and worth stating once here, because it's
/// what makes a rail on a thin remote client honest:
///
/// - **The host routes.** The matrix, the connected providers, the Settings pin, live quota and
/// provider incidents live on the Mac. A send carries the rail's *stop*, never a concrete pair
/// (`StartChatRequest.intelligence`, `ClientMsg.setSessionIntelligence`), and the host resolves
/// it with its own classifier. So the phone can never pin a worse model than the Mac would.
/// - **The phone previews.** It runs the shared `HeuristicPurposeClassifier` — the same code the
/// host runs, moved into NucleicProtocol precisely so both sides classify identically — over
/// the draft, and looks the answer up in the host's projected route table. Sub-millisecond, no
/// round trip, so the route line can follow every keystroke on a cellular link.
/// - **The preview can be wrong, and says so by being a preview.** The host's send-time
/// classification runs a fuller stack; when the two disagree, the transcript's routing note
/// records what actually ran. That's the same relationship the Mac's own pre-send preview has
/// with its send-time resolve.
extension RemoteStore {
/// Whether the context host offers the rail at all. False keeps the composers on their manual
/// model/effort pickers — an older host, a host with routing switched off in Settings, or one
/// we haven't finished handshaking with.
var routesIntelligence: Bool {
capabilities.canRouteIntelligence && intelligenceCatalog.isRoutable
}
/// The rail's opening stop for a fresh composer: the host's own default, so the phone and the
/// Mac open on the same rung.
var defaultIntelligenceLevel: Int { intelligenceCatalog.defaultLevel }
/// Classify a draft and resolve what the host would run for it at `level`. `nil` while the
/// draft is blank (nothing to classify, so nothing to promise) or the host isn't routing.
///
/// Classification is deliberately not cached against the draft: the heuristic is a keyword
/// and shape pass over the prompt's first 2000 characters and costs far less than the layout
/// pass that will render its result.
func intelligenceRoute(for draft: String, level: Int) -> WireIntelligenceCatalog.Route? {
guard routesIntelligence else { return nil }
let prompt = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else { return nil }
let verdict = HeuristicPurposeClassifier.classify(prompt)
return intelligenceCatalog.route(purpose: verdict.purpose, level: level)
}
/// The route spoken to VoiceOver as part of the rail's value ("Sonnet 5, effort High"), so
/// assistive tech learns what a stop will actually run without navigating to the route line.
func intelligenceRouteDescription(for draft: String, level: Int) -> String? {
guard let route = intelligenceRoute(for: draft, level: level) else { return nil }
return "\(modelCatalog.displayName(route.model)), "
+ "\(modelCatalog.effortNoun(forModel: route.model).lowercased()) "
+ modelCatalog.effortDisplayName(route.effort)
}
// MARK: - The new-chat rail's remembered stop
private static let newChatLevelsKey = "nucleic.newChatIntelligence"
/// 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.
///
/// 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
}
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)
}
private func newChatIntelligenceKey(_ project: ProjectID?) -> String {
project?.rawValue ?? "__none__"
}
/// Turn Orchestra on for an open chat. It rides the existing effort sentinel rather than a
/// stop of its own (the host resolves the sentinel to xhigh plus standing subagent consent),
/// so this is the ordinary effort verb — which also means an older host honors it.
func setSessionOrchestra(_ id: SessionID) {
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.
func sessionIntelligenceLevel(_ summary: WireSessionSummary) -> Int {
guard routesIntelligence else { return defaultIntelligenceLevel }
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
}
/// 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
/// would make the rail look like it does nothing as you move it.
static var demoIntelligenceCatalog: WireIntelligenceCatalog {
let ladder: [(Int, String, String)] = [
(1, "Quick", "Cheapest and fastest. Good for typos, renames, and one-line changes."),
(2, "Light", "A small step up. Scoped edits you could describe in a sentence."),
(3, "Balanced", "The everyday default — a mid-tier model at moderate effort."),
(4, "Deep", "More capable models and more thinking. Worth it for real features."),
(5, "Max", "The most capable model this task can use, at its best effort."),
]
let perLevel: [Int: (String, String)] = [
1: ("claude-sonnet-4-6", "low"),
2: ("claude-sonnet-4-6", "medium"),
3: ("gpt-5.5", "high"),
4: ("claude-opus-4-8[1m]", "high"),
5: ("claude-opus-4-8[1m]", "max"),
]
return WireIntelligenceCatalog(
levels: ladder.map {
WireIntelligenceCatalog.Level(rawValue: $0.0, displayName: $0.1, blurb: $0.2)
},
routes: PromptPurpose.allCases.flatMap { purpose in
ladder.map { level, name, _ in
let pair = perLevel[level] ?? ("claude-sonnet-4-6", "high")
return WireIntelligenceCatalog.Route(
purpose: purpose.rawValue, level: level,
model: pair.0, effort: pair.1,
reason: "\(purpose.displayName) · \(name) — demo routing",
isAvailable: true)
}
},
defaultLevel: 3,
orchestraDisplayName: "Orchestra",
orchestraBlurb:
"Max, plus managed subagents: the work fans out to parallel workers and one "
+ "supervisor pulls the results together. Nucleic Control projects only.",
orchestraAvailable: true)
}
}
@@ -345,9 +345,13 @@ final class RemoteStore: ObservableObject {
/// itself as the Covalence origin, so the landed session is Covalence-managed (its owner keeps it
/// on the optimal host between turns); the phone can't own sessions, so the origin exclusion
/// is naturally satisfied by every candidate.
/// `intelligence` rides along exactly as it does for a direct start — and matters more here:
/// the accepting host resolves the stop against *its own* providers and quota, which are the
/// ones the chat will actually run on, so shipping a stop rather than a pair is what lets a
/// dispatch land well on a host the phone knows nothing about.
func dispatchChatToMesh(
projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, auto: Bool? = nil
effort: String? = nil, auto: Bool? = nil, intelligence: Int? = nil
) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, let (descriptor, candidates) =
@@ -375,7 +379,8 @@ final class RemoteStore: ObservableObject {
let request = StartChatRequest(
projectID: targetProjectID, message: text, model: model, effort: effort,
auto: auto, requestID: requestID,
covalenceOriginDeviceID: IdentityStore.deviceID())
covalenceOriginDeviceID: IdentityStore.deviceID(),
intelligence: intelligence)
var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10))
if outcome == nil {
outcome = await sendDispatch(request, on: conn, timeout: .seconds(5))
@@ -556,6 +561,11 @@ final class RemoteStore: ObservableObject {
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
/// The context host's Intelligence ladder + resolved routes (SYNC §5.2), driving the
/// composers' rail and its route line. `.empty` until a routing host welcomes us — the
/// composers then fall back to the manual model/effort pickers, exactly as the Mac does with
/// the slider switched off.
@Published private(set) var intelligenceCatalog: WireIntelligenceCatalog = .empty
/// Home / Projects / To-Dos state — the dashboard projection.
@Published private(set) var dashboard = DashboardSnapshot.empty
@@ -1263,7 +1273,7 @@ final class RemoteStore: ObservableObject {
grantedScope = .control
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true,
canMarkSessionDone: true)
canMarkSessionDone: true, canRouteIntelligence: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
@@ -1282,6 +1292,7 @@ final class RemoteStore: ObservableObject {
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
intelligenceCatalog = Self.demoIntelligenceCatalog
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
@@ -1687,6 +1698,7 @@ final class RemoteStore: ObservableObject {
setIfChanged(\.capabilities, ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
setIfChanged(\.intelligenceCatalog, ctx?.intelligenceCatalog ?? .empty)
var targetConnectivity: Connectivity
var targetScope: DeviceScope
@@ -2668,17 +2680,22 @@ final class RemoteStore: ObservableObject {
// MARK: - Control intents (control scope; the same actions the Mac can take)
/// Start a chat on the owning Mac. `intelligence` is the rail's stop: pass it and leave
/// `model` nil to have the host classify the opening message and route it (the composer's
/// normal path); pass a concrete `model`/`effort` instead when the user pinned one. Sending
/// both leaves the explicit pair in charge — see `AppStore.remoteStartSelection`.
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, baseBranch: String? = nil,
useWorktree: Bool = true, auto: Bool? = nil,
attachments: [WireAttachment] = []) {
attachments: [WireAttachment] = [], intelligence: Int? = nil) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
// An attachment-only opening message is allowed — the host materializes the files into the
// new working tree and uses their references as the first prompt.
guard !text.isEmpty || !attachments.isEmpty else { return }
send(.startChat(StartChatRequest(
projectID: projectID, message: text, model: model, effort: effort,
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments)))
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments,
intelligence: intelligence)))
}
func captureTodo(_ text: String, projectID: ProjectID?) {
@@ -2741,6 +2758,16 @@ final class RemoteStore: ObservableObject {
// `nil` model/effort resets the session to the host/app default.
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
/// Move an open chat's Intelligence rail. The phone names the *stop*; the host re-routes
/// inside the chat's fixed backend lane, applies the resulting model + effort, and broadcasts
/// the refreshed summary. Deliberately no optimistic local write of a model: only the host
/// knows what a stop resolves to, and guessing would show a pair that then changed under the
/// user a moment later. Gated on the capability — an older host *throws* on the unknown tag,
/// which would surface as a connection error for a control they just touched.
func setSessionIntelligence(_ id: SessionID, level: Int) {
guard capabilities.canRouteIntelligence else { return }
send(.setSessionIntelligence(id, level))
}
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
@@ -2869,6 +2896,7 @@ final class RemoteStore: ObservableObject {
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
.setArchived(let id, _), .markSessionDone(let id),
.setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionIntelligence(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
.resolveProcessStall(let id, _, _),
@@ -3109,6 +3137,14 @@ final class RemoteStore: ObservableObject {
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
case .setSessionEffort(let id, let effort):
demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
case .setSessionIntelligence(let id, let level):
// No host to route, so stand in for it: resolve the stop through the demo catalog the
// same way the real host would and apply the pair, so the rail's round trip — move,
// wait, read the answer back — behaves as it does against a live Mac.
let route = intelligenceCatalog.route(purpose: .general, level: level)
demoUpdateSession(id) {
$0.demoCopy(model: .some(route?.model), effort: .some(route?.effort))
}
case .setSessionAuto(let id, let auto):
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
case .setSessionAutoShip(let id, let autoShip):
+298 -126
View File
@@ -2,8 +2,26 @@ import SwiftUI
import NucleicProtocol
/// Start a new chat, revealed as a floating bar when the "+" is tapped (see `NewChatAffordance`).
/// Styled as the same Liquid Glass composer bar the open session carries, for consistency: picks a
/// project (or is scoped to one), types a prompt, optionally toggles Auto.
/// Styled as the same Liquid Glass composer bar the open session carries, for consistency.
///
/// **Three rows, in reach order.** This composer used to stack up to six: project + Auto, a mesh
/// destination picker, model + effort + an overflow chevron, a disclosed base-branch/worktree
/// block, attachments, and the field. On a Mac that's one wide strip; on a phone it was a wall
/// that pushed the field — the only row anyone actually came for — up under the navigation bar,
/// with the keyboard eating the rest. So it now reads top to bottom as *context → what → how
/// much*:
///
/// 1. **Context** — the project, Auto, and a `⋯` holding everything rare (run destination, base
/// branch, worktree). None of those change per chat; they don't deserve permanent rows.
/// 2. **The field**, with attach and send.
/// 3. **The Intelligence rail** and its route line — closest to the thumb, because it's the one
/// control you're most likely to adjust right before sending, and the bottom of the composer
/// is the easiest place on a phone to reach.
///
/// The model and effort menus are *gone* as standing controls: the rail replaces them exactly as
/// it does on the Mac, and the route line beneath it still opens both for a deliberate pin. When
/// the host doesn't route (older Mac, or the Settings toggle off), row 3 falls back to the old
/// pair of menus so nothing becomes unreachable.
struct StartChatComposer: View {
@EnvironmentObject var store: RemoteStore
/// The prompt text, owned by `NewChatAffordance` so it can coordinate auto-focus and the
@@ -26,7 +44,20 @@ struct StartChatComposer: View {
@State private var autoOverride: Bool?
@State private var model: String?
@State private var effort = MobileEfforts.fallback
// Advanced options (the Mac new-session sheet's base branch + worktree fields).
/// The Intelligence rail's stop for the chat about to start. Seeded from the remembered
/// per-project choice (falling back to the host's default) once a project resolves.
@State private var intelligence = IntelligenceLevel.fallback.rawValue
/// Orchestra — the constellation past Max. Mirrored into `effort` as the wire sentinel on
/// change, so the existing start path carries it unchanged.
@State private var orchestra = false
/// 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
@State private var baseBranch = ""
@State private var useWorktree = true
@@ -57,87 +88,7 @@ struct StartChatComposer: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
if lockedProject == nil {
Menu {
Button("No project") { projectID = nil }
if !projects.isEmpty { Divider() }
ForEach(projects) { project in
Button(project.name) { projectID = project.id }
}
} label: {
// The model also selects the backend, so the picker offers every project.
// Defaults to "No project" until one is chosen.
Label(selected?.name ?? "No project", systemImage: "folder")
.font(.subheadline)
}
} else {
// Scoped to this project's page — show it as context, not a picker.
Label(selected?.name ?? "", systemImage: "folder")
.font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) {
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
}
.toggleStyle(.button)
.tint(Palette.accent)
.font(.caption)
// Locked on for a Nucleic Control project — those chats always run autonomously.
.disabled(controlled)
.help(controlled
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
: "Auto-approve safe actions; destructive ones still ask.")
}
// Covalence (mesh work queue): when an eligible peer holds this repo, offer "Covalence" so
// the chat routes to whichever host fits — and moves between hosts as availability
// changes — rather than always running on its own owner.
if meshAvailable {
Menu {
Picker("Run on", selection: $runOnMesh) {
Text("This Mac").tag(false)
Text("Covalence").tag(true)
}
.pickerStyle(.inline)
} label: {
Label(
runOnMesh ? "Covalence" : "This Mac",
systemImage: runOnMesh ? "antenna.radiowaves.left.and.right" : "desktopcomputer")
.font(.caption)
.foregroundStyle(Palette.accent)
}
}
HStack {
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
Button {
withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() }
} label: {
Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle")
.font(.caption)
.foregroundStyle(Palette.accent)
}
.buttonStyle(.plain)
}
if showOptions {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary)
TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.font(.caption)
}
Toggle(isOn: $useWorktree) {
Label("Run in a worktree", systemImage: "square.stack.3d.up")
.font(.caption)
}
.tint(Palette.accent)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
contextRow
// Staged attachments ride above the field, matching the in-session composer.
if !attachments.isEmpty {
StagedAttachmentBar(attachments: $attachments)
@@ -147,44 +98,8 @@ struct StartChatComposer: View {
.font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(alignment: .bottom, spacing: 8) {
// Attach photos/files to the opening message.
AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed)
// Plain, borderless field — matching the open session's chat bar rather than a
// boxed form control.
TextField("Describe a task…", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.padding(.vertical, 3)
.focused(focus)
Button {
if let project = selected {
if runOnMesh && meshAvailable {
// Mesh dispatch: route to the best eligible host (attachments can't
// ride a dispatch, same as a remote start).
store.dispatchChatToMesh(
projectID: project.id, message: draft, model: model,
effort: effort, auto: effectiveAuto)
} else {
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
store.startChat(
in: project.id, message: draft, model: model, effort: effort,
baseBranch: branch.isEmpty ? nil : branch,
useWorktree: useWorktree, auto: effectiveAuto,
attachments: attachments.wireAttachments)
}
draft = ""
attachments = []
attachmentsOverflowed = false
onStart?()
}
} label: {
Image(systemName: "arrow.up.circle.fill").font(.title)
}
// An attachment-only opening message is allowed (a project must still be chosen).
.disabled(selected == nil || (draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.canControl)
.keyboardShortcut(.return, modifiers: .command)
}
fieldRow
intelligenceRow
if !store.canControl {
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
}
@@ -194,11 +109,23 @@ struct StartChatComposer: View {
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled, cornerRadius: 26)
.orchestraGlow(active: orchestraActive, cornerRadius: 26)
// Prefill the base branch with the project's default so it actually appears (mirroring the
// Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder.
.onAppear(perform: syncToProject)
.onChange(of: projectID) { _, _ in syncToProject() }
// 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)
}
// 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.
.onChange(of: intelligence) { _, level in
store.setNewChatIntelligenceLevel(lockedProject?.id ?? projectID, level)
}
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
.onChange(of: model) { _, newModel in
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
@@ -209,11 +136,256 @@ struct StartChatComposer: View {
}
}
/// Sync the composer to the freshly-selected project: show its default branch as the base, 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`).
// MARK: - Row 1: context
/// Project, Auto, and a `⋯` for the rare per-project settings. Everything here is chosen once
/// and then left alone, which is why it's one compact strip rather than the three rows it was.
private var contextRow: some View {
HStack(spacing: 8) {
if lockedProject == nil {
Menu {
Button("No project") { projectID = nil }
if !projects.isEmpty { Divider() }
ForEach(projects) { project in
Button(project.name) { projectID = project.id }
}
} label: {
// The model also selects the backend, so the picker offers every project.
// Defaults to "No project" until one is chosen.
Label(selected?.name ?? "No project", systemImage: "folder")
.font(.subheadline)
.lineLimit(1)
}
} else {
// Scoped to this project's page — show it as context, not a picker.
Label(selected?.name ?? "", systemImage: "folder")
.font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
}
// Covalence (mesh work queue): shown inline only when the chat is actually going
// somewhere other than its owner, so the common single-Mac case says nothing at all.
if runOnMesh && meshAvailable {
Label("Covalence", systemImage: "antenna.radiowaves.left.and.right")
.font(.caption.weight(.medium))
.foregroundStyle(Palette.accent)
.lineLimit(1)
}
Spacer(minLength: 4)
Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) {
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
}
.toggleStyle(.button)
.tint(Palette.accent)
.font(.caption)
// Locked on for a Nucleic Control project — those chats always run autonomously.
.disabled(controlled)
.accessibilityHint(controlled
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
: "Auto-approve safe actions; destructive ones still ask.")
optionsMenu
}
}
/// Run destination, base branch, and worktree — the settings a chat inherits from its project
/// and almost never overrides. They were three standing rows; as a menu they cost one glyph.
private var optionsMenu: some View {
Menu {
if meshAvailable {
Picker("Run on", selection: $runOnMesh) {
Label("This Mac", systemImage: "desktopcomputer").tag(false)
Label("Covalence", systemImage: "antenna.radiowaves.left.and.right").tag(true)
}
.pickerStyle(.inline)
}
// Neither applies to a mesh dispatch: the accepting host uses its own defaults, so
// offering them here would promise a base branch the chat never gets.
if !runOnMesh {
Section {
Toggle(isOn: $useWorktree) {
Label("Run in a worktree", systemImage: "square.stack.3d.up")
}
Button {
showOptions = true
} label: {
Label(
baseBranch.isEmpty
? "Base branch…"
: "Base branch: \(baseBranch)",
systemImage: "arrow.triangle.branch")
}
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.body)
.foregroundStyle(Palette.accent)
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
.accessibilityLabel("Chat options")
// A text field can't live inside a menu, so the one option that needs typing gets a small
// alert rather than reinstating a permanent row for it.
.alert("Base branch", isPresented: $showOptions) {
TextField(selected?.defaultBranch ?? "main", text: $baseBranch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Use Default") { baseBranch = selected?.defaultBranch ?? "" }
Button("Done", role: .cancel) {}
} message: {
Text("The branch this chat's work starts from.")
}
}
// MARK: - Row 2: the field
private var fieldRow: some View {
HStack(alignment: .bottom, spacing: 8) {
// Attach photos/files to the opening message.
AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed)
// Plain, borderless field — matching the open session's chat bar rather than a
// boxed form control.
TextField("Describe a task…", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.padding(.vertical, 3)
.focused(focus)
Button(action: start) {
Image(systemName: "arrow.up.circle.fill").font(.title)
}
// An attachment-only opening message is allowed (a project must still be chosen).
.disabled(!canStart)
.keyboardShortcut(.return, modifiers: .command)
.accessibilityLabel("Start chat")
}
}
// 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.
@ViewBuilder
private var intelligenceRow: some View {
if store.routesIntelligence {
VStack(alignment: .leading, spacing: 2) {
IntelligenceRail(
level: $intelligence,
orchestra: $orchestra,
catalog: store.intelligenceCatalog,
enabled: store.canControl && pinnedModel == nil,
orchestraAvailable: controlled,
composerText: draft,
routeDescription: store.intelligenceRouteDescription(
for: draft, level: previewLevel),
unavailableReason: railUnavailableReason,
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
})
}
} else {
// No routing on this host (older Mac, or the Settings toggle off) — the pair of menus
// the rail replaces, exactly as before.
HStack {
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(
effort: $effort, catalog: store.modelCatalog, modelSKU: model,
controlled: controlled)
}
}
}
/// The stop the route line describes: under the finger during a drag, else the committed one.
private var previewLevel: Int { intelligencePreview ?? intelligence }
private var previewRoute: WireIntelligenceCatalog.Route? {
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 canStart: Bool {
selected != nil && store.canControl
&& !(draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty)
}
/// 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.
private func start() {
guard let project = selected else { return }
let routed = store.routesIntelligence && pinnedModel == nil && !orchestra
let sentModel = routed ? nil : (pinnedModel ?? model)
let sentEffort = orchestra
? store.modelCatalog.orchestraSentinelOrFallback
: (routed ? nil : (pinnedEffort ?? effort))
let sentLevel = store.routesIntelligence ? intelligence : nil
if runOnMesh && meshAvailable {
// Mesh dispatch: route to the best eligible host (attachments can't ride a dispatch,
// same as a remote start).
store.dispatchChatToMesh(
projectID: project.id, message: draft, model: sentModel,
effort: sentEffort, auto: effectiveAuto, intelligence: sentLevel)
} else {
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
store.startChat(
in: project.id, message: draft, model: sentModel, effort: sentEffort,
baseBranch: branch.isEmpty ? nil : branch,
useWorktree: useWorktree, auto: effectiveAuto,
attachments: attachments.wireAttachments, intelligence: sentLevel)
}
draft = ""
attachments = []
attachmentsOverflowed = false
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`).
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
}
@@ -0,0 +1,789 @@
import SwiftUI
import NucleicProtocol
#if canImport(UIKit)
import UIKit
#endif
/// The composer's Intelligence control on iOS: five capability stops plus the Orchestra
/// constellation just past Max, with an energized fill that brightens and quickens as the level
/// rises. The phone's counterpart of the Mac's `IntelligenceSlider` — same ladder, same geometry
/// (`IntelligenceSliderMath`, shared through NucleicProtocol), same crest into orchestra gold —
/// re-drawn for a thumb instead of a pointer.
///
/// What the touch translation actually changes, and why:
///
/// - **The nub is always drawn.** The Mac reveals it on hover and hides it at rest, because a
/// pointer announces itself before it commits. A finger has no hover state: an invisible nub is
/// an invisible control. So the nub is permanent here, and its resting appearance carries the
/// weight the Mac gave to hover.
/// - **The touch band is 44pt; the track is 10.** The visible rail stays slim (it's decoration
/// between two dense rows), but the gesture region is a full 44pt tall — the whole row, plus
/// the composer's own padding above and below it, which is dead space anyway. A 10pt track,
/// 3pt detents and a 26pt nub also run slightly heavier than the Mac's 8/2/20, because this
/// sits on translucent Liquid Glass over a moving transcript rather than on an opaque panel.
/// - **A tap is a jump.** Tapping anywhere along the band snaps to the nearest stop, so the
/// control is fully usable without a drag at all — which matters when the alternative is
/// dragging a thumb across a 10pt bar with the rest of your hand covering it.
/// - **The value floats above the finger.** During a drag a small capsule bubble names the stop
/// over the nub. On the Mac the level is legible because nothing occludes the rail; here the
/// finger is directly on top of it, so the readout has to move out from under the touch. This
/// is the system scrubber idiom (volume, playback position), and it is the single biggest
/// reason a straight port of the Mac control would feel broken on a phone.
/// - **Orchestra keeps its elastic overrun.** The resisted Max → Orchestra half-step was designed
/// to feel like stretching past the end of a rail — which is precisely the iOS rubber-band
/// idiom, so it translates better than anything else here. It gets a firmer activation
/// threshold (a thumb overshoots more than a pointer) and richer haptics: soft impacts spaced
/// along the stretch, a rigid one when it catches.
/// - **Every gesture has a non-gesture twin.** The route line below the rail is a menu carrying
/// the same stops (see `IntelligenceRouteLine`), and VoiceOver gets the native adjustable
/// action rather than the Mac's substituted continuous slider — increment/decrement matches
/// what a five-detent ladder actually is, and the rotor drives it without any dragging.
///
/// The level is never carried by hue alone: fill length, detent dots, the bubble, the route line
/// and the accessibility value all state it too.
struct IntelligenceRail: View {
/// The committed stop (`IntelligenceLevel.rawValue`). Written on release, never mid-drag.
@Binding var level: Int
/// Whether the constellation past Max is selected. Bound separately (rather than folded into
/// `level`) for the same reason as on the Mac: Orchestra is a backend *mode* carried at Max
/// capability, not a sixth rung.
@Binding var orchestra: Bool
/// The ladder and captions the host projected. An empty catalog means the host isn't routing,
/// and the composer shows its manual pickers instead of this control.
var catalog: WireIntelligenceCatalog
var enabled: Bool = true
/// Whether the constellation is offered at all — the host advertises it, and the target
/// project is under Nucleic Control.
var orchestraAvailable: Bool = false
/// The draft. Its edits pause the shimmer (a control that keeps twinkling under the sentence
/// you're typing is noise), which resumes after three quiet seconds — the Mac's rule exactly.
var composerText: String = ""
/// What this stop currently resolves to ("Sonnet 5, effort high"), folded into the
/// accessibility value so VoiceOver learns the consequence of a stop without hunting for the
/// separate route line.
var routeDescription: String? = nil
/// Why the rail can't be moved, when `enabled` is false (offline, view-only, quota hold).
var unavailableReason: String? = nil
/// Reports the stop under the finger during a drag so the route line can follow it before the
/// binding commits; `nil` once the gesture ends and the binding is authoritative again.
var onPreviewChanged: ((Int?) -> Void)? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorSchemeContrast) private var colorSchemeContrast
@Environment(\.colorScheme) private var colorScheme
/// Non-nil while a finger is down — the displayed position, magnetized toward detents. The
/// binding only commits on release.
@State private var dragFraction: Double?
@State private var dragLane: Lane?
@State private var dragStartedFromOrchestra = false
/// The stop the last haptic fired for, so a drag that wanders within one detent stays quiet.
@State private var lastHapticStop: String?
/// Spaces the stretch rumble by elastic progress rather than touch-event cadence, so holding
/// still is silent and a 120 Hz panel can't turn it into a buzz.
@State private var stretchRumbleAnchor: Double?
/// Empty composers shimmer continuously; a keystroke pauses it until three quiet seconds pass.
@State private var energyAnimating = true
/// One-runloop-deferred animation gate: without it the shimmer's first phase and the initial
/// level animation sweep up the control's first layout and visibly slide it into place as the
/// composer appears.
@State private var settled = false
/// The rail's laid-out width, tracked from its own geometry so the gesture's hit-testing and
/// the drawing can never disagree about where a detent sits — a rotation, a Dynamic Type
/// change, or the keyboard resizing the composer mid-drag all land here first.
@State private var railWidth: CGFloat = 240
private enum Lane { case standard, orchestra }
/// Track and nub scale with Dynamic Type: at accessibility sizes the surrounding rows grow,
/// and a rail pinned to 10pt beside them stops reading as the same control.
@ScaledMetric(relativeTo: .footnote) private var trackHeight: CGFloat = 10
@ScaledMetric(relativeTo: .footnote) private var nubSize: CGFloat = 26
@ScaledMetric(relativeTo: .footnote) private var rowHeight: CGFloat = 30
/// The full-height gesture region. 44pt is the HIG minimum, and the rail earns it by
/// borrowing the composer's own vertical padding rather than by growing the layout.
private static let touchHeight: CGFloat = 44
/// Orchestra activates only very near its node, protecting Max across the short gap — held
/// higher than the Mac's 0.90 because a thumb overshoots where a pointer doesn't.
private static let orchestraActivation = 0.93
/// Elastic easing over the Max → Orchestra half-step, so it never feels like a sixth detent.
private static let orchestraResistance = 2.0
private static let stretchRumbleSpacing = 0.16
/// Tap radius around the constellation, sized to the midpoint of the gap rather than to the
/// resting dot — a fat-finger tap near it should land it without stealing a pixel from Max.
private static let orchestraTapRadius: CGFloat = 22
private static let trailingInset: CGFloat = 6
private var inset: CGFloat { nubSize / 2 }
// MARK: - Displayed state
private var levels: [WireIntelligenceCatalog.Level] {
catalog.levels.sorted { $0.rawValue < $1.rawValue }
}
private var displayedOrchestra: Bool {
switch dragLane {
case .standard: false
case .orchestra: (dragFraction ?? 0) >= Self.orchestraActivation
case nil: orchestra
}
}
private var displayedLevel: Int {
if dragLane == .orchestra { return maxRawLevel }
guard let dragFraction else { return level }
return IntelligenceSliderMath.nearestLevel(fraction: dragFraction).rawValue
}
private var displayedName: String {
displayedOrchestra ? catalog.orchestraDisplayName : stopName(displayedLevel)
}
private var maxRawLevel: Int { levels.last?.rawValue ?? IntelligenceLevel.max.rawValue }
private var minRawLevel: Int { levels.first?.rawValue ?? IntelligenceLevel.quick.rawValue }
/// Normalized position of a stop, 0 (first) … 1 (last).
private func fraction(of rawLevel: Int) -> Double {
IntelligenceLevel(rawValue: rawLevel)?.fraction ?? 0
}
private var standardFraction: Double {
if dragLane == .orchestra || (dragLane == nil && orchestra) { return 1 }
return dragFraction ?? fraction(of: level)
}
/// At rest the rail's structure sits deliberately light — it is decoration between two dense
/// rows. Either contrast accommodation raises all of it to a legible weight.
private var highContrast: Bool {
reduceTransparency || colorSchemeContrast == .increased
}
private func contrasted(_ base: Double, _ raised: Double) -> Double {
highContrast ? raised : base
}
/// `Color.secondary` resolves dark in light mode, so the unfilled rail lands close in weight
/// to the fill running over it and the two read as one bar. Only light mode lightens; high
/// contrast keeps its raised value in both.
private func railOpacity(_ base: Double, _ raised: Double) -> Double {
if highContrast { return raised }
return colorScheme == .light ? base * 0.55 : base
}
// MARK: - Body
var body: some View {
GeometryReader { geo in
rail(width: geo.size.width, height: geo.size.height)
}
.frame(height: rowHeight)
.onGeometryChange(for: CGFloat.self, of: \.size.width) { railWidth = $0 }
// Grow the hit area into the composer's own padding, take the content shape there, then
// subtract the growth again — so a 30pt row carries a 44pt target without costing the
// composer a single point of height. The padding it borrows is dead space either way.
.padding(.vertical, (Self.touchHeight - rowHeight) / 2)
.contentShape(Rectangle())
.gesture(dragGesture, isEnabled: enabled)
.padding(.vertical, -(Self.touchHeight - rowHeight) / 2)
.opacity(enabled ? 1 : 0.45)
.accessibilityElement()
.accessibilityLabel("Intelligence")
.accessibilityValue(Text(accessibilityValue))
.accessibilityHint(Text(accessibilityHint))
.accessibilityIdentifier("intelligence-rail")
// The iOS-native shape for a discrete ladder: the rotor's increment/decrement moves one
// stop, so the whole control — including the Orchestra half-step — is reachable without
// dragging a thumb along a 10pt bar. (The Mac substitutes a continuous `Slider` instead,
// because that's what AppKit's assistive tech expects there.)
.accessibilityAdjustableAction { direction in
guard enabled else { return }
switch direction {
case .increment: step(1)
case .decrement: step(-1)
@unknown default: break
}
}
.onAppear { DispatchQueue.main.async { settled = true } }
.onDisappear { onPreviewChanged?(nil) }
.task(id: composerText) {
guard !composerText.isEmpty else {
energyAnimating = true
return
}
energyAnimating = false
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
energyAnimating = true
}
}
@ViewBuilder
private func rail(width: CGFloat, height: CGFloat) -> some View {
let trackY = height / 2
let maxX = maxNodeX(width: width)
let orchestraX = orchestraNodeX(width: width)
let gap = orchestraAvailable ? max(orchestraX - maxX, 0) : 0
let railLeading = inset - trackHeight / 2
let mainRailWidth = maxX + trackHeight / 2 - railLeading
let guideWidth = orchestraX + trackHeight / 2 - railLeading
let nubBaseX = inset + (maxX - inset) * standardFraction
let approach: CGFloat = dragLane == .orchestra
? CGFloat(min((dragFraction ?? 0) / Self.orchestraActivation, 1)) : 0
let stretch = gap * approach * approach
let fillProgress: CGFloat = displayedOrchestra && dragLane == nil
? 1 : approach * approach
let fillEndX = nubBaseX + gap * fillProgress
let fillWidth = fillEndX + trackHeight / 2 - railLeading
// Lift Deep without raising Max's peak — the same eased ramp the Mac's glow uses.
let deepProgress = max(0, (standardFraction - 0.5) * 2)
let deepGlow = deepProgress * (1.25 - 0.25 * deepProgress)
let animating = settled && enabled && !reduceMotion
let motion: Animation? = dragLane == nil && settled && enabled
? (reduceMotion ? .linear(duration: 0.01) : .spring(response: 0.32, dampingFraction: 0.78))
: nil
ZStack(alignment: .topLeading) {
// A faint capsule underneath hints the rail continues past Max. The ordinary rail
// layers above it and keeps its own rounded cap, so Orchestra reads as an extension
// rather than a sixth point on the ladder.
if orchestraAvailable {
Capsule()
.fill(Color.secondary.opacity(railOpacity(0.15, 0.34)))
.frame(width: guideWidth, height: trackHeight)
.position(x: railLeading + guideWidth / 2, y: trackY)
}
Capsule()
.fill(Color.secondary.opacity(railOpacity(0.18, 0.42)))
.frame(width: mainRailWidth, height: trackHeight)
.position(x: railLeading + mainRailWidth / 2, y: trackY)
ForEach(levels) { stop in
let stopFraction = fraction(of: stop.rawValue)
let dotSize: CGFloat = stop.rawValue == maxRawLevel && orchestraAvailable ? 5 : 3
Circle()
.fill(Color.secondary.opacity(
stopFraction <= standardFraction ? 0 : contrasted(0.42, 0.85)))
.frame(width: dotSize, height: dotSize)
.position(x: inset + (maxX - inset) * stopFraction, y: trackY)
.animation(motion, value: standardFraction)
}
IntelligenceEnergyFill(
fraction: standardFraction,
accent: Palette.accent,
animating: animating,
paused: !energyAnimating)
.frame(width: fillWidth, height: trackHeight)
.clipShape(Capsule())
.position(x: railLeading + fillWidth / 2, y: trackY)
.shadow(
color: Palette.orchestra.opacity(
0.10 + 0.35 * standardFraction * standardFraction),
radius: 6 * standardFraction)
.shadow(color: Palette.orchestra.opacity(deepGlow * 0.35), radius: 14 * standardFraction)
.animation(motion, value: fillWidth)
.allowsHitTesting(false)
// Always drawn — a finger has no hover, so this is the whole affordance.
Capsule()
.fill(nubStyle)
.overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1))
.shadow(color: .black.opacity(0.28), radius: 2, y: 1)
.frame(width: nubSize + stretch, height: nubSize)
// Between Max and Orchestra the left cap stays rooted at Max while the right cap
// stretches across the empty half-step toward the constellation.
.position(x: nubBaseX + stretch / 2, y: trackY)
.opacity(displayedOrchestra && dragLane == nil ? 0 : 1)
.animation(motion, value: nubBaseX)
.animation(motion, value: stretch)
.animation(motion, value: displayedOrchestra)
.allowsHitTesting(false)
if orchestraAvailable {
OrchestraNode(selected: displayedOrchestra, dimOpacity: contrasted(0.30, 0.70),
arcOpacity: contrasted(0.10, 0.45), coreOpacity: contrasted(0.52, 0.90))
.frame(width: 32, height: 32)
.position(x: orchestraX, y: trackY)
.animation(
enabled ? .spring(response: 0.3, dampingFraction: 0.7) : nil,
value: displayedOrchestra)
.allowsHitTesting(false)
}
// The scrubber bubble. Only while dragging, and only above the nub — the finger is on
// the rail, so this is the only place the current stop can actually be read.
if dragLane != nil {
ValueBubble(text: displayedName, isOrchestra: displayedOrchestra)
.position(
x: min(max(nubBaseX + stretch / 2, 34), width - 34),
y: trackY - rowHeight)
.transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .bottom)))
.allowsHitTesting(false)
}
}
.animation(.easeOut(duration: 0.12), value: dragLane == nil)
}
private var nubStyle: AnyShapeStyle {
// Accent → orchestra gold crest, as a stacked-opacity blend so it holds up across the
// dynamic light/dark palette colors without color-space math (the Mac's idiom).
let amount = min(max(standardFraction * standardFraction, 0), 1)
return AnyShapeStyle(LinearGradient(
colors: [
Palette.accent.opacity(1 - amount * 0.85),
Palette.orchestra.opacity(0.15 + amount * 0.85),
],
startPoint: .top, endPoint: .bottom))
}
private func maxNodeX(width: CGFloat) -> CGFloat {
guard orchestraAvailable else { return width - inset }
let orchestraX = orchestraNodeX(width: width)
// Four ordinary intervals plus a half-interval to Orchestra: Max occupies 8/9 of the
// usable Quick → Orchestra span, matching the Mac exactly.
return inset + (orchestraX - inset) * 8 / 9
}
private func orchestraNodeX(width: CGFloat) -> CGFloat {
width - inset - Self.trailingInset
}
// MARK: - Gesture
/// Tap and drag in one recognizer: `minimumDistance: 0` means a plain tap lands on `onChanged`
/// once and commits on release, so the control is fully usable without ever dragging.
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
// First touch of this gesture. `reversing` is carried as a local through the rest
// of the event rather than read back out of `@State` a line later: the elastic
// curve mirrors on it, and a stale read would make the very first frame of a pull
// off Orchestra use the forward easing.
let firstTouch = dragLane == nil
let reversing = firstTouch ? orchestra : dragStartedFromOrchestra
if firstTouch {
dragStartedFromOrchestra = reversing
lastHapticStop = displayedName
}
let selection = selection(
at: value.location,
// A tap is a press that hasn't moved: honor the constellation's generous tap
// radius until the finger travels, then switch to the elastic stretch.
isTap: isTap(value.translation),
reversing: reversing)
dragLane = selection.lane
dragFraction = selection.fraction
updateStretchRumble(selection)
// Derived from `selection` rather than from the state just assigned above: a
// detent haptic that fires a frame late reads as lag on the one control whose
// whole job is telling your thumb where it is.
let landing = landing(for: selection)
if landing.name != lastHapticStop {
lastHapticStop = landing.name
if landing.orchestra { Haptics.orchestraEngaged() } else { Haptics.detent() }
}
onPreviewChanged?(landing.level)
}
.onEnded { value in
let selection = selection(
at: value.location, isTap: isTap(value.translation),
reversing: dragStartedFromOrchestra)
let landing = landing(for: selection)
if landing.orchestra, !orchestra { Haptics.orchestraEngaged() }
orchestra = landing.orchestra
level = landing.level
dragFraction = nil
dragLane = nil
dragStartedFromOrchestra = false
stretchRumbleAnchor = nil
lastHapticStop = nil
onPreviewChanged?(nil)
}
}
/// A press that hasn't travelled yet. The threshold is deliberately small — it only decides
/// whether the constellation's tap radius or its elastic stretch applies.
private func isTap(_ translation: CGSize) -> Bool {
abs(translation.width) < 4 && abs(translation.height) < 4
}
/// What a hit-test resolves to, as the committed values would read. The one place the drag,
/// its haptics, its preview callback, and the release all agree.
private func landing(
for selection: (lane: Lane, fraction: Double)
) -> (level: Int, orchestra: Bool, name: String) {
if selection.lane == .orchestra {
let engaged = selection.fraction >= Self.orchestraActivation
return (
maxRawLevel, engaged,
engaged ? catalog.orchestraDisplayName : stopName(maxRawLevel))
}
let landed = IntelligenceSliderMath.nearestLevel(fraction: selection.fraction).rawValue
return (landed, false, stopName(landed))
}
/// A stop's caption, falling back to the shared ladder's own name if the host projected a
/// catalog without one (an older or trimmed catalog) — an empty bubble would be worse.
private func stopName(_ rawLevel: Int) -> String {
let projected = catalog.displayName(forLevel: rawLevel)
if !projected.isEmpty { return projected }
return IntelligenceLevel(rawValue: rawLevel)?.displayName ?? ""
}
private func selection(
at point: CGPoint, isTap: Bool, reversing: Bool
) -> (lane: Lane, fraction: Double) {
// Geometry is recomputed from the laid-out width rather than captured, so a rotation or a
// Dynamic Type change mid-gesture can't leave the hit-test on a stale track.
let width = railWidth
let maxX = maxNodeX(width: width)
if orchestraAvailable,
let stretched = orchestraSelection(
at: point, maxX: maxX, orchestraX: orchestraNodeX(width: width),
isTap: isTap, reversing: reversing)
{
return (.orchestra, stretched)
}
let raw = IntelligenceSliderMath.fraction(
atX: point.x, trackWidth: maxX + inset, inset: inset)
return (.standard, IntelligenceSliderMath.magnetized(fraction: raw))
}
private func orchestraSelection(
at point: CGPoint, maxX: CGFloat, orchestraX: CGFloat, isTap: Bool, reversing: Bool
) -> Double? {
let gap = orchestraX - maxX
let dx = point.x - maxX
// A tap anywhere inside the node's generous radius lands it outright: on a phone, asking
// for a precise elastic drag as the *only* way to reach a mode would put it out of reach
// for anyone who can't hold a steady drag.
if isTap, abs(point.x - orchestraX) <= Self.orchestraTapRadius { return 1 }
guard gap > 0, dx > 0 else { return nil }
let raw = min(max(dx / gap, 0), 1)
if reversing {
// Mirror the spring when leaving Orchestra: it starts fully extended at the node and
// gives way toward Max, rather than reusing the forward lag in reverse.
return 1 - pow(1 - Double(raw), Self.orchestraResistance)
}
return pow(Double(raw), Self.orchestraResistance)
}
/// A light pulse at even intervals along the resisted stretch, spaced by elastic progress. The
/// firmer `orchestraEngaged` still owns the detent at the end.
private func updateStretchRumble(_ selection: (lane: Lane, fraction: Double)) {
guard selection.lane == .orchestra,
selection.fraction > 0,
selection.fraction < Self.orchestraActivation
else {
stretchRumbleAnchor = nil
return
}
let progress = selection.fraction / Self.orchestraActivation
if let anchor = stretchRumbleAnchor, abs(progress - anchor) < Self.stretchRumbleSpacing {
return
}
stretchRumbleAnchor = progress
Haptics.stretch()
}
/// One detent in `delta`'s direction — the VoiceOver adjustable action and the route menu's
/// own stepping both land here, so neither can drift from the drag over the Max ⇄ Orchestra
/// half-step (which is not an ordinary detent).
private func step(_ delta: Int) {
guard let next = IntelligenceRailStep.stepped(
from: level, orchestra: orchestra, delta: delta,
orchestraAvailable: orchestraAvailable,
minLevel: minRawLevel, maxLevel: maxRawLevel)
else { return }
orchestra = next.orchestra
level = next.level
Haptics.detent()
}
// MARK: - Accessibility
private var accessibilityValue: String {
var parts = [displayedName]
if displayedOrchestra { parts.append("multi-agent orchestration") }
if !enabled {
parts.append(unavailableReason ?? "no route available")
} else if let routeDescription {
parts.append("routing to \(routeDescription)")
}
return parts.joined(separator: ", ")
}
private var accessibilityHint: String {
guard enabled else {
return unavailableReason ?? "No route is available for this level right now."
}
var hint = "Sets how much model capability this chat gets. Higher levels give more "
+ "thorough results and cost more. Swipe up or down to change it."
if orchestraAvailable {
hint += " The stop past \(catalog.displayName(forLevel: maxRawLevel)) is "
+ "\(catalog.orchestraDisplayName), which fans work out to parallel subagents."
}
return hint
}
}
/// One detent of movement along the rail as a pure function of where it currently sits — shared
/// by the rail's own adjustable action and the route line's menu, so the two can't drift over the
/// Max ⇄ Orchestra half-step, which is not an ordinary detent and is easy to get subtly wrong in
/// a second implementation. (The Mac's `IntelligenceRailStep` plays the same role there; this one
/// is bounds-driven rather than enum-driven because the phone's ladder arrives over the wire.)
enum IntelligenceRailStep {
static func stepped(
from level: Int, orchestra: Bool, delta: Int,
orchestraAvailable: Bool, minLevel: Int, maxLevel: Int
) -> (level: Int, orchestra: Bool)? {
guard delta != 0 else { return nil }
// Orchestra is the far end: the only way off it is back down to Max.
if orchestra { return delta < 0 ? (maxLevel, false) : nil }
let next = level + delta
if next >= minLevel, next <= maxLevel { return (next, false) }
// Past Max, the constellation is the next stop where it's offered at all.
if delta > 0, orchestraAvailable, level == maxLevel { return (maxLevel, true) }
return nil
}
}
// MARK: - Pieces
/// The floating capsule that names the stop under the finger. Sits above the nub because the
/// thumb is on top of the rail — without it a drag is blind, which is the single change that
/// matters most in moving this control from a pointer to a touch screen.
private struct ValueBubble: View {
var text: String
var isOrchestra: Bool
var body: some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(isOrchestra ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary))
.lineLimit(1)
.fixedSize()
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(
(isOrchestra ? Palette.orchestra : Palette.accent).opacity(0.35),
lineWidth: 1))
.shadow(color: .black.opacity(0.18), radius: 6, y: 2)
}
}
/// The constellation past Max: a right-facing half-orbit of satellites tying the workers into one
/// multi-agent system, which keeps Orchestra from reading as just another button or stop on the
/// rail. Same construction as the Mac's, scaled up for touch.
private struct OrchestraNode: View {
var selected: Bool
var dimOpacity: Double
var arcOpacity: Double
var coreOpacity: Double
/// Seven evenly-spaced positions across a right-facing half-circle.
private static let degrees: [Double] = [-90, -60, -30, 0, 30, 60, 90]
var body: some View {
let orbit: CGFloat = selected ? 12 : 8
let satellite: CGFloat = selected ? 4 : 2.5
ZStack {
HalfOrbitArc()
.stroke(
selected ? Palette.orchestra.opacity(0.34) : Color.secondary.opacity(arcOpacity),
style: StrokeStyle(lineWidth: selected ? 1.2 : 0.85, lineCap: .round))
.frame(width: orbit * 2, height: orbit * 2)
ForEach(Array(Self.degrees.enumerated()), id: \.offset) { _, degrees in
let radians = degrees * .pi / 180
Circle()
.fill(selected
? AnyShapeStyle(Palette.orchestra)
: AnyShapeStyle(Color.secondary.opacity(dimOpacity)))
.frame(width: satellite, height: satellite)
.offset(x: cos(radians) * orbit, y: sin(radians) * orbit)
}
// Unlike the satellites, the center dot is a persistent detent: it makes Orchestra
// legible as a destination before it's ever selected.
Circle()
.fill(selected
? AnyShapeStyle(Palette.orchestra)
: AnyShapeStyle(Color.secondary.opacity(coreOpacity)))
.overlay(Circle().strokeBorder(.white.opacity(selected ? 0.75 : 0.25), lineWidth: 0.5))
.frame(width: selected ? 7 : 3, height: selected ? 7 : 3)
}
.shadow(color: selected ? Palette.orchestra.opacity(0.34) : .clear, radius: selected ? 8 : 0)
}
}
private struct HalfOrbitArc: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: min(rect.width, rect.height) / 2,
startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: false)
return path
}
}
/// The animated fill: an accent→gold base whose gold share grows with the level, plus a traveling
/// specular band whose period shortens from a lazy 3 s at Quick to a brisk 0.9 s at Max. Reduce
/// Motion and the disabled state remove the band outright; a typing pause is gentler — the band
/// first travels fully past the trailing edge, then only the base color remains.
private struct IntelligenceEnergyFill: View {
var fraction: Double
var accent: Color
var animating: Bool
var paused: Bool
/// A phase/date anchor lets the band change speed without changing position. Computing
/// `time % period` directly gives every level its own clock, so moving the rail jumps to an
/// unrelated point in the cycle.
@State private var phaseAnchor = 0.0
@State private var phaseAnchorDate = Date()
/// Flips only once the band has cleared the trailing edge, so typing never cuts a visible
/// sweep in half.
@State private var pausedAfterSweep = false
private var sweeping: Bool { animating && !pausedAfterSweep }
private var period: Double { 3.0 - 2.1 * fraction }
var body: some View {
// Slow ambient motion, not direct manipulation: 20 fps is visually continuous while
// cutting a permanently-idle composer's timeline wakeups by two thirds, which matters
// rather more on a phone battery than on a Mac.
TimelineView(.animation(minimumInterval: 1 / 20, paused: !sweeping)) { context in
Canvas { graphics, size in
let gold = Palette.orchestra
let rect = CGRect(origin: .zero, size: size)
// Base: accent shading into gold, the gold share scaling with the level, so Quick
// reads as the app accent and Max unmistakably as orchestra gold.
graphics.fill(
Path(rect),
with: .linearGradient(
Gradient(stops: [
.init(color: accent.opacity(0.55 + 0.25 * fraction), location: 0),
.init(color: gold.opacity(0.25 + 0.75 * fraction), location: 1),
]),
startPoint: .zero, endPoint: CGPoint(x: size.width, y: 0)))
// Keep the accent as the low-level base, then borrow the destination gold as the
// level rises. Concentrating this wash at the leading edge removes the
// green-to-yellow clash at Max without flattening the gradient.
let warmth = 0.18 * pow(fraction, 2.2)
if warmth > 0 {
graphics.fill(
Path(rect),
with: .linearGradient(
Gradient(stops: [
.init(color: gold.opacity(warmth), location: 0),
.init(color: gold.opacity(warmth * 0.45), location: 0.42),
.init(color: gold.opacity(0), location: 0.78),
]),
startPoint: .zero, endPoint: CGPoint(x: size.width, y: 0)))
}
guard sweeping else { return }
// Sweep from fully off the left edge to fully off the right, so the band enters
// and exits cleanly instead of popping.
let phase = phase(at: context.date)
let band = max(14, size.width * 0.25)
let center = (size.width + band * 2) * phase - band
graphics.fill(
Path(rect),
with: .linearGradient(
Gradient(stops: [
.init(color: .clear, location: 0),
.init(color: .white.opacity(0.18 + 0.30 * fraction), location: 0.5),
.init(color: .clear, location: 1),
]),
startPoint: CGPoint(x: center - band, y: 0),
endPoint: CGPoint(x: center + band, y: 0)))
}
}
.onChange(of: fraction) { oldValue, _ in
guard sweeping else { return }
// Re-anchor at the current visual position using the old rate, so the next frame
// continues from exactly here at the new rate rather than phase-jumping.
let now = Date()
phaseAnchor = (phaseAnchor + max(0, now.timeIntervalSince(phaseAnchorDate))
/ (3.0 - 2.1 * oldValue)).truncatingRemainder(dividingBy: 1)
phaseAnchorDate = now
}
.task(id: PauseKey(paused: paused, animating: animating, fraction: fraction)) {
guard paused else {
pausedAfterSweep = false
phaseAnchor = 0
phaseAnchorDate = Date()
return
}
guard animating, !pausedAfterSweep else { return }
// Hold the current phase and wait out only the remainder of this sweep. At phase 1
// the band is wholly past the trailing edge, so stopping there is visually identical
// to the sweep ending on its own.
let remaining = max((1 - phase(at: Date())) * period, 1 / 30)
try? await Task.sleep(for: .seconds(remaining))
guard !Task.isCancelled, paused, animating else { return }
phaseAnchor = 0
phaseAnchorDate = Date()
pausedAfterSweep = true
}
}
private func phase(at date: Date) -> Double {
let elapsed = max(0, date.timeIntervalSince(phaseAnchorDate))
return (phaseAnchor + elapsed / period).truncatingRemainder(dividingBy: 1)
}
private struct PauseKey: Hashable {
var paused: Bool
var animating: Bool
var fraction: Double
}
}
// MARK: - Haptics
/// The rail's touch feedback. iOS has a far richer vocabulary here than the Mac's two-pulse
/// `NSHapticFeedbackManager`, and a control whose whole point is that you can feel where you are
/// without looking should use it: the selection generator for detents (exactly what it's for),
/// a soft impact for the elastic stretch, and a rigid one for Orchestra catching — so the mode
/// *feels* categorically different from the five ordinary stops, not merely one further along.
@MainActor
enum Haptics {
#if canImport(UIKit)
// Generators are UIKit main-actor objects; kept alive between pulses so a detent doesn't pay
// engine-start latency on every crossing of a fast drag.
private static let selection = UISelectionFeedbackGenerator()
private static let soft = UIImpactFeedbackGenerator(style: .soft)
private static let rigid = UIImpactFeedbackGenerator(style: .rigid)
#endif
static func detent() {
#if canImport(UIKit)
selection.selectionChanged()
#endif
}
static func stretch() {
#if canImport(UIKit)
soft.impactOccurred(intensity: 0.55)
#endif
}
static func orchestraEngaged() {
#if canImport(UIKit)
rigid.impactOccurred()
#endif
}
}
@@ -0,0 +1,211 @@
import SwiftUI
import NucleicProtocol
/// The line beneath the Intelligence rail: what the current stop actually resolves to, and the
/// rail's non-gestural twin.
///
/// 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:
///
/// 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.
///
/// 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.
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.
@ScaledMetric(relativeTo: .caption) private var rowHeight: CGFloat = 16
private var stopName: String {
if orchestra { return catalog.orchestraDisplayName }
let projected = catalog.displayName(forLevel: level)
return projected.isEmpty
? (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.
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)"
}
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)
}
.font(.caption.weight(.medium))
.foregroundStyle(lineStyle)
.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."))
}
private var lineStyle: AnyShapeStyle {
if isUnavailable { 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)
}
}
@@ -35,6 +35,12 @@ struct SessionDetailView: View {
// already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
// of wiping the transcript the visible copy is showing.
@State private var openToken = 0
// The Intelligence rail's stop while a gesture is in flight. The committed value is read back
// from the session summary (the host owns it — it re-routes and applies the pair), so this is
// the only local level state: an optimistic stop shown until the host's answer lands, plus the
// live preview under the finger.
@State private var pendingIntelligence: Int?
@State private var intelligencePreview: Int?
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
@@ -273,21 +279,128 @@ struct SessionDetailView: View {
}
}
/// The model and effort selectors for the next turn, relocated from the composer's control row
/// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels.
/// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here.
/// The model and effort selectors for the next turn. With routing on they are no longer the
/// control — the rail below the field is — so they drop out of the ⋯ menu entirely rather than
/// sitting there as a second, contradictory way to set the same thing; the route line under
/// the rail carries the manual pick instead. Without routing they stay exactly where they were.
@ViewBuilder
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
// A session's backend is fixed at creation, so only same-backend models are offered.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
// No explicit model yet → use the session backend's default, so the menu
// shows that backend's effort range rather than collapsing to one level.
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
controlled: controlled,
// Orchestra is a one-way latch for an existing chat — the host won't turn it
// off, so disable the other levels here to match.
lockWhenOrchestra: true)
if !store.routesIntelligence {
// A session's backend is fixed at creation, so only same-backend models are offered.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
// No explicit model yet → use the session backend's default, so the menu
// shows that backend's effort range rather than collapsing to one level.
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
controlled: controlled,
// Orchestra is a one-way latch for an existing chat — the host won't turn it
// off, so disable the other levels here to match.
lockWhenOrchestra: true)
}
}
// MARK: - The in-session Intelligence rail
/// The stop this chat's rail sits at. The host owns it — moving the rail sends the stop and
/// the host re-routes within the chat's fixed backend lane, then broadcasts the resulting
/// model/effort — so the committed value is *derived* from the summary rather than held here.
/// `pendingIntelligence` covers only the round trip, and `intelligencePreview` the drag.
private var sessionIntelligence: Int {
guard let summary else { return store.defaultIntelligenceLevel }
return pendingIntelligence ?? store.sessionIntelligenceLevel(summary)
}
private var sessionOrchestra: Bool {
store.modelCatalog.isOrchestra(summary?.effort)
}
/// The rail and route line for the open chat, sitting below the field where the thumb is.
/// Only where there's something to route: an archived or view-only chat, or a host that
/// doesn't route, keeps the composer as it was.
@ViewBuilder
private func intelligenceRow(_ summary: WireSessionSummary) -> some View {
if store.routesIntelligence, store.canControl {
let level = intelligencePreview ?? sessionIntelligence
VStack(alignment: .leading, spacing: 2) {
IntelligenceRail(
level: Binding(
get: { sessionIntelligence },
set: { next in
// Show the new stop at once and let the host answer with the pair it
// resolves to; `pendingIntelligence` clears when that summary lands.
pendingIntelligence = next
store.setSessionIntelligence(sessionID, level: next)
}),
orchestra: Binding(
get: { sessionOrchestra },
// Orchestra is a one-way latch for an existing chat — the host refuses to
// turn it off (`SessionController.setEffort`), so the rail only ever sends
// the engage, and a pull back toward Max is simply not acted on rather
// than being sent and silently ignored.
set: { on in if on { store.setSessionOrchestra(sessionID) } }),
catalog: store.intelligenceCatalog,
enabled: store.connectivity.isLive && !summary.archived,
orchestraAvailable: controlled,
composerText: draft,
routeDescription: sessionRouteDescription,
unavailableReason: summary.archived
? "This chat is archived."
: (store.connectivity.isLive ? nil : "The Mac that owns this chat is offline."),
onPreviewChanged: { intelligencePreview = $0 })
IntelligenceRouteLine(
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))
}
// 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.effort) { _, _ in pendingIntelligence = nil }
}
}
/// The route description spoken to VoiceOver — the concrete pair this chat is on now, since
/// for an existing chat that's a fact rather than a prediction.
private var sessionRouteDescription: String? {
guard let summary, let model = summary.model, let effort = summary.effort else { return nil }
return "\(store.modelCatalog.displayName(model)), "
+ "\(store.modelCatalog.effortNoun(forModel: model).lowercased()) "
+ 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)
}
/// "View changes" with the live +/− counts when the host projects them, so the menu hints
@@ -557,11 +670,18 @@ struct SessionDetailView: View {
// Mac — plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
}
// How much intelligence the next turn gets, directly under the field
// and closest to the thumb — the same placement (and the same
// rail-replaces-the-menus rule) as the new-chat composer, so the two
// entry points read identically.
if let summary { intelligenceRow(summary) }
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
// Orchestra's signature gold ring, as on the Mac's composer.
.orchestraGlow(active: sessionOrchestra && controlled, cornerRadius: 26)
}
}
}