diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 053b767..4359d46 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -28,6 +28,11 @@ final class HostConnection { private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) private(set) var grantedScope: DeviceScope = .approve private(set) var modelCatalog: WireModelCatalog = .empty + /// This host's Intelligence ladder + currently-resolved routes (SYNC §5.2), from + /// `Welcome.intelligence` and refreshed by `HostMsg.intelligenceCatalog` as its provider/quota + /// posture moves. `.empty` until a routing host welcomes us — the composers then keep their + /// manual model/effort pickers. + private(set) var intelligenceCatalog: WireIntelligenceCatalog = .empty private(set) var dashboard = DashboardSnapshot.empty private(set) var meshPeers: [PeerSummary] = [] /// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello @@ -626,6 +631,7 @@ final class HostConnection { capabilities = welcome.capabilities grantedScope = welcome.grantedScope modelCatalog = welcome.modelCatalog + intelligenceCatalog = welcome.intelligence // Adopt the host's account-level synced settings (relay-approvals opt-in, …). A host // that predates the field omits it, and we keep whatever we already had. if let settings = welcome.settings { callbacks.settingsChanged(settings) } @@ -879,6 +885,11 @@ final class HostConnection { case .settings(let settings): // The account-level synced settings changed on the host — adopt the new truth. callbacks.settingsChanged(settings) + case .intelligenceCatalog(let catalog): + // The host re-resolved its routes (a provider came or went, a quota window moved) — + // the rail's preview follows, so it never names a model this Mac can't reach. + intelligenceCatalog = catalog + callbacks.didUpdate() case .agentLoginChallenge(let challenge): // Remote agent sign-in: the host built the consent URL for an attempt this phone // began — up to RemoteStore's flow (browser + redirect capture). diff --git a/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift new file mode 100644 index 0000000..6f8e8fc --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0ea4649..dcfbe70 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -345,9 +345,13 @@ final class RemoteStore: ObservableObject { /// itself as the Covalence origin, so the landed session is Covalence-managed (its owner keeps it /// on the optimal host between turns); the phone can't own sessions, so the origin exclusion /// is naturally satisfied by every candidate. + /// `intelligence` rides along exactly as it does for a direct start — and matters more here: + /// the accepting host resolves the stop against *its own* providers and quota, which are the + /// ones the chat will actually run on, so shipping a stop rather than a pair is what lets a + /// dispatch land well on a host the phone knows nothing about. func dispatchChatToMesh( projectID: ProjectID, message: String, model: String? = nil, - effort: String? = nil, auto: Bool? = nil + effort: String? = nil, auto: Bool? = nil, intelligence: Int? = nil ) { let text = message.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, let (descriptor, candidates) = @@ -375,7 +379,8 @@ final class RemoteStore: ObservableObject { let request = StartChatRequest( projectID: targetProjectID, message: text, model: model, effort: effort, auto: auto, requestID: requestID, - covalenceOriginDeviceID: IdentityStore.deviceID()) + covalenceOriginDeviceID: IdentityStore.deviceID(), + intelligence: intelligence) var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10)) if outcome == nil { outcome = await sendDispatch(request, on: conn, timeout: .seconds(5)) @@ -556,6 +561,11 @@ final class RemoteStore: ObservableObject { /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers. /// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list. @Published private(set) var modelCatalog: WireModelCatalog = .empty + /// The context host's Intelligence ladder + resolved routes (SYNC §5.2), driving the + /// composers' rail and its route line. `.empty` until a routing host welcomes us — the + /// composers then fall back to the manual model/effort pickers, exactly as the Mac does with + /// the slider switched off. + @Published private(set) var intelligenceCatalog: WireIntelligenceCatalog = .empty /// Home / Projects / To-Dos state — the dashboard projection. @Published private(set) var dashboard = DashboardSnapshot.empty @@ -1263,7 +1273,7 @@ final class RemoteStore: ObservableObject { grantedScope = .control capabilities = WireCapabilities( canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true, - canMarkSessionDone: true) + canMarkSessionDone: true, canRouteIntelligence: true) modelCatalog = WireModelCatalog( groups: [ [WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode, @@ -1282,6 +1292,7 @@ final class RemoteStore: ObservableObject { effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"], orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control", fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high") + intelligenceCatalog = Self.demoIntelligenceCatalog let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2") func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String, _ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0, @@ -1687,6 +1698,7 @@ final class RemoteStore: ObservableObject { setIfChanged(\.capabilities, ctx?.capabilities ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) + setIfChanged(\.intelligenceCatalog, ctx?.intelligenceCatalog ?? .empty) var targetConnectivity: Connectivity var targetScope: DeviceScope @@ -2668,17 +2680,22 @@ final class RemoteStore: ObservableObject { // MARK: - Control intents (control scope; the same actions the Mac can take) + /// Start a chat on the owning Mac. `intelligence` is the rail's stop: pass it and leave + /// `model` nil to have the host classify the opening message and route it (the composer's + /// normal path); pass a concrete `model`/`effort` instead when the user pinned one. Sending + /// both leaves the explicit pair in charge — see `AppStore.remoteStartSelection`. func startChat(in projectID: ProjectID, message: String, model: String? = nil, effort: String? = nil, baseBranch: String? = nil, useWorktree: Bool = true, auto: Bool? = nil, - attachments: [WireAttachment] = []) { + attachments: [WireAttachment] = [], intelligence: Int? = nil) { let text = message.trimmingCharacters(in: .whitespacesAndNewlines) // An attachment-only opening message is allowed — the host materializes the files into the // new working tree and uses their references as the first prompt. guard !text.isEmpty || !attachments.isEmpty else { return } send(.startChat(StartChatRequest( projectID: projectID, message: text, model: model, effort: effort, - baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments))) + baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments, + intelligence: intelligence))) } func captureTodo(_ text: String, projectID: ProjectID?) { @@ -2741,6 +2758,16 @@ final class RemoteStore: ObservableObject { // `nil` model/effort resets the session to the host/app default. func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) } func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) } + /// Move an open chat's Intelligence rail. The phone names the *stop*; the host re-routes + /// inside the chat's fixed backend lane, applies the resulting model + effort, and broadcasts + /// the refreshed summary. Deliberately no optimistic local write of a model: only the host + /// knows what a stop resolves to, and guessing would show a pair that then changed under the + /// user a moment later. Gated on the capability — an older host *throws* on the unknown tag, + /// which would surface as a connection error for a control they just touched. + func setSessionIntelligence(_ id: SessionID, level: Int) { + guard capabilities.canRouteIntelligence else { return } + send(.setSessionIntelligence(id, level)) + } func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) } func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) } func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) } @@ -2869,6 +2896,7 @@ final class RemoteStore: ObservableObject { .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _), .setArchived(let id, _), .markSessionDone(let id), .setSessionModel(let id, _), .setSessionEffort(let id, _), + .setSessionIntelligence(let id, _), .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _), .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id), .resolveProcessStall(let id, _, _), @@ -3109,6 +3137,14 @@ final class RemoteStore: ObservableObject { demoUpdateSession(id) { $0.demoCopy(model: .some(model)) } case .setSessionEffort(let id, let effort): demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) } + case .setSessionIntelligence(let id, let level): + // No host to route, so stand in for it: resolve the stop through the demo catalog the + // same way the real host would and apply the pair, so the rail's round trip — move, + // wait, read the answer back — behaves as it does against a live Mac. + let route = intelligenceCatalog.route(purpose: .general, level: level) + demoUpdateSession(id) { + $0.demoCopy(model: .some(route?.model), effort: .some(route?.effort)) + } case .setSessionAuto(let id, let auto): demoUpdateSession(id) { $0.demoCopy(auto: auto) } case .setSessionAutoShip(let id, let autoShip): diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index eae4189..b047c61 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -2,8 +2,26 @@ import SwiftUI import NucleicProtocol /// Start a new chat, revealed as a floating bar when the "+" is tapped (see `NewChatAffordance`). -/// Styled as the same Liquid Glass composer bar the open session carries, for consistency: picks a -/// project (or is scoped to one), types a prompt, optionally toggles Auto. +/// Styled as the same Liquid Glass composer bar the open session carries, for consistency. +/// +/// **Three rows, in reach order.** This composer used to stack up to six: project + Auto, a mesh +/// destination picker, model + effort + an overflow chevron, a disclosed base-branch/worktree +/// block, attachments, and the field. On a Mac that's one wide strip; on a phone it was a wall +/// that pushed the field — the only row anyone actually came for — up under the navigation bar, +/// with the keyboard eating the rest. So it now reads top to bottom as *context → what → how +/// much*: +/// +/// 1. **Context** — the project, Auto, and a `⋯` holding everything rare (run destination, base +/// branch, worktree). None of those change per chat; they don't deserve permanent rows. +/// 2. **The field**, with attach and send. +/// 3. **The Intelligence rail** and its route line — closest to the thumb, because it's the one +/// control you're most likely to adjust right before sending, and the bottom of the composer +/// is the easiest place on a phone to reach. +/// +/// The model and effort menus are *gone* as standing controls: the rail replaces them exactly as +/// it does on the Mac, and the route line beneath it still opens both for a deliberate pin. When +/// the host doesn't route (older Mac, or the Settings toggle off), row 3 falls back to the old +/// pair of menus so nothing becomes unreachable. struct StartChatComposer: View { @EnvironmentObject var store: RemoteStore /// The prompt text, owned by `NewChatAffordance` so it can coordinate auto-focus and the @@ -26,7 +44,20 @@ struct StartChatComposer: View { @State private var autoOverride: Bool? @State private var model: String? @State private var effort = MobileEfforts.fallback - // Advanced options (the Mac new-session sheet's base branch + worktree fields). + /// The Intelligence rail's stop for the chat about to start. Seeded from the remembered + /// per-project choice (falling back to the host's default) once a project resolves. + @State private var intelligence = IntelligenceLevel.fallback.rawValue + /// Orchestra — the constellation past Max. Mirrored into `effort` as the wire sentinel on + /// change, so the existing start path carries it unchanged. + @State private var orchestra = false + /// The stop under the finger mid-drag, so the route line follows it before the binding + /// commits on release. `nil` between gestures. + @State private var intelligencePreview: Int? + /// A concrete pair pinned from the route line, which suspends routing for this chat only. + @State private var pinnedModel: String? + @State private var pinnedEffort: String? + // Advanced options (the Mac new-session sheet's base branch + worktree fields). Now reached + // from the `⋯` menu rather than a disclosed row — they're set once per project, not per chat. @State private var showOptions = false @State private var baseBranch = "" @State private var useWorktree = true @@ -57,87 +88,7 @@ struct StartChatComposer: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack { - if lockedProject == nil { - Menu { - Button("No project") { projectID = nil } - if !projects.isEmpty { Divider() } - ForEach(projects) { project in - Button(project.name) { projectID = project.id } - } - } label: { - // The model also selects the backend, so the picker offers every project. - // Defaults to "No project" until one is chosen. - Label(selected?.name ?? "No project", systemImage: "folder") - .font(.subheadline) - } - } else { - // Scoped to this project's page — show it as context, not a picker. - Label(selected?.name ?? "", systemImage: "folder") - .font(.subheadline).foregroundStyle(.secondary) - } - Spacer() - Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) { - Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash") - } - .toggleStyle(.button) - .tint(Palette.accent) - .font(.caption) - // Locked on for a Nucleic Control project — those chats always run autonomously. - .disabled(controlled) - .help(controlled - ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." - : "Auto-approve safe actions; destructive ones still ask.") - } - // Covalence (mesh work queue): when an eligible peer holds this repo, offer "Covalence" so - // the chat routes to whichever host fits — and moves between hosts as availability - // changes — rather than always running on its own owner. - if meshAvailable { - Menu { - Picker("Run on", selection: $runOnMesh) { - Text("This Mac").tag(false) - Text("Covalence").tag(true) - } - .pickerStyle(.inline) - } label: { - Label( - runOnMesh ? "Covalence" : "This Mac", - systemImage: runOnMesh ? "antenna.radiowaves.left.and.right" : "desktopcomputer") - .font(.caption) - .foregroundStyle(Palette.accent) - } - } - HStack { - ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) - Spacer() - EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled) - Button { - withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() } - } label: { - Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle") - .font(.caption) - .foregroundStyle(Palette.accent) - } - .buttonStyle(.plain) - } - if showOptions { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary) - TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textFieldStyle(.roundedBorder) - .font(.caption) - } - Toggle(isOn: $useWorktree) { - Label("Run in a worktree", systemImage: "square.stack.3d.up") - .font(.caption) - } - .tint(Palette.accent) - } - .transition(.move(edge: .top).combined(with: .opacity)) - } + contextRow // Staged attachments ride above the field, matching the in-session composer. if !attachments.isEmpty { StagedAttachmentBar(attachments: $attachments) @@ -147,44 +98,8 @@ struct StartChatComposer: View { .font(.caption2).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } - HStack(alignment: .bottom, spacing: 8) { - // Attach photos/files to the opening message. - AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed) - // Plain, borderless field — matching the open session's chat bar rather than a - // boxed form control. - TextField("Describe a task…", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...5) - .padding(.vertical, 3) - .focused(focus) - Button { - if let project = selected { - if runOnMesh && meshAvailable { - // Mesh dispatch: route to the best eligible host (attachments can't - // ride a dispatch, same as a remote start). - store.dispatchChatToMesh( - projectID: project.id, message: draft, model: model, - effort: effort, auto: effectiveAuto) - } else { - let branch = baseBranch.trimmingCharacters(in: .whitespaces) - store.startChat( - in: project.id, message: draft, model: model, effort: effort, - baseBranch: branch.isEmpty ? nil : branch, - useWorktree: useWorktree, auto: effectiveAuto, - attachments: attachments.wireAttachments) - } - draft = "" - attachments = [] - attachmentsOverflowed = false - onStart?() - } - } label: { - Image(systemName: "arrow.up.circle.fill").font(.title) - } - // An attachment-only opening message is allowed (a project must still be chosen). - .disabled(selected == nil || (draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.canControl) - .keyboardShortcut(.return, modifiers: .command) - } + fieldRow + intelligenceRow if !store.canControl { Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) } @@ -194,11 +109,23 @@ struct StartChatComposer: View { .padding(.horizontal, 14) .padding(.vertical, 12) .glassSurface(cornerRadius: 26) - .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled, cornerRadius: 26) + .orchestraGlow(active: orchestraActive, cornerRadius: 26) // Prefill the base branch with the project's default so it actually appears (mirroring the // Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder. .onAppear(perform: syncToProject) .onChange(of: projectID) { _, _ in syncToProject() } + // Orchestra is a rail position on screen but the effort sentinel on the wire; keep the two + // in step here so the start path below needs no special case. + .onChange(of: orchestra) { _, on in + effort = on + ? store.modelCatalog.orchestraSentinelOrFallback + : (store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback) + } + // Remember the stop per project scope, exactly as the draft text is remembered: coming + // back to the same repo shouldn't silently reset a level the user chose deliberately. + .onChange(of: intelligence) { _, level in + store.setNewChatIntelligenceLevel(lockedProject?.id ?? projectID, level) + } // Switching to a model with a lower effort cap can't leave an unsupported level selected. .onChange(of: model) { _, newModel in let levels = store.modelCatalog.offeredEfforts(forModel: newModel) @@ -209,11 +136,256 @@ struct StartChatComposer: View { } } - /// Sync the composer to the freshly-selected project: show its default branch as the base, and - /// resolve a carried-over Orchestra selection down to a real level when the new project isn't - /// under Nucleic Control (Orchestra can't run there — mirrors the Mac's `effectiveEffort`). + // MARK: - Row 1: context + + /// Project, Auto, and a `⋯` for the rare per-project settings. Everything here is chosen once + /// and then left alone, which is why it's one compact strip rather than the three rows it was. + private var contextRow: some View { + HStack(spacing: 8) { + if lockedProject == nil { + Menu { + Button("No project") { projectID = nil } + if !projects.isEmpty { Divider() } + ForEach(projects) { project in + Button(project.name) { projectID = project.id } + } + } label: { + // The model also selects the backend, so the picker offers every project. + // Defaults to "No project" until one is chosen. + Label(selected?.name ?? "No project", systemImage: "folder") + .font(.subheadline) + .lineLimit(1) + } + } else { + // Scoped to this project's page — show it as context, not a picker. + Label(selected?.name ?? "", systemImage: "folder") + .font(.subheadline).foregroundStyle(.secondary).lineLimit(1) + } + // Covalence (mesh work queue): shown inline only when the chat is actually going + // somewhere other than its owner, so the common single-Mac case says nothing at all. + if runOnMesh && meshAvailable { + Label("Covalence", systemImage: "antenna.radiowaves.left.and.right") + .font(.caption.weight(.medium)) + .foregroundStyle(Palette.accent) + .lineLimit(1) + } + Spacer(minLength: 4) + Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) { + Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash") + } + .toggleStyle(.button) + .tint(Palette.accent) + .font(.caption) + // Locked on for a Nucleic Control project — those chats always run autonomously. + .disabled(controlled) + .accessibilityHint(controlled + ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." + : "Auto-approve safe actions; destructive ones still ask.") + optionsMenu + } + } + + /// Run destination, base branch, and worktree — the settings a chat inherits from its project + /// and almost never overrides. They were three standing rows; as a menu they cost one glyph. + private var optionsMenu: some View { + Menu { + if meshAvailable { + Picker("Run on", selection: $runOnMesh) { + Label("This Mac", systemImage: "desktopcomputer").tag(false) + Label("Covalence", systemImage: "antenna.radiowaves.left.and.right").tag(true) + } + .pickerStyle(.inline) + } + // Neither applies to a mesh dispatch: the accepting host uses its own defaults, so + // offering them here would promise a base branch the chat never gets. + if !runOnMesh { + Section { + Toggle(isOn: $useWorktree) { + Label("Run in a worktree", systemImage: "square.stack.3d.up") + } + Button { + showOptions = true + } label: { + Label( + baseBranch.isEmpty + ? "Base branch…" + : "Base branch: \(baseBranch)", + systemImage: "arrow.triangle.branch") + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.body) + .foregroundStyle(Palette.accent) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .accessibilityLabel("Chat options") + // A text field can't live inside a menu, so the one option that needs typing gets a small + // alert rather than reinstating a permanent row for it. + .alert("Base branch", isPresented: $showOptions) { + TextField(selected?.defaultBranch ?? "main", text: $baseBranch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Use Default") { baseBranch = selected?.defaultBranch ?? "" } + Button("Done", role: .cancel) {} + } message: { + Text("The branch this chat's work starts from.") + } + } + + // MARK: - Row 2: the field + + private var fieldRow: some View { + HStack(alignment: .bottom, spacing: 8) { + // Attach photos/files to the opening message. + AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed) + // Plain, borderless field — matching the open session's chat bar rather than a + // boxed form control. + TextField("Describe a task…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .padding(.vertical, 3) + .focused(focus) + Button(action: start) { + Image(systemName: "arrow.up.circle.fill").font(.title) + } + // An attachment-only opening message is allowed (a project must still be chosen). + .disabled(!canStart) + .keyboardShortcut(.return, modifiers: .command) + .accessibilityLabel("Start chat") + } + } + + // MARK: - Row 3: how much intelligence + + /// The rail and its route line — or, on a host that doesn't route, the model and effort menus + /// it replaces. Placed last so it sits closest to the thumb: it's the control most likely to + /// be adjusted in the moment before sending. + @ViewBuilder + private var intelligenceRow: some View { + if store.routesIntelligence { + VStack(alignment: .leading, spacing: 2) { + IntelligenceRail( + level: $intelligence, + orchestra: $orchestra, + catalog: store.intelligenceCatalog, + enabled: store.canControl && pinnedModel == nil, + orchestraAvailable: controlled, + composerText: draft, + routeDescription: store.intelligenceRouteDescription( + for: draft, level: previewLevel), + unavailableReason: railUnavailableReason, + onPreviewChanged: { intelligencePreview = $0 }) + IntelligenceRouteLine( + level: previewLevel, + orchestra: orchestra, + catalog: store.intelligenceCatalog, + route: previewRoute, + orchestraAvailable: controlled, + enabled: store.canControl, + onSelect: { level, wantsOrchestra in + pinnedModel = nil + pinnedEffort = nil + intelligence = level + orchestra = wantsOrchestra + }, + onSelectRoute: { model, effort in + // A deliberate pin suspends routing for this chat only — the rail stays on + // screen at the stop it was, dimmed, and the route line says it's pinned. + pinnedModel = model + pinnedEffort = effort + orchestra = false + }, + modelCatalog: store.modelCatalog, + manualSelection: pinnedModel.flatMap { model in + pinnedEffort.map { (model: model, effort: $0) } + }, + onResumeRouting: { + pinnedModel = nil + pinnedEffort = nil + }) + } + } else { + // No routing on this host (older Mac, or the Settings toggle off) — the pair of menus + // the rail replaces, exactly as before. + HStack { + ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) + Spacer() + EffortMenu( + effort: $effort, catalog: store.modelCatalog, modelSKU: model, + controlled: controlled) + } + } + } + + /// The stop the route line describes: under the finger during a drag, else the committed one. + private var previewLevel: Int { intelligencePreview ?? intelligence } + + private var previewRoute: WireIntelligenceCatalog.Route? { + store.intelligenceRoute(for: draft, level: previewLevel) + } + + private var orchestraActive: Bool { controlled && (orchestra || MobileEfforts.isOrchestra(effort)) } + + /// Why the rail is inert, when it is. Dimming alone says "unavailable" but not why, and a + /// phone has no tooltip to fill the gap — so this rides the accessibility value and the + /// route line both. + private var railUnavailableReason: String? { + if !store.canControl { return "This device is view-only." } + if pinnedModel != nil { + return "A model is pinned for this chat. Choose Automatic Routing to use the rail." + } + return nil + } + + private var canStart: Bool { + selected != nil && store.canControl + && !(draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) + } + + /// Start the chat. When the rail is driving, the request carries the **stop** and no model: + /// the host classifies the real prompt with its own stack and picks the pair, so the phone + /// never pins a worse model than the Mac would have (see `RemoteIntelligence`). A pinned pair + /// — or a host that doesn't route — sends the concrete values instead. + private func start() { + guard let project = selected else { return } + let routed = store.routesIntelligence && pinnedModel == nil && !orchestra + let sentModel = routed ? nil : (pinnedModel ?? model) + let sentEffort = orchestra + ? store.modelCatalog.orchestraSentinelOrFallback + : (routed ? nil : (pinnedEffort ?? effort)) + let sentLevel = store.routesIntelligence ? intelligence : nil + + if runOnMesh && meshAvailable { + // Mesh dispatch: route to the best eligible host (attachments can't ride a dispatch, + // same as a remote start). + store.dispatchChatToMesh( + projectID: project.id, message: draft, model: sentModel, + effort: sentEffort, auto: effectiveAuto, intelligence: sentLevel) + } else { + let branch = baseBranch.trimmingCharacters(in: .whitespaces) + store.startChat( + in: project.id, message: draft, model: sentModel, effort: sentEffort, + baseBranch: branch.isEmpty ? nil : branch, + useWorktree: useWorktree, auto: effectiveAuto, + attachments: attachments.wireAttachments, intelligence: sentLevel) + } + draft = "" + attachments = [] + attachmentsOverflowed = false + onStart?() + } + + /// Sync the composer to the freshly-selected project: show its default branch as the base, + /// restore the rail's remembered stop for this scope, and resolve a carried-over Orchestra + /// selection down to a real level when the new project isn't under Nucleic Control (Orchestra + /// can't run there — mirrors the Mac's `effectiveEffort`). private func syncToProject() { baseBranch = selected?.defaultBranch ?? "" + intelligence = store.newChatIntelligenceLevel(lockedProject?.id ?? projectID) + if !controlled { orchestra = false } if MobileEfforts.isOrchestra(effort), !controlled { effort = store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback } diff --git a/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift b/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift new file mode 100644 index 0000000..52df1a2 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/IntelligenceRail.swift @@ -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 + } +} diff --git a/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift new file mode 100644 index 0000000..c9dea59 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/IntelligenceRoute.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 064a1cf..9003fed 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -35,6 +35,12 @@ struct SessionDetailView: View { // already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead // of wiping the transcript the visible copy is showing. @State private var openToken = 0 + // The Intelligence rail's stop while a gesture is in flight. The committed value is read back + // from the session summary (the host owns it — it re-routes and applies the pair), so this is + // the only local level state: an optimistic stop shown until the host's answer lands, plus the + // live preview under the finger. + @State private var pendingIntelligence: Int? + @State private var intelligencePreview: Int? private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } @@ -273,21 +279,128 @@ struct SessionDetailView: View { } } - /// The model and effort selectors for the next turn, relocated from the composer's control row - /// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels. - /// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here. + /// The model and effort selectors for the next turn. With routing on they are no longer the + /// control — the rail below the field is — so they drop out of the ⋯ menu entirely rather than + /// sitting there as a second, contradictory way to set the same thing; the route line under + /// the rail carries the manual pick instead. Without routing they stay exactly where they were. @ViewBuilder private func modelEffortMenu(_ summary: WireSessionSummary) -> some View { - // A session's backend is fixed at creation, so only same-backend models are offered. - ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend) - EffortMenu(effort: effortBinding, catalog: store.modelCatalog, - // No explicit model yet → use the session backend's default, so the menu - // shows that backend's effort range rather than collapsing to one level. - modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku, - controlled: controlled, - // Orchestra is a one-way latch for an existing chat — the host won't turn it - // off, so disable the other levels here to match. - lockWhenOrchestra: true) + if !store.routesIntelligence { + // A session's backend is fixed at creation, so only same-backend models are offered. + ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend) + EffortMenu(effort: effortBinding, catalog: store.modelCatalog, + // No explicit model yet → use the session backend's default, so the menu + // shows that backend's effort range rather than collapsing to one level. + modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku, + controlled: controlled, + // Orchestra is a one-way latch for an existing chat — the host won't turn it + // off, so disable the other levels here to match. + lockWhenOrchestra: true) + } + } + + // MARK: - The in-session Intelligence rail + + /// The stop this chat's rail sits at. The host owns it — moving the rail sends the stop and + /// the host re-routes within the chat's fixed backend lane, then broadcasts the resulting + /// model/effort — so the committed value is *derived* from the summary rather than held here. + /// `pendingIntelligence` covers only the round trip, and `intelligencePreview` the drag. + private var sessionIntelligence: Int { + guard let summary else { return store.defaultIntelligenceLevel } + return pendingIntelligence ?? store.sessionIntelligenceLevel(summary) + } + + private var sessionOrchestra: Bool { + store.modelCatalog.isOrchestra(summary?.effort) + } + + /// The rail and route line for the open chat, sitting below the field where the thumb is. + /// Only where there's something to route: an archived or view-only chat, or a host that + /// doesn't route, keeps the composer as it was. + @ViewBuilder + private func intelligenceRow(_ summary: WireSessionSummary) -> some View { + if store.routesIntelligence, store.canControl { + let level = intelligencePreview ?? sessionIntelligence + VStack(alignment: .leading, spacing: 2) { + IntelligenceRail( + level: Binding( + get: { sessionIntelligence }, + set: { next in + // Show the new stop at once and let the host answer with the pair it + // resolves to; `pendingIntelligence` clears when that summary lands. + pendingIntelligence = next + store.setSessionIntelligence(sessionID, level: next) + }), + orchestra: Binding( + get: { sessionOrchestra }, + // Orchestra is a one-way latch for an existing chat — the host refuses to + // turn it off (`SessionController.setEffort`), so the rail only ever sends + // the engage, and a pull back toward Max is simply not acted on rather + // than being sent and silently ignored. + set: { on in if on { store.setSessionOrchestra(sessionID) } }), + catalog: store.intelligenceCatalog, + enabled: store.connectivity.isLive && !summary.archived, + orchestraAvailable: controlled, + composerText: draft, + routeDescription: sessionRouteDescription, + unavailableReason: summary.archived + ? "This chat is archived." + : (store.connectivity.isLive ? nil : "The Mac that owns this chat is offline."), + onPreviewChanged: { intelligencePreview = $0 }) + IntelligenceRouteLine( + level: level, + orchestra: sessionOrchestra, + catalog: store.intelligenceCatalog, + route: store.intelligenceRoute(for: draft, level: level), + orchestraAvailable: controlled, + enabled: store.connectivity.isLive && !summary.archived, + onSelect: { next, wantsOrchestra in + if wantsOrchestra { + store.setSessionOrchestra(sessionID) + } else { + pendingIntelligence = next + store.setSessionIntelligence(sessionID, level: next) + } + }, + // An existing chat's backend is fixed at creation, so a manual pin here goes + // through the ordinary per-session model/effort verbs — which already refuse a + // cross-backend model — rather than the rail's routing path. + onSelectRoute: { model, effort in + pendingIntelligence = nil + store.setSessionModel(sessionID, model) + store.setSessionEffort(sessionID, effort) + }, + modelCatalog: sessionModelCatalog(summary)) + } + // The host's answer is authoritative: once the summary reflects a model/effort, drop + // the optimistic stop so the rail reads back what actually happened — including when + // the host *refused* the move (a quota-blocked stop), where the rail springs back. + .onChange(of: summary.model) { _, _ in pendingIntelligence = nil } + .onChange(of: summary.effort) { _, _ in pendingIntelligence = nil } + } + } + + /// The route description spoken to VoiceOver — the concrete pair this chat is on now, since + /// for an existing chat that's a fact rather than a prediction. + private var sessionRouteDescription: String? { + guard let summary, let model = summary.model, let effort = summary.effort else { return nil } + return "\(store.modelCatalog.displayName(model)), " + + "\(store.modelCatalog.effortNoun(forModel: model).lowercased()) " + + store.modelCatalog.effortDisplayName(effort) + } + + /// The model catalog narrowed to this chat's backend, so the route line's manual picker can't + /// offer a provider the session can never switch to. + private func sessionModelCatalog(_ summary: WireSessionSummary) -> WireModelCatalog { + let models = store.modelCatalog.models(for: summary.backend) + guard !models.isEmpty else { return store.modelCatalog } + return WireModelCatalog( + groups: [models], + effortDisplayNames: store.modelCatalog.effortDisplayNames, + orchestraSentinel: store.modelCatalog.orchestraSentinel, + orchestraRequiresControlNote: store.modelCatalog.orchestraRequiresControlNote, + fallbackModel: summary.model ?? models[0].sku, + fallbackEffort: store.modelCatalog.fallbackEffort) } /// "View changes" with the live +/− counts when the host projects them, so the menu hints @@ -557,11 +670,18 @@ struct SessionDetailView: View { // Mac — plain Return stays newline in the multiline field. .keyboardShortcut(.return, modifiers: .command) } + // How much intelligence the next turn gets, directly under the field + // and closest to the thumb — the same placement (and the same + // rail-replaces-the-menus rule) as the new-chat composer, so the two + // entry points read identically. + if let summary { intelligenceRow(summary) } } } .padding(.horizontal, 14) .padding(.vertical, 12) .glassSurface(cornerRadius: 26) + // Orchestra's signature gold ring, as on the Mac's composer. + .orchestraGlow(active: sessionOrchestra && controlled, cornerRadius: 26) } } }