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