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 capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||||
private(set) var grantedScope: DeviceScope = .approve
|
private(set) var grantedScope: DeviceScope = .approve
|
||||||
private(set) var modelCatalog: WireModelCatalog = .empty
|
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 dashboard = DashboardSnapshot.empty
|
||||||
private(set) var meshPeers: [PeerSummary] = []
|
private(set) var meshPeers: [PeerSummary] = []
|
||||||
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
|
/// 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
|
capabilities = welcome.capabilities
|
||||||
grantedScope = welcome.grantedScope
|
grantedScope = welcome.grantedScope
|
||||||
modelCatalog = welcome.modelCatalog
|
modelCatalog = welcome.modelCatalog
|
||||||
|
intelligenceCatalog = welcome.intelligence
|
||||||
// Adopt the host's account-level synced settings (relay-approvals opt-in, …). A host
|
// 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.
|
// that predates the field omits it, and we keep whatever we already had.
|
||||||
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
|
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
|
||||||
@@ -879,6 +885,11 @@ final class HostConnection {
|
|||||||
case .settings(let settings):
|
case .settings(let settings):
|
||||||
// The account-level synced settings changed on the host — adopt the new truth.
|
// The account-level synced settings changed on the host — adopt the new truth.
|
||||||
callbacks.settingsChanged(settings)
|
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):
|
case .agentLoginChallenge(let challenge):
|
||||||
// Remote agent sign-in: the host built the consent URL for an attempt this phone
|
// Remote agent sign-in: the host built the consent URL for an attempt this phone
|
||||||
// began — up to RemoteStore's flow (browser + redirect capture).
|
// 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
|
/// 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
|
/// on the optimal host between turns); the phone can't own sessions, so the origin exclusion
|
||||||
/// is naturally satisfied by every candidate.
|
/// 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(
|
func dispatchChatToMesh(
|
||||||
projectID: ProjectID, message: String, model: String? = nil,
|
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)
|
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty, let (descriptor, candidates) =
|
guard !text.isEmpty, let (descriptor, candidates) =
|
||||||
@@ -375,7 +379,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
let request = StartChatRequest(
|
let request = StartChatRequest(
|
||||||
projectID: targetProjectID, message: text, model: model, effort: effort,
|
projectID: targetProjectID, message: text, model: model, effort: effort,
|
||||||
auto: auto, requestID: requestID,
|
auto: auto, requestID: requestID,
|
||||||
covalenceOriginDeviceID: IdentityStore.deviceID())
|
covalenceOriginDeviceID: IdentityStore.deviceID(),
|
||||||
|
intelligence: intelligence)
|
||||||
var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10))
|
var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10))
|
||||||
if outcome == nil {
|
if outcome == nil {
|
||||||
outcome = await sendDispatch(request, on: conn, timeout: .seconds(5))
|
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.
|
/// 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.
|
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
|
||||||
@Published private(set) var modelCatalog: WireModelCatalog = .empty
|
@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.
|
/// Home / Projects / To-Dos state — the dashboard projection.
|
||||||
@Published private(set) var dashboard = DashboardSnapshot.empty
|
@Published private(set) var dashboard = DashboardSnapshot.empty
|
||||||
@@ -1263,7 +1273,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
grantedScope = .control
|
grantedScope = .control
|
||||||
capabilities = WireCapabilities(
|
capabilities = WireCapabilities(
|
||||||
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true,
|
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true,
|
||||||
canMarkSessionDone: true)
|
canMarkSessionDone: true, canRouteIntelligence: true)
|
||||||
modelCatalog = WireModelCatalog(
|
modelCatalog = WireModelCatalog(
|
||||||
groups: [
|
groups: [
|
||||||
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
|
[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"],
|
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
|
||||||
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
|
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
|
||||||
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
|
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
|
||||||
|
intelligenceCatalog = Self.demoIntelligenceCatalog
|
||||||
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
|
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
|
||||||
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
|
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
|
||||||
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
|
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
|
||||||
@@ -1687,6 +1698,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
setIfChanged(\.capabilities, ctx?.capabilities
|
setIfChanged(\.capabilities, ctx?.capabilities
|
||||||
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
|
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
|
||||||
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
|
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
|
||||||
|
setIfChanged(\.intelligenceCatalog, ctx?.intelligenceCatalog ?? .empty)
|
||||||
|
|
||||||
var targetConnectivity: Connectivity
|
var targetConnectivity: Connectivity
|
||||||
var targetScope: DeviceScope
|
var targetScope: DeviceScope
|
||||||
@@ -2668,17 +2680,22 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Control intents (control scope; the same actions the Mac can take)
|
// 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,
|
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
|
||||||
effort: String? = nil, baseBranch: String? = nil,
|
effort: String? = nil, baseBranch: String? = nil,
|
||||||
useWorktree: Bool = true, auto: Bool? = nil,
|
useWorktree: Bool = true, auto: Bool? = nil,
|
||||||
attachments: [WireAttachment] = []) {
|
attachments: [WireAttachment] = [], intelligence: Int? = nil) {
|
||||||
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// An attachment-only opening message is allowed — the host materializes the files into the
|
// 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.
|
// new working tree and uses their references as the first prompt.
|
||||||
guard !text.isEmpty || !attachments.isEmpty else { return }
|
guard !text.isEmpty || !attachments.isEmpty else { return }
|
||||||
send(.startChat(StartChatRequest(
|
send(.startChat(StartChatRequest(
|
||||||
projectID: projectID, message: text, model: model, effort: effort,
|
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?) {
|
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.
|
// `nil` model/effort resets the session to the host/app default.
|
||||||
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
|
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
|
||||||
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
|
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 setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
|
||||||
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
|
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
|
||||||
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
|
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, _),
|
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
|
||||||
.setArchived(let id, _), .markSessionDone(let id),
|
.setArchived(let id, _), .markSessionDone(let id),
|
||||||
.setSessionModel(let id, _), .setSessionEffort(let id, _),
|
.setSessionModel(let id, _), .setSessionEffort(let id, _),
|
||||||
|
.setSessionIntelligence(let id, _),
|
||||||
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
|
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
|
||||||
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
|
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
|
||||||
.resolveProcessStall(let id, _, _),
|
.resolveProcessStall(let id, _, _),
|
||||||
@@ -3109,6 +3137,14 @@ final class RemoteStore: ObservableObject {
|
|||||||
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
|
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
|
||||||
case .setSessionEffort(let id, let effort):
|
case .setSessionEffort(let id, let effort):
|
||||||
demoUpdateSession(id) { $0.demoCopy(effort: .some(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):
|
case .setSessionAuto(let id, let auto):
|
||||||
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
|
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
|
||||||
case .setSessionAutoShip(let id, let autoShip):
|
case .setSessionAutoShip(let id, let autoShip):
|
||||||
|
|||||||
@@ -2,8 +2,26 @@ import SwiftUI
|
|||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// Start a new chat, revealed as a floating bar when the "+" is tapped (see `NewChatAffordance`).
|
/// 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
|
/// Styled as the same Liquid Glass composer bar the open session carries, for consistency.
|
||||||
/// project (or is scoped to one), types a prompt, optionally toggles Auto.
|
///
|
||||||
|
/// **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 {
|
struct StartChatComposer: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
/// The prompt text, owned by `NewChatAffordance` so it can coordinate auto-focus and the
|
/// 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 autoOverride: Bool?
|
||||||
@State private var model: String?
|
@State private var model: String?
|
||||||
@State private var effort = MobileEfforts.fallback
|
@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 showOptions = false
|
||||||
@State private var baseBranch = ""
|
@State private var baseBranch = ""
|
||||||
@State private var useWorktree = true
|
@State private var useWorktree = true
|
||||||
@@ -57,7 +88,60 @@ struct StartChatComposer: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
contextRow
|
||||||
|
// Staged attachments ride above the field, matching the in-session composer.
|
||||||
|
if !attachments.isEmpty {
|
||||||
|
StagedAttachmentBar(attachments: $attachments)
|
||||||
|
}
|
||||||
|
if attachmentsOverflowed {
|
||||||
|
Text("Some files were too large to attach.")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
fieldRow
|
||||||
|
intelligenceRow
|
||||||
|
if !store.canControl {
|
||||||
|
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The same Liquid Glass bar the open session carries, so the new-chat entry box is visually
|
||||||
|
// identical to the in-chat composer.
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.glassSurface(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)
|
||||||
|
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||||
|
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
||||||
|
effort = levels.last ?? MobileEfforts.fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if lockedProject == nil {
|
||||||
Menu {
|
Menu {
|
||||||
Button("No project") { projectID = nil }
|
Button("No project") { projectID = nil }
|
||||||
@@ -70,13 +154,22 @@ struct StartChatComposer: View {
|
|||||||
// Defaults to "No project" until one is chosen.
|
// Defaults to "No project" until one is chosen.
|
||||||
Label(selected?.name ?? "No project", systemImage: "folder")
|
Label(selected?.name ?? "No project", systemImage: "folder")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Scoped to this project's page — show it as context, not a picker.
|
// Scoped to this project's page — show it as context, not a picker.
|
||||||
Label(selected?.name ?? "", systemImage: "folder")
|
Label(selected?.name ?? "", systemImage: "folder")
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
.font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
// 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 })) {
|
Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) {
|
||||||
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
|
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
|
||||||
}
|
}
|
||||||
@@ -85,68 +178,66 @@ struct StartChatComposer: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
// Locked on for a Nucleic Control project — those chats always run autonomously.
|
// Locked on for a Nucleic Control project — those chats always run autonomously.
|
||||||
.disabled(controlled)
|
.disabled(controlled)
|
||||||
.help(controlled
|
.accessibilityHint(controlled
|
||||||
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
||||||
: "Auto-approve safe actions; destructive ones still ask.")
|
: "Auto-approve safe actions; destructive ones still ask.")
|
||||||
|
optionsMenu
|
||||||
}
|
}
|
||||||
// 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.
|
/// Run destination, base branch, and worktree — the settings a chat inherits from its project
|
||||||
if meshAvailable {
|
/// and almost never overrides. They were three standing rows; as a menu they cost one glyph.
|
||||||
|
private var optionsMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
|
if meshAvailable {
|
||||||
Picker("Run on", selection: $runOnMesh) {
|
Picker("Run on", selection: $runOnMesh) {
|
||||||
Text("This Mac").tag(false)
|
Label("This Mac", systemImage: "desktopcomputer").tag(false)
|
||||||
Text("Covalence").tag(true)
|
Label("Covalence", systemImage: "antenna.radiowaves.left.and.right").tag(true)
|
||||||
}
|
}
|
||||||
.pickerStyle(.inline)
|
.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)
|
|
||||||
}
|
}
|
||||||
|
// 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) {
|
Toggle(isOn: $useWorktree) {
|
||||||
Label("Run in a worktree", systemImage: "square.stack.3d.up")
|
Label("Run in a worktree", systemImage: "square.stack.3d.up")
|
||||||
.font(.caption)
|
|
||||||
}
|
}
|
||||||
.tint(Palette.accent)
|
Button {
|
||||||
|
showOptions = true
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
baseBranch.isEmpty
|
||||||
|
? "Base branch…"
|
||||||
|
: "Base branch: \(baseBranch)",
|
||||||
|
systemImage: "arrow.triangle.branch")
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
}
|
||||||
// Staged attachments ride above the field, matching the in-session composer.
|
|
||||||
if !attachments.isEmpty {
|
|
||||||
StagedAttachmentBar(attachments: $attachments)
|
|
||||||
}
|
}
|
||||||
if attachmentsOverflowed {
|
} label: {
|
||||||
Text("Some files were too large to attach.")
|
Image(systemName: "ellipsis.circle")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.body)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
// Attach photos/files to the opening message.
|
// Attach photos/files to the opening message.
|
||||||
AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed)
|
AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed)
|
||||||
@@ -157,63 +248,144 @@ struct StartChatComposer: View {
|
|||||||
.lineLimit(1...5)
|
.lineLimit(1...5)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.focused(focus)
|
.focused(focus)
|
||||||
Button {
|
Button(action: start) {
|
||||||
if let project = selected {
|
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 {
|
if runOnMesh && meshAvailable {
|
||||||
// Mesh dispatch: route to the best eligible host (attachments can't
|
// Mesh dispatch: route to the best eligible host (attachments can't ride a dispatch,
|
||||||
// ride a dispatch, same as a remote start).
|
// same as a remote start).
|
||||||
store.dispatchChatToMesh(
|
store.dispatchChatToMesh(
|
||||||
projectID: project.id, message: draft, model: model,
|
projectID: project.id, message: draft, model: sentModel,
|
||||||
effort: effort, auto: effectiveAuto)
|
effort: sentEffort, auto: effectiveAuto, intelligence: sentLevel)
|
||||||
} else {
|
} else {
|
||||||
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
||||||
store.startChat(
|
store.startChat(
|
||||||
in: project.id, message: draft, model: model, effort: effort,
|
in: project.id, message: draft, model: sentModel, effort: sentEffort,
|
||||||
baseBranch: branch.isEmpty ? nil : branch,
|
baseBranch: branch.isEmpty ? nil : branch,
|
||||||
useWorktree: useWorktree, auto: effectiveAuto,
|
useWorktree: useWorktree, auto: effectiveAuto,
|
||||||
attachments: attachments.wireAttachments)
|
attachments: attachments.wireAttachments, intelligence: sentLevel)
|
||||||
}
|
}
|
||||||
draft = ""
|
draft = ""
|
||||||
attachments = []
|
attachments = []
|
||||||
attachmentsOverflowed = false
|
attachmentsOverflowed = false
|
||||||
onStart?()
|
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)
|
|
||||||
}
|
|
||||||
if !store.canControl {
|
|
||||||
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The same Liquid Glass bar the open session carries, so the new-chat entry box is visually
|
|
||||||
// identical to the in-chat composer.
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.glassSurface(cornerRadius: 26)
|
|
||||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled, 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() }
|
|
||||||
// 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)
|
|
||||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
|
||||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
|
||||||
effort = levels.last ?? MobileEfforts.fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync the composer to the freshly-selected project: show its default branch as the base, and
|
/// Sync the composer to the freshly-selected project: show its default branch as the base,
|
||||||
/// resolve a carried-over Orchestra selection down to a real level when the new project isn't
|
/// restore the rail's remembered stop for this scope, and resolve a carried-over Orchestra
|
||||||
/// under Nucleic Control (Orchestra can't run there — mirrors the Mac's `effectiveEffort`).
|
/// 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() {
|
private func syncToProject() {
|
||||||
baseBranch = selected?.defaultBranch ?? ""
|
baseBranch = selected?.defaultBranch ?? ""
|
||||||
|
intelligence = store.newChatIntelligenceLevel(lockedProject?.id ?? projectID)
|
||||||
|
if !controlled { orchestra = false }
|
||||||
if MobileEfforts.isOrchestra(effort), !controlled {
|
if MobileEfforts.isOrchestra(effort), !controlled {
|
||||||
effort = store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback
|
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
|
// already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
|
||||||
// of wiping the transcript the visible copy is showing.
|
// of wiping the transcript the visible copy is showing.
|
||||||
@State private var openToken = 0
|
@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? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
@@ -273,11 +279,13 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model and effort selectors for the next turn, relocated from the composer's control row
|
/// The model and effort selectors for the next turn. With routing on they are no longer the
|
||||||
/// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels.
|
/// control — the rail below the field is — so they drop out of the ⋯ menu entirely rather than
|
||||||
/// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here.
|
/// 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
|
@ViewBuilder
|
||||||
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
|
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
|
||||||
|
if !store.routesIntelligence {
|
||||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||||
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
||||||
@@ -289,6 +297,111 @@ struct SessionDetailView: View {
|
|||||||
// off, so disable the other levels here to match.
|
// off, so disable the other levels here to match.
|
||||||
lockWhenOrchestra: true)
|
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
|
/// "View changes" with the live +/− counts when the host projects them, so the menu hints
|
||||||
/// whether there's anything to look at before opening the sheet.
|
/// whether there's anything to look at before opening the sheet.
|
||||||
@@ -557,11 +670,18 @@ struct SessionDetailView: View {
|
|||||||
// Mac — plain Return stays newline in the multiline field.
|
// Mac — plain Return stays newline in the multiline field.
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.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(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.glassSurface(cornerRadius: 26)
|
.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