Nucleic-Session: 129BD35A-FB15-4E39-82E2-F476950F60AD Co-authored-by: Nucleic <[email protected]>
326 lines
15 KiB
Swift
326 lines
15 KiB
Swift
import SwiftUI
|
|
import NucleicProtocol
|
|
|
|
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): a deep
|
|
/// teal accent, the same categorical status palette, and the same status→color mapping
|
|
/// refined by turn disposition — so the two devices read identically. Pure SwiftUI Color
|
|
/// (no AppKit), adapting to light/dark via the system.
|
|
enum Palette {
|
|
static let accent = Color(red: 0.04, green: 0.52, blue: 0.50) // deep teal
|
|
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // blue
|
|
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // amber
|
|
static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green
|
|
static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple
|
|
static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red
|
|
/// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's
|
|
/// `AppPalette.frozen`.
|
|
static let frozen = Color(red: 0.26, green: 0.64, blue: 0.96)
|
|
static let neutral = Color.secondary
|
|
/// The orchestra (orchestration mode) signature gold — mirrors the Mac's
|
|
/// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the
|
|
/// composer's golden ring.
|
|
static let orchestra = Color(red: 0.90, green: 0.66, blue: 0.16)
|
|
|
|
/// 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: success.opacity(0.4)
|
|
case 2: success.opacity(0.7)
|
|
default: success
|
|
}
|
|
}
|
|
|
|
/// 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) : success.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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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)) }
|
|
/// 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
|
|
}
|
|
private var label: String { model.map { catalog.displayName($0) } ?? "Default model" }
|
|
|
|
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: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "cpu")
|
|
Text(label)
|
|
if let badge = model.flatMap({ catalog.contextBadge($0) }) {
|
|
Text(badge).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(Palette.accent)
|
|
}
|
|
.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
|
|
|
|
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)
|
|
}
|
|
|
|
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)) }
|
|
}
|
|
}
|
|
Divider()
|
|
Button { effort = sentinel } label: {
|
|
Label(
|
|
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
|
|
systemImage: orchestraActive ? "checkmark" : "sparkles")
|
|
}
|
|
.disabled(!controlled)
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
|
|
Text(display(triggerEffort))
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 }
|
|
}
|
|
}
|