Nucleic-Session: 3854DB9E-8136-4DF7-95E7-BBFC7E615174 Co-authored-by: Nucleic <[email protected]>
592 lines
27 KiB
Swift
592 lines
27 KiB
Swift
import SwiftUI
|
||
import NucleicProtocol
|
||
|
||
/// The user's preferred appearance (Settings → Appearance). `system` follows iOS.
|
||
/// Same storage key and semantics as the Mac's `AppAppearance`.
|
||
enum AppAppearance: String, CaseIterable, Identifiable {
|
||
case system, light, dark
|
||
|
||
var id: String { rawValue }
|
||
static let storageKey = "nucleic.appearance"
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .system: "System"
|
||
case .light: "Light"
|
||
case .dark: "Dark"
|
||
}
|
||
}
|
||
|
||
/// `nil` means follow the system setting.
|
||
var colorScheme: ColorScheme? {
|
||
switch self {
|
||
case .system: nil
|
||
case .light: .light
|
||
case .dark: .dark
|
||
}
|
||
}
|
||
}
|
||
|
||
/// User-selectable global text size (Settings → Appearance), applied app-wide via
|
||
/// `.dynamicTypeSize` so the semantic fonts the UI uses scale together. Mirrors the Mac's
|
||
/// `AppTextSize` (same storage key and steps).
|
||
enum AppTextSize: String, CaseIterable, Identifiable {
|
||
case small, medium, large, xLarge, xxLarge
|
||
|
||
var id: String { rawValue }
|
||
static let storageKey = "nucleic.textSize"
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .small: "Small"
|
||
case .medium: "Default"
|
||
case .large: "Large"
|
||
case .xLarge: "Larger"
|
||
case .xxLarge: "Largest"
|
||
}
|
||
}
|
||
|
||
/// `.large` is the system default content size, so "Default" maps to it.
|
||
var dynamicTypeSize: DynamicTypeSize {
|
||
switch self {
|
||
case .small: .small
|
||
case .medium: .large
|
||
case .large: .xLarge
|
||
case .xLarge: .xxLarge
|
||
case .xxLarge: .xxxLarge
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Color-vision mode. Remaps the categorical palette (status dots, activity grid, accent) to
|
||
/// hues that stay distinguishable under the common color-vision deficiencies — the same
|
||
/// palettes as the Mac's `ColorVisionMode`/`AppPalette`, same storage key, so both devices
|
||
/// read identically for the same user.
|
||
enum ColorVisionMode: String, CaseIterable, Identifiable {
|
||
case standard, deuteranopia, protanopia, tritanopia
|
||
|
||
var id: String { rawValue }
|
||
static let storageKey = "nucleic.colorVision"
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .standard: "Standard"
|
||
case .deuteranopia: "Deuteranopia (red–green)"
|
||
case .protanopia: "Protanopia (red–green)"
|
||
case .tritanopia: "Tritanopia (blue–yellow)"
|
||
}
|
||
}
|
||
|
||
/// The persisted mode (Settings → Appearance).
|
||
static var stored: ColorVisionMode {
|
||
ColorVisionMode(
|
||
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? "") ?? .standard
|
||
}
|
||
}
|
||
|
||
/// A resolved set of semantic colors for one `ColorVisionMode` — the mobile mirror of the
|
||
/// Mac's `AppPalette`, including its night-softening: vivid in light mode, desaturated and
|
||
/// dimmed in dark so status accents read calmly instead of glowing.
|
||
struct ResolvedPalette {
|
||
var accent: Color
|
||
var neutral: Color
|
||
var active: Color
|
||
var attention: Color
|
||
var success: Color
|
||
var paused: Color
|
||
var danger: Color
|
||
var frozen: Color
|
||
var activityBase: Color
|
||
|
||
/// The vivid, light-mode categorical colors (identical constants to the Mac's
|
||
/// `AppPalette.base`); `make` night-softens them for dark mode.
|
||
private static func base(_ mode: ColorVisionMode) -> ResolvedPalette {
|
||
switch mode {
|
||
case .standard:
|
||
ResolvedPalette(
|
||
accent: Color(red: 0.04, green: 0.52, blue: 0.50), // deep teal
|
||
neutral: .secondary,
|
||
active: Color(red: 0.20, green: 0.55, blue: 0.95), // blue
|
||
attention: Color(red: 1.00, green: 0.65, blue: 0.15), // amber
|
||
success: Color(red: 0.30, green: 0.80, blue: 0.45), // green
|
||
paused: Color(red: 0.62, green: 0.49, blue: 0.93), // purple
|
||
danger: Color(red: 0.92, green: 0.34, blue: 0.34), // red
|
||
frozen: Color(red: 0.26, green: 0.64, blue: 0.96), // cold "iced" blue
|
||
activityBase: Color(red: 0.30, green: 0.80, blue: 0.45))
|
||
case .deuteranopia, .protanopia:
|
||
// Okabe–Ito-derived: avoids relying on red↔green separation.
|
||
ResolvedPalette(
|
||
accent: Color(red: 0.00, green: 0.50, blue: 0.58), // deep teal
|
||
neutral: .secondary,
|
||
active: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
|
||
attention: Color(red: 0.90, green: 0.62, blue: 0.00), // orange
|
||
success: Color(red: 0.34, green: 0.71, blue: 0.91), // sky blue
|
||
paused: Color(red: 0.80, green: 0.47, blue: 0.65), // reddish purple
|
||
danger: Color(red: 0.84, green: 0.37, blue: 0.00), // vermillion
|
||
frozen: Color(red: 0.20, green: 0.58, blue: 0.86), // cool sky blue
|
||
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
|
||
case .tritanopia:
|
||
// Blue↔yellow weak; red↔green is intact, so lean on those plus magenta.
|
||
ResolvedPalette(
|
||
accent: Color(red: 0.46, green: 0.33, blue: 0.78), // indigo-violet
|
||
neutral: .secondary,
|
||
active: Color(red: 0.00, green: 0.62, blue: 0.45), // green-teal
|
||
attention: Color(red: 0.85, green: 0.20, blue: 0.46), // magenta
|
||
success: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
|
||
paused: Color(red: 0.67, green: 0.36, blue: 0.66), // purple
|
||
danger: Color(red: 0.79, green: 0.10, blue: 0.15), // red
|
||
frozen: Color(red: 0.30, green: 0.72, blue: 0.85), // light cyan
|
||
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
|
||
}
|
||
}
|
||
|
||
static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
|
||
var p = base(mode)
|
||
// The accent is the interactive tint (buttons, menus, toggles, links), not a calm
|
||
// status hue — night-softening the already-deep teal made it vanish on dark
|
||
// backgrounds, so dark mode brightens it instead.
|
||
p.accent = Palette.nightBright(p.accent)
|
||
p.active = Palette.nightSoft(p.active)
|
||
p.attention = Palette.nightSoft(p.attention)
|
||
p.success = Palette.nightSoft(p.success)
|
||
p.paused = Palette.nightSoft(p.paused)
|
||
p.danger = Palette.nightSoft(p.danger)
|
||
p.frozen = Palette.nightSoft(p.frozen)
|
||
p.activityBase = Palette.nightSoft(p.activityBase)
|
||
return p
|
||
}
|
||
}
|
||
|
||
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): the same
|
||
/// categorical status palette per color-vision mode, night-softened in dark mode, and the
|
||
/// same status→color mapping refined by turn disposition — so the two devices read
|
||
/// identically. The accessors resolve against the persisted `ColorVisionMode` on each read;
|
||
/// the resolved palettes themselves are built once.
|
||
enum Palette {
|
||
private static let resolved: [ColorVisionMode: ResolvedPalette] = Dictionary(
|
||
uniqueKeysWithValues: ColorVisionMode.allCases.map { ($0, ResolvedPalette.make($0)) })
|
||
|
||
static var current: ResolvedPalette { resolved[ColorVisionMode.stored] ?? resolved[.standard]! }
|
||
|
||
static var accent: Color { current.accent }
|
||
static var active: Color { current.active }
|
||
static var attention: Color { current.attention }
|
||
static var success: Color { current.success }
|
||
static var paused: Color { current.paused }
|
||
static var danger: Color { current.danger }
|
||
/// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's
|
||
/// `AppPalette.frozen`.
|
||
static var frozen: Color { current.frozen }
|
||
static let neutral = Color.secondary
|
||
/// The orchestra (orchestration mode) signature gold — the exact appearance-aware pair the
|
||
/// Mac's `AppTheme.orchestra` uses: a rich amber-gold in light (dark enough to read on
|
||
/// white), a bright warm gold in dark so it glows without burning. A brand color, not a
|
||
/// categorical status, so it doesn't remap with color-vision mode.
|
||
static let orchestra = dynamic(
|
||
light: UIColor(red: 0.80, green: 0.57, blue: 0.09, alpha: 1),
|
||
dark: UIColor(red: 1.0, green: 0.82, blue: 0.34, alpha: 1))
|
||
/// Primary prose color for chat text — the Mac's `AppTheme.primaryText`: a soft off-white
|
||
/// on the deep dark background (reads calmly at night) and a near-black in light.
|
||
static let primaryText = dynamic(
|
||
light: UIColor(red: 0.24, green: 0.24, blue: 0.27, alpha: 1),
|
||
dark: UIColor(red: 0.80, green: 0.81, blue: 0.83, alpha: 1))
|
||
|
||
/// A `Color` that resolves to `light`/`dark` with the active trait collection (driven by
|
||
/// `.preferredColorScheme`) — the UIKit twin of the Mac's `NSColor(name:)` provider.
|
||
private static func dynamic(light: UIColor, dark: UIColor) -> Color {
|
||
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light })
|
||
}
|
||
|
||
/// The inverse of `nightSoft`, for the interactive accent: light mode keeps `base`; dark
|
||
/// mode gets a brighter, lightly desaturated variant so the tint stays legible against
|
||
/// deep backgrounds and glass instead of sinking into them.
|
||
static func nightBright(_ base: Color) -> Color {
|
||
let ui = UIColor(base)
|
||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
|
||
let dark = UIColor(hue: h, saturation: s * 0.72, brightness: max(b, 0.85), alpha: a)
|
||
return dynamic(light: ui, dark: dark)
|
||
}
|
||
|
||
/// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same
|
||
/// night-softening curve as the Mac's `AppPalette.nightSoft`.
|
||
static func nightSoft(_ base: Color) -> Color {
|
||
let ui = UIColor(base)
|
||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
|
||
let dark = UIColor(hue: h, saturation: s * 0.68, brightness: min(b, 0.80) * 0.82, alpha: a)
|
||
return dynamic(light: ui, dark: dark)
|
||
}
|
||
|
||
/// Dot/accent color for a session's status, refined by the last turn's disposition — a
|
||
/// finished-the-work turn reads as "done" (success) rather than the calm "ready" accent.
|
||
static func status(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> Color {
|
||
if status == .awaitingInput, disposition == .completed { return success }
|
||
switch status {
|
||
case .idle, .provisioning: return neutral
|
||
case .running: return active
|
||
case .awaitingApproval: return attention
|
||
case .awaitingInput: return accent
|
||
case .finished: return success
|
||
case .interrupted: return paused
|
||
case .error: return danger
|
||
}
|
||
}
|
||
|
||
static func activity(level: Int) -> Color {
|
||
switch level {
|
||
case 0: Color.secondary.opacity(0.15)
|
||
case 1: current.activityBase.opacity(0.4)
|
||
case 2: current.activityBase.opacity(0.7)
|
||
default: current.activityBase
|
||
}
|
||
}
|
||
|
||
/// Activity-grid shade for a continuous brightness in `0…1` (relative to the user's own
|
||
/// activity, outliers excluded — see `ActivityScale`). `0` is an empty day's neutral gray.
|
||
/// Mirrors the Mac's `AppPalette.activity(intensity:)`.
|
||
static func activity(intensity: Double) -> Color {
|
||
intensity <= 0 ? Color.secondary.opacity(0.15) : current.activityBase.opacity(intensity)
|
||
}
|
||
}
|
||
|
||
/// Status glyph + label, matching the Mac semantics (UX_MACOS §1).
|
||
enum StatusStyle {
|
||
static func symbol(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String {
|
||
if status == .awaitingInput, disposition == .completed { return "checkmark.circle.fill" }
|
||
switch status {
|
||
case .idle: return "circle"
|
||
case .provisioning: return "circle.dotted"
|
||
case .running: return "circle.lefthalf.filled"
|
||
case .awaitingApproval: return "exclamationmark.triangle.fill"
|
||
case .awaitingInput: return "bubble.left.fill"
|
||
case .finished: return "checkmark.circle.fill"
|
||
case .interrupted: return "stop.circle"
|
||
case .error: return "xmark.octagon.fill"
|
||
}
|
||
}
|
||
|
||
static func label(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String {
|
||
if status == .awaitingInput, disposition == .completed { return "Done" }
|
||
return status.displayName
|
||
}
|
||
|
||
/// Attention-first ordering for the session list (UX_IOS §3).
|
||
static func sortRank(_ summary: WireSessionSummary) -> Int {
|
||
switch summary.status {
|
||
case .awaitingApproval: return 0
|
||
case .awaitingInput: return summary.disposition == .completed ? 4 : 1
|
||
case .running, .provisioning: return 2
|
||
case .idle: return 3
|
||
case .finished, .interrupted, .error: return 5
|
||
}
|
||
}
|
||
|
||
/// The shared session-list sort: attention-first by `sortRank`, then, within a rank, by
|
||
/// last-turn recency (`lastTurnAt`) rather than raw activity — so rows hold their place
|
||
/// instead of reshuffling every few seconds as the agent works. Mirrors the Mac sidebar.
|
||
static func attentionThenRecency(_ lhs: WireSessionSummary, _ rhs: WireSessionSummary) -> Bool {
|
||
let l = sortRank(lhs), r = sortRank(rhs)
|
||
return l == r ? lhs.lastTurnAt > rhs.lastTurnAt : l < r
|
||
}
|
||
}
|
||
|
||
extension SessionStatus {
|
||
/// "Needs you" vs background work, mirroring the Mac sidebar's disposition refinement.
|
||
func needsYou(_ disposition: TurnDisposition?) -> Bool {
|
||
switch self {
|
||
case .awaitingApproval: return true
|
||
case .awaitingInput: return disposition != .completed
|
||
default: return false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension Risk {
|
||
var isHigh: Bool { self == .destructive || self == .network || self == .hostExec }
|
||
var label: String { self == .hostExec ? "host machine" : rawValue }
|
||
var color: Color {
|
||
switch self {
|
||
case .destructive, .network, .hostExec: return Palette.danger
|
||
case .execute: return Palette.attention
|
||
case .write: return Color(red: 0.85, green: 0.75, blue: 0.2)
|
||
case .readOnly, .unknown: return .secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Adds a "Done" button to the keyboard's accessory toolbar so any focused composer can always
|
||
/// dismiss the keyboard. The multiline composers (`axis: .vertical`) turn Return into a newline,
|
||
/// so without this there's no way to release the keyboard once it opens. The modifier carries its
|
||
/// own `@FocusState` and binds it to the field, keeping callers to a single `.keyboardDismissable()`.
|
||
struct KeyboardDismissable: ViewModifier {
|
||
@FocusState private var focused: Bool
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.focused($focused)
|
||
.toolbar {
|
||
ToolbarItemGroup(placement: .keyboard) {
|
||
Spacer()
|
||
Button { focused = false } label: {
|
||
Label("Done", systemImage: "keyboard.chevron.compact.down")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A floating Liquid Glass surface for bars that hover over scrolling content (the session
|
||
/// chat bar, approval card). Real `glassEffect` on iOS 26; an ultra-thin material with a
|
||
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
|
||
struct GlassSurface: ViewModifier {
|
||
var cornerRadius: CGFloat = 24
|
||
func body(content: Content) -> some View {
|
||
if #available(iOS 26.0, *) {
|
||
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
||
} else {
|
||
content
|
||
.background(.ultraThinMaterial,
|
||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real
|
||
/// interactive `glassEffect` on iOS 26; an ultra-thin material disc with a hairline edge and a
|
||
/// soft shadow on earlier systems so it still lifts off the list.
|
||
struct GlassCircle: ViewModifier {
|
||
func body(content: Content) -> some View {
|
||
if #available(iOS 26.0, *) {
|
||
content.glassEffect(.regular.interactive(), in: .circle)
|
||
} else {
|
||
content
|
||
.background(.ultraThinMaterial, in: .circle)
|
||
.overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.2), radius: 6, y: 2)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
|
||
struct CardBackground: ViewModifier {
|
||
var padding: CGFloat = 14
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.padding(padding)
|
||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
||
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
|
||
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
|
||
modifier(GlassSurface(cornerRadius: cornerRadius))
|
||
}
|
||
/// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the
|
||
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.
|
||
func glassCircle() -> some View { modifier(GlassCircle()) }
|
||
/// Apply the teal accent app-wide.
|
||
func nucleicTint() -> some View { tint(Palette.accent) }
|
||
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
|
||
/// composer no dismiss affordance (no Return key on a vertical/multiline field, nothing to
|
||
/// tap) and the keyboard stays up. See `KeyboardDismissable`.
|
||
func keyboardDismissable() -> some View { modifier(KeyboardDismissable()) }
|
||
/// Wrap a composer field in the orchestra purple ring while `active` (mirrors the Mac's
|
||
/// animated composer glow, lighter touch).
|
||
func orchestraGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View {
|
||
modifier(OrchestraGlowModifier(active: active, cornerRadius: cornerRadius))
|
||
}
|
||
}
|
||
|
||
/// The effort levels the remote offers, mirroring the Mac's `ModelCatalog.efforts` plus
|
||
/// orchestra. `orchestraSentinel` MUST stay equal to the host's `OrchestrationMode.effortSentinel`
|
||
/// — it's sent verbatim over the sync wire and resolved on the host (to `xhigh` + standing
|
||
/// consent), so a mismatch would silently disable the mode.
|
||
enum MobileEfforts {
|
||
static let levels = ["low", "medium", "high", "xhigh", "max"]
|
||
static let orchestraSentinel = "orchestra"
|
||
static let fallback = "high"
|
||
|
||
static func isOrchestra(_ effort: String) -> Bool { effort == orchestraSentinel }
|
||
static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort }
|
||
}
|
||
|
||
extension WireModelCatalog {
|
||
/// Effort levels to offer for `sku`. When no model is selected yet, fall back to the default
|
||
/// model's range (not the lone fallback level), and to the built-in list before a `Welcome`
|
||
/// catalog has arrived (an empty catalog, e.g. demo / older host).
|
||
func offeredEfforts(forModel sku: String?) -> [String] {
|
||
guard !groups.isEmpty else { return MobileEfforts.levels }
|
||
let levels = efforts(forModel: sku ?? fallbackModel)
|
||
return levels.isEmpty ? MobileEfforts.levels : levels
|
||
}
|
||
/// The orchestra sentinel, or the built-in default when no catalog is present.
|
||
var orchestraSentinelOrFallback: String {
|
||
orchestraSentinel.isEmpty ? MobileEfforts.orchestraSentinel : orchestraSentinel
|
||
}
|
||
/// Pretty label for an effort level, tolerating the empty (pre-`Welcome`) catalog.
|
||
func offeredEffortDisplayName(_ effort: String) -> String {
|
||
groups.isEmpty ? MobileEfforts.displayName(effort) : effortDisplayName(effort)
|
||
}
|
||
}
|
||
|
||
/// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` — the
|
||
/// home composer, where the model also selects the backend — shows every provider group with
|
||
/// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that
|
||
/// provider's models. Selecting sets the SKU; the binding's `nil` means "host default".
|
||
struct ModelMenu: View {
|
||
@Binding var model: String?
|
||
let catalog: WireModelCatalog
|
||
var backend: BackendID? = nil
|
||
|
||
private var groups: [[WireModelCatalog.Model]] {
|
||
if let backend {
|
||
let models = catalog.models(for: backend)
|
||
return models.isEmpty ? [] : [models]
|
||
}
|
||
return catalog.groups
|
||
}
|
||
// `nil` model means "host default": name that model (the catalog's fallback SKU) rather than
|
||
// showing a generic "Default model". Falls back to the generic text only for the empty catalog.
|
||
private var label: String {
|
||
if let model { return catalog.displayName(model) }
|
||
let defaultName = catalog.displayName(catalog.fallbackModel)
|
||
return defaultName.isEmpty ? "Default model" : defaultName
|
||
}
|
||
|
||
var body: some View {
|
||
Menu {
|
||
ForEach(Array(groups.enumerated()), id: \.offset) { index, group in
|
||
if index > 0 { Divider() }
|
||
ForEach(group) { item in
|
||
Button { model = item.sku } label: { menuLabel(item) }
|
||
}
|
||
}
|
||
} label: {
|
||
// Accent icon, primary-ink text: an all-accent caption washes out on the Liquid
|
||
// Glass chat bar, so only the glyph carries the tint.
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "cpu").foregroundStyle(Palette.accent)
|
||
Text(label)
|
||
if let badge = model.flatMap({ catalog.contextBadge($0) }) {
|
||
Text(badge).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
}
|
||
.disabled(groups.isEmpty)
|
||
}
|
||
|
||
@ViewBuilder private func menuLabel(_ item: WireModelCatalog.Model) -> some View {
|
||
let name = item.contextBadge.map { "\(item.displayName) · \($0)" } ?? item.displayName
|
||
if item.sku == model { Label(name, systemImage: "checkmark") } else { Text(name) }
|
||
}
|
||
}
|
||
|
||
/// A compact effort selector for the remote composers, driven by the wire catalog so it offers
|
||
/// exactly the levels the chosen model supports (Codex caps at xhigh, Grok exposes only Auto).
|
||
/// Orchestra sits below the API levels with a sparkles glyph and gold accent; it's a Nucleic
|
||
/// Control capability, so it's only selectable when the target project is under Control.
|
||
struct EffortMenu: View {
|
||
@Binding var effort: String
|
||
let catalog: WireModelCatalog
|
||
/// The model whose effort caps apply (nil → fall back to the built-in level list).
|
||
var modelSKU: String? = nil
|
||
/// Whether the target project is under Nucleic Control (gates Orchestra).
|
||
var controlled: Bool = false
|
||
/// Latch Orchestra on once it's active: for an existing chat the host refuses to turn Orchestra
|
||
/// off (`SessionController.setEffort`), so the other levels are disabled to match. Left off for
|
||
/// new-chat composition, where the choice isn't committed yet and can still be changed.
|
||
var lockWhenOrchestra: Bool = false
|
||
|
||
private var levels: [String] { catalog.offeredEfforts(forModel: modelSKU) }
|
||
private var sentinel: String { catalog.orchestraSentinelOrFallback }
|
||
private func isOrchestra(_ e: String) -> Bool { e.caseInsensitiveCompare(sentinel) == .orderedSame }
|
||
private func display(_ e: String) -> String {
|
||
isOrchestra(e) ? "Orchestra" : catalog.offeredEffortDisplayName(e)
|
||
}
|
||
|
||
/// Orchestra only counts as active where it's permitted; a stray selection on a non-Control
|
||
/// project (or a level the current model doesn't support) reads as the nearest supported level
|
||
/// so the trigger never shows something that can't run.
|
||
private var orchestraActive: Bool { controlled && isOrchestra(effort) }
|
||
private var triggerEffort: String {
|
||
if orchestraActive { return effort }
|
||
if isOrchestra(effort) { return levels.last ?? MobileEfforts.fallback }
|
||
return levels.contains(effort) ? effort : (levels.last ?? MobileEfforts.fallback)
|
||
}
|
||
|
||
/// Once Orchestra is latched on for an existing chat, the ordinary levels can't be chosen.
|
||
private var locked: Bool { lockWhenOrchestra && orchestraActive }
|
||
|
||
var body: some View {
|
||
Menu {
|
||
ForEach(levels, id: \.self) { level in
|
||
Button { effort = level } label: {
|
||
if effort == level { Label(display(level), systemImage: "checkmark") }
|
||
else { Text(display(level)) }
|
||
}
|
||
.disabled(locked)
|
||
}
|
||
Divider()
|
||
Button { effort = sentinel } label: {
|
||
Label(
|
||
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
|
||
systemImage: orchestraActive ? "checkmark" : "sparkles")
|
||
}
|
||
.disabled(!controlled)
|
||
} label: {
|
||
// Same treatment as `ModelMenu`: tinted glyph, primary-ink text for glass-bar
|
||
// legibility. Orchestra keeps its full-gold signature — that state should glow.
|
||
HStack(spacing: 4) {
|
||
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
|
||
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
|
||
Text(display(triggerEffort))
|
||
}
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(orchestraActive ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A bright golden ring that throbs around a composer while orchestra is selected — the
|
||
/// remote echo of the Mac's `OrchestraGlow`: the same bold, high-amplitude swell on a slow
|
||
/// beat with a long pause between pulses. Honors Reduce Motion (holds steady).
|
||
struct OrchestraGlowModifier: ViewModifier {
|
||
var active: Bool
|
||
var cornerRadius: CGFloat
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@State private var pulsing = false
|
||
|
||
func body(content: Content) -> some View {
|
||
let animating = active && pulsing && !reduceMotion
|
||
return content
|
||
.overlay {
|
||
if active {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.strokeBorder(Palette.orchestra.opacity(animating ? 0.95 : 0.5),
|
||
lineWidth: animating ? 2.2 : 1.3)
|
||
.shadow(color: Palette.orchestra.opacity(animating ? 0.7 : 0.22),
|
||
radius: animating ? 13 : 4)
|
||
.shadow(color: Palette.orchestra.opacity(animating ? 0.4 : 0.06),
|
||
radius: animating ? 20 : 4)
|
||
.animation(
|
||
reduceMotion ? .default
|
||
: .easeInOut(duration: 2.6).repeatForever(autoreverses: true),
|
||
value: pulsing)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.onAppear { pulsing = active }
|
||
.onChange(of: active) { _, now in pulsing = now }
|
||
}
|
||
}
|