diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 05404c3..7aca549 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -8,6 +8,7 @@ struct StartChatComposer: View { @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false + @State private var effort = MobileEfforts.fallback private var projects: [WireProject] { store.dashboard.projects } private var selected: WireProject? { @@ -27,6 +28,7 @@ struct StartChatComposer: View { .font(.subheadline) } Spacer() + EffortMenu(effort: $effort) Toggle(isOn: $auto) { Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash") } @@ -38,9 +40,10 @@ struct StartChatComposer: View { TextField("Describe a task…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...5) + .ultracodeGlow(active: MobileEfforts.isUltracode(effort)) Button { if let project = selected { - store.startChat(in: project.id, message: draft, auto: auto) + store.startChat(in: project.id, message: draft, effort: effort, auto: auto) draft = "" } } label: { diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 96850dd..781c1df 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -47,6 +47,7 @@ struct ProjectDetailView: View { @EnvironmentObject var store: RemoteStore let project: WireProject @State private var draft = "" + @State private var effort = MobileEfforts.fallback private var sessions: [WireSessionSummary] { store.liveSessions @@ -57,14 +58,21 @@ struct ProjectDetailView: View { var body: some View { List { Section { - HStack(alignment: .bottom, spacing: 8) { - TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical) - .textFieldStyle(.roundedBorder).lineLimit(1...4) - Button { - store.startChat(in: project.id, message: draft) - draft = "" - } label: { Image(systemName: "arrow.up.circle.fill").font(.title2) } - .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + VStack(alignment: .leading, spacing: 8) { + HStack { + EffortMenu(effort: $effort) + Spacer() + } + HStack(alignment: .bottom, spacing: 8) { + TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(1...4) + .ultracodeGlow(active: MobileEfforts.isUltracode(effort)) + Button { + store.startChat(in: project.id, message: draft, effort: effort) + draft = "" + } label: { Image(systemName: "arrow.up.circle.fill").font(.title2) } + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + } } } Section("Sessions") { diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 9b972c5..0c10bcb 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -13,6 +13,10 @@ enum Palette { 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 static let neutral = Color.secondary + /// The ultracode (orchestration mode) signature purple — mirrors the Mac's + /// `AppTheme.ultracode`. Used for the effort picker's Ultracode option and the + /// composer's purple ring. + static let ultracode = Color(red: 0.55, green: 0.28, blue: 0.95) /// 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. @@ -113,4 +117,79 @@ 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) } + /// Wrap a composer field in the ultracode purple ring while `active` (mirrors the Mac's + /// animated composer glow, lighter touch). + func ultracodeGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View { + modifier(UltracodeGlowModifier(active: active, cornerRadius: cornerRadius)) + } +} + +/// The effort levels the remote offers, mirroring the Mac's `ModelCatalog.efforts` plus +/// ultracode. `ultracodeSentinel` 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 ultracodeSentinel = "ultracode" + static let fallback = "high" + + static func isUltracode(_ effort: String) -> Bool { effort == ultracodeSentinel } + static func displayName(_ effort: String) -> String { isUltracode(effort) ? "Ultracode" : effort } +} + +/// A compact effort selector for the remote composers. Ultracode sits below the API levels, +/// set apart with a sparkles glyph and the purple accent. +struct EffortMenu: View { + @Binding var effort: String + + var body: some View { + Menu { + ForEach(MobileEfforts.levels, id: \.self) { level in + Button { effort = level } label: { + if effort == level { Label(level, systemImage: "checkmark") } else { Text(level) } + } + } + Divider() + Button { effort = MobileEfforts.ultracodeSentinel } label: { + Label("Ultracode", systemImage: MobileEfforts.isUltracode(effort) ? "checkmark" : "sparkles") + } + } label: { + HStack(spacing: 4) { + Image(systemName: MobileEfforts.isUltracode(effort) ? "sparkles" : "slider.horizontal.3") + Text(MobileEfforts.displayName(effort)) + } + .font(.caption) + .foregroundStyle(MobileEfforts.isUltracode(effort) ? Palette.ultracode : Palette.accent) + } + } +} + +/// A gentle purple ring that breathes around a composer while ultracode is selected — the +/// remote echo of the Mac's `UltracodeGlow`. Honors Reduce Motion (holds steady). +struct UltracodeGlowModifier: 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.ultracode.opacity(animating ? 0.9 : 0.5), + lineWidth: animating ? 1.6 : 1.2) + .shadow(color: Palette.ultracode.opacity(animating ? 0.5 : 0.2), + radius: animating ? 8 : 3) + .animation( + reduceMotion ? .default + : .easeInOut(duration: 1.8).repeatForever(autoreverses: true), + value: pulsing) + .allowsHitTesting(false) + } + } + .onAppear { pulsing = active } + .onChange(of: active) { _, now in pulsing = now } + } }