Nucleic: Build A Claude “ultracode”-like System
This commit is contained in:
@@ -43,6 +43,14 @@ enum AppTheme {
|
||||
static let primaryText = dynamic(
|
||||
light: NSColor(srgbRed: 0.24, green: 0.24, blue: 0.27, alpha: 1),
|
||||
dark: NSColor(srgbRed: 0.80, green: 0.81, blue: 0.83, alpha: 1))
|
||||
/// The signature ultracode purple — the orchestration mode's accent for the effort
|
||||
/// pill, the composer's animated glow, and the multi-agent transcript cards. A vivid
|
||||
/// violet in light mode; a softer, brighter lavender in dark so it glows without
|
||||
/// burning. Mode-independent (a brand color, not a categorical status), so it lives
|
||||
/// here next to the other app surfaces rather than in the color-vision palette.
|
||||
static let ultracode = dynamic(
|
||||
light: NSColor(srgbRed: 0.55, green: 0.28, blue: 0.95, alpha: 1),
|
||||
dark: NSColor(srgbRed: 0.72, green: 0.55, blue: 1.0, alpha: 1))
|
||||
|
||||
/// A `Color` that resolves to `light`/`dark` against the view's effective
|
||||
/// appearance (driven by `.preferredColorScheme`).
|
||||
|
||||
@@ -210,6 +210,7 @@ struct HomeView: View {
|
||||
.frame(height: composerHeight)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
|
||||
.ultracodeGlow(active: isUltracode)
|
||||
Button(action: start) {
|
||||
SubmitKeyIcon(mode: submitMode)
|
||||
}
|
||||
@@ -278,6 +279,9 @@ struct HomeView: View {
|
||||
.help("Model for the new chat")
|
||||
}
|
||||
|
||||
/// Whether the chat about to start runs in ultracode (orchestration) mode.
|
||||
private var isUltracode: Bool { ModelCatalog.isUltracode(effectiveEffort) }
|
||||
|
||||
private var effortMenu: some View {
|
||||
Menu {
|
||||
ForEach(ModelCatalog.efforts, id: \.self) { level in
|
||||
@@ -285,12 +289,35 @@ struct HomeView: View {
|
||||
choiceLabel(level, isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
|
||||
}
|
||||
}
|
||||
// Ultracode sits below the API levels, set apart: it isn't an effort level but an
|
||||
// orchestration mode (xhigh + standing consent to fan out to subagents).
|
||||
Divider()
|
||||
Button { effortOverride = ModelCatalog.ultracodeEffort } label: {
|
||||
ultracodeMenuItem
|
||||
}
|
||||
} label: {
|
||||
if isUltracode {
|
||||
UltracodeEffortLabel()
|
||||
} else {
|
||||
Text("Effort: \(effectiveEffort)")
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.fixedSize()
|
||||
.help("Reasoning effort for the new chat")
|
||||
.help(isUltracode ? ModelCatalog.ultracodeBlurb : "Reasoning effort for the new chat")
|
||||
}
|
||||
|
||||
/// The Ultracode row in an effort menu: a sparkles glyph (a checkmark when it's the
|
||||
/// selected mode), "Ultracode", and the "(default)" suffix when it's the app default.
|
||||
@ViewBuilder
|
||||
private var ultracodeMenuItem: some View {
|
||||
let selected = isUltracode
|
||||
let isDefault = ModelCatalog.isUltracode(defaultEffort)
|
||||
Label {
|
||||
Text("Ultracode") + (isDefault ? Text(" (default)") : Text(""))
|
||||
} icon: {
|
||||
Image(systemName: selected ? "checkmark" : UltracodeStyle.symbol)
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-approve mode for the chat about to be started; defaults to the app-wide
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import NucleicCore
|
||||
|
||||
/// Selectable Claude model SKUs and effort levels, plus the persistence keys for
|
||||
/// the per-app defaults applied to new chats.
|
||||
@@ -17,6 +18,26 @@ enum ModelCatalog {
|
||||
]
|
||||
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
/// The "ultracode" orchestration mode (`OrchestrationMode`). It rides in the effort menu
|
||||
/// like a level, but it isn't an API effort — it pairs `xhigh` with standing consent to
|
||||
/// fan work out to parallel subagents. Kept out of `efforts` (the canonical API levels) so
|
||||
/// it can be presented separately, below a divider, with its own purple treatment.
|
||||
static let ultracodeEffort = OrchestrationMode.effortSentinel
|
||||
|
||||
/// Whether `effort` selects ultracode.
|
||||
static func isUltracode(_ effort: String?) -> Bool { OrchestrationMode.isUltracode(effort) }
|
||||
|
||||
/// A pretty, human label for an effort level — ultracode reads "Ultracode"; the plain API
|
||||
/// levels keep their lowercase word (matching how they're shown in the menu today).
|
||||
static func effortDisplayName(_ effort: String) -> String {
|
||||
isUltracode(effort) ? "Ultracode" : effort
|
||||
}
|
||||
|
||||
/// One-line description of what ultracode does, for the menu item's help tooltip.
|
||||
static let ultracodeBlurb =
|
||||
"Ultracode: maximum-effort orchestration. Runs at xhigh and lets the agent fan work "
|
||||
+ "out to parallel subagents on its own for thorough, verified results (uses more tokens)."
|
||||
|
||||
/// A pretty, human label for a model SKU (e.g. "claude-sonnet-4-6" → "Sonnet
|
||||
/// 4.6"). The two Opus 4.8 SKUs share the name "Opus 4.8"; their context window
|
||||
/// is conveyed by the picker badge (see `contextBadge`). Falls back to a
|
||||
|
||||
@@ -259,6 +259,9 @@ struct SessionDetailView: View {
|
||||
.help("Model for the next turn")
|
||||
}
|
||||
|
||||
/// Whether the next turn runs in ultracode (orchestration) mode.
|
||||
private var isUltracode: Bool { ModelCatalog.isUltracode(effectiveEffort) }
|
||||
|
||||
private var effortMenu: some View {
|
||||
Menu {
|
||||
ForEach(ModelCatalog.efforts, id: \.self) { level in
|
||||
@@ -266,8 +269,20 @@ struct SessionDetailView: View {
|
||||
choiceLabel(level, isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
|
||||
}
|
||||
}
|
||||
// Ultracode is an orchestration mode, not an API level — set it apart below the
|
||||
// canonical efforts (xhigh + standing consent to fan out to parallel subagents).
|
||||
Divider()
|
||||
Button { Task { await store.setOpenSessionEffort(ModelCatalog.ultracodeEffort) } } label: {
|
||||
ultracodeMenuItem
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isUltracode {
|
||||
UltracodeEffortLabel()
|
||||
} else {
|
||||
Text("Effort: \(effectiveEffort)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
@@ -275,9 +290,23 @@ struct SessionDetailView: View {
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(
|
||||
isUltracode ? AnyShapeStyle(AppTheme.ultracode.opacity(0.6)) : AnyShapeStyle(.quaternary),
|
||||
lineWidth: 1))
|
||||
.fixedSize()
|
||||
.help("Reasoning effort for the next turn")
|
||||
.help(isUltracode ? ModelCatalog.ultracodeBlurb : "Reasoning effort for the next turn")
|
||||
}
|
||||
|
||||
/// The Ultracode row in the effort menu: a sparkles glyph (checkmark when selected),
|
||||
/// "Ultracode", and "(default)" when it's the app default.
|
||||
@ViewBuilder
|
||||
private var ultracodeMenuItem: some View {
|
||||
let isDefault = ModelCatalog.isUltracode(defaultEffort)
|
||||
Label {
|
||||
Text("Ultracode") + (isDefault ? Text(" (default)") : Text(""))
|
||||
} icon: {
|
||||
Image(systemName: isUltracode ? "checkmark" : UltracodeStyle.symbol)
|
||||
}
|
||||
}
|
||||
|
||||
private var autoToggle: some View {
|
||||
@@ -891,6 +920,8 @@ struct SessionDetailView: View {
|
||||
.frame(height: composerHeight)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
|
||||
// An archived chat is inert, so don't glow even if its effort was ultracode.
|
||||
.ultracodeGlow(active: isUltracode && !isArchived)
|
||||
if isArchived {
|
||||
// Read-only chat: the send/cmd-return control is replaced by Unarchive,
|
||||
// the only way to make the composer and messages interactive again.
|
||||
|
||||
@@ -55,9 +55,16 @@ struct TranscriptRow: View {
|
||||
case .tool(_, let call, let result, let finished):
|
||||
toolRow(call: call, result: result, finished: finished).modifier(NonResponsePadding())
|
||||
case .toolGroup(_, let signature, let calls):
|
||||
// A run made entirely of subagent spawns is a multi-agent fan-out — render it
|
||||
// as the purple orchestration card (parallel subagents with live status) rather
|
||||
// than the generic tool block.
|
||||
if calls.allSatisfy({ Self.isSubagentCall($0.call) }) {
|
||||
OrchestrationCard(calls: calls).modifier(NonResponsePadding())
|
||||
} else {
|
||||
// The block supplies the single rounded container + padding; its inner
|
||||
// rows render chrome-less so they read as one contiguous card.
|
||||
ToolGroupRow(signature: signature, calls: calls).modifier(NonResponsePadding())
|
||||
}
|
||||
case .event(let event):
|
||||
rawEventBody(event).modifier(NonResponsePadding())
|
||||
}
|
||||
@@ -75,12 +82,23 @@ struct TranscriptRow: View {
|
||||
card
|
||||
} else if let card = gitCommitCard(call: call, result: result) {
|
||||
card
|
||||
} else if Self.isSubagentCall(call) {
|
||||
// A lone subagent spawn renders as its own purple subagent card (type, task, and
|
||||
// running/done/failed status), making delegated work read distinctly from the
|
||||
// agent's own tool calls.
|
||||
SubagentCard(call: call, result: result, finished: finished)
|
||||
} else {
|
||||
ToolCallRow(call: call, result: result, finished: finished,
|
||||
worktreeRoot: store.openSession?.worktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `call` spawns a subagent — Claude Code's `Task` tool (and the `Agent` alias).
|
||||
/// These get the dedicated subagent / orchestration cards instead of a generic tool row.
|
||||
static func isSubagentCall(_ call: ToolCall) -> Bool {
|
||||
call.name == "Task" || call.name == "Agent"
|
||||
}
|
||||
|
||||
/// A git commit pipeline renders as the same structured card the approval shows — subject,
|
||||
/// body, and steps — rather than the raw "Bash <command>" row. The literal command stays
|
||||
/// reachable under the card's "Show command", and a trailing `git log` result (the new
|
||||
@@ -988,3 +1006,277 @@ private struct NonResponsePadding: ViewModifier {
|
||||
content.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subagent & multi-agent cards
|
||||
//
|
||||
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated
|
||||
// work in the ultracode purple so it reads distinctly from the agent's own tool calls: a
|
||||
// lone `Task` becomes a `SubagentCard`, and a parallel wave of them becomes an
|
||||
// `OrchestrationCard`. Both build entirely from the existing tool-call/result events
|
||||
// (`ToolCall.parentToolCallID` already links a subagent's inner calls to its parent), so
|
||||
// no protocol or projection changes are needed.
|
||||
|
||||
/// The lifecycle state of a spawned subagent, read from its `Task` tool result: no result
|
||||
/// yet → still working; an error result → failed; otherwise → done.
|
||||
private enum SubagentRunState {
|
||||
case running, done, failed
|
||||
|
||||
init(result: ToolResult?) {
|
||||
guard let result else { self = .running; return }
|
||||
self = result.isError ? .failed : .done
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .running: "Working"
|
||||
case .done: "Done"
|
||||
case .failed: "Failed"
|
||||
}
|
||||
}
|
||||
|
||||
/// Status tint: done/failed reuse the shared palette so they match the rest of the app;
|
||||
/// a still-running subagent glows in the ultracode purple.
|
||||
func color(_ palette: AppPalette) -> Color {
|
||||
switch self {
|
||||
case .running: AppTheme.ultracode
|
||||
case .done: palette.success
|
||||
case .failed: palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the human-facing bits out of a `Task` tool call so the subagent cards agree on what
|
||||
/// to show.
|
||||
private enum Subagent {
|
||||
/// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
|
||||
static func type(_ call: ToolCall) -> String? {
|
||||
let raw = call.input["subagent_type"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (raw?.isEmpty == false) ? raw : nil
|
||||
}
|
||||
|
||||
/// The one-line task the parent handed the subagent (`description`), falling back to the
|
||||
/// full `prompt`, then a generic label.
|
||||
static func taskLabel(_ call: ToolCall) -> String {
|
||||
if let description = call.input["description"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
|
||||
return description
|
||||
}
|
||||
if let prompt = call.input["prompt"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !prompt.isEmpty {
|
||||
return prompt
|
||||
}
|
||||
return "Subagent task"
|
||||
}
|
||||
|
||||
/// "Explore — find the composer", joining the flavor and task for a compact one-liner.
|
||||
static func headline(_ call: ToolCall) -> String {
|
||||
let label = taskLabel(call)
|
||||
if let type = type(call) { return "\(type) — \(label)" }
|
||||
return label
|
||||
}
|
||||
|
||||
/// The subagent's returned report, or nil while it's still working / reported nothing.
|
||||
static func report(_ result: ToolResult?) -> String? {
|
||||
guard let result else { return nil }
|
||||
let text = TranscriptRow.resultText(result.content)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// A small leading status glyph for a subagent — a purple spinner while it works, a green
|
||||
/// check or red cross once it settles.
|
||||
private struct SubagentStatusGlyph: View {
|
||||
@Environment(\.appPalette) private var palette
|
||||
let state: SubagentRunState
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .running: ProgressView().controlSize(.mini).tint(AppTheme.ultracode)
|
||||
case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(palette.success)
|
||||
case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(palette.danger)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.frame(width: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// A status pill (spinner / check / cross + word) for the header of a subagent card.
|
||||
private struct SubagentStatusChip: View {
|
||||
@Environment(\.appPalette) private var palette
|
||||
let state: SubagentRunState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
SubagentStatusGlyph(state: state).frame(width: 14)
|
||||
Text(state.label)
|
||||
}
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(state.color(palette))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(state.color(palette).opacity(0.14), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// One delegated subagent (`Task`), as a purple card: its type and the task it was given, a
|
||||
/// live Working / Done / Failed status, and — once it reports back — its findings, collapsed
|
||||
/// by default. Mirrors the other transcript cards (GitBlockCard et al.) in the ultracode hue.
|
||||
private struct SubagentCard: View {
|
||||
@Environment(\.appPalette) private var palette
|
||||
let call: ToolCall
|
||||
let result: ToolResult?
|
||||
let finished: Bool
|
||||
@State private var expanded = false
|
||||
|
||||
private let accent = AppTheme.ultracode
|
||||
private var state: SubagentRunState { SubagentRunState(result: result) }
|
||||
private var report: String? { Subagent.report(result) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard report != nil else { return }
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
if expanded, let report {
|
||||
Divider().overlay(accent.opacity(0.2))
|
||||
Text(report)
|
||||
.font(.callout)
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.background(accent.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.body)
|
||||
.foregroundStyle(accent)
|
||||
.frame(width: 18)
|
||||
(Text("Subagent")
|
||||
+ (Subagent.type(call).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: state)
|
||||
if report != nil {
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(Subagent.taskLabel(call))
|
||||
.font(.callout)
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
.lineLimit(expanded ? nil : 2)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together,
|
||||
/// the visual centerpiece of ultracode's orchestration. The header sums up the wave (count +
|
||||
/// overall status); collapsed, each subagent is a one-line status row; expanded, each shows
|
||||
/// its task and returned report. Purple throughout to mark it as orchestration work.
|
||||
private struct OrchestrationCard: View {
|
||||
@Environment(\.appPalette) private var palette
|
||||
let calls: [(call: ToolCall, result: ToolResult?, finished: Bool)]
|
||||
@State private var expanded = false
|
||||
|
||||
private let accent = AppTheme.ultracode
|
||||
|
||||
/// Overall wave status: failed if any subagent failed, else running if any is still
|
||||
/// working, else done.
|
||||
private var overall: SubagentRunState {
|
||||
let states = calls.map { SubagentRunState(result: $0.result) }
|
||||
if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
|
||||
if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
|
||||
return .done
|
||||
}
|
||||
|
||||
private var doneCount: Int { calls.filter { $0.result != nil }.count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
ForEach(Array(calls.enumerated()), id: \.element.call.toolCallID) { _, entry in
|
||||
Divider().overlay(accent.opacity(0.18))
|
||||
if expanded {
|
||||
expandedRow(entry)
|
||||
} else {
|
||||
collapsedRow(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(accent.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "rectangle.3.group.fill")
|
||||
.font(.body)
|
||||
.foregroundStyle(accent)
|
||||
.frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Multi-agent fan-out")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
Text("\(calls.count) subagents · \(doneCount) done")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: overall)
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
|
||||
/// Collapsed: one line per subagent — status glyph + "Type — task", truncated.
|
||||
private func collapsedRow(_ entry: (call: ToolCall, result: ToolResult?, finished: Bool)) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
||||
SubagentStatusGlyph(state: SubagentRunState(result: entry.result))
|
||||
Text(Subagent.headline(entry.call))
|
||||
.font(.callout)
|
||||
.foregroundStyle(AppTheme.primaryText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 6)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
}
|
||||
|
||||
/// Expanded: the same status line plus the subagent's returned report beneath it.
|
||||
private func expandedRow(_ entry: (call: ToolCall, result: ToolResult?, finished: Bool)) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
collapsedRow(entry)
|
||||
if let report = Subagent.report(entry.result) {
|
||||
Text(report)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shared visual language for ultracode (orchestration) mode: the signature purple, a
|
||||
/// gently-pulsing composer glow, and the small effort-pill label. Kept together so the
|
||||
/// home chat bar, the session composer, and the effort menus all read identically.
|
||||
enum UltracodeStyle {
|
||||
/// SF Symbol that marks ultracode throughout the UI — the same sparkles used for the
|
||||
/// agent's thinking, so "extra intelligence" reads consistently.
|
||||
static let symbol = "sparkles"
|
||||
|
||||
/// A soft violet gradient used for the glow stroke, hot corner to cool corner.
|
||||
static func strokeGradient(intensity: Double) -> LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
AppTheme.ultracode.opacity(0.55 + 0.40 * intensity),
|
||||
AppTheme.ultracode.opacity(0.28 + 0.30 * intensity),
|
||||
],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cute, restrained purple halo that breathes around a view while ultracode is the active
|
||||
/// effort. Mirrors `StreakBadge`'s idiom — a single `@State` flag toggled to kick off an
|
||||
/// `easeInOut(...).repeatForever(autoreverses:)` pulse — so the animation feels native to the
|
||||
/// app. The halo crests brighter and never collapses to nothing, so even at the trough it
|
||||
/// reads as "ultracode is on", not a flicker. Honors Reduce Motion: the glow still shows, it
|
||||
/// just holds steady instead of pulsing.
|
||||
struct UltracodeGlow: ViewModifier {
|
||||
/// Whether ultracode is the selected effort. When false the modifier is inert (no glow,
|
||||
/// no animation), so a plain chat composer is untouched.
|
||||
var active: Bool
|
||||
var cornerRadius: CGFloat = 8
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
/// Toggled once the view is on screen (and whenever `active` flips), which starts —
|
||||
/// or, under Reduce Motion, simply settles — the repeating pulse.
|
||||
@State private var pulsing = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Animate only while ultracode is on, the pulse has been kicked off, and the user
|
||||
// hasn't asked to reduce motion; otherwise the halo holds at a calm mid intensity.
|
||||
let animating = active && pulsing && !reduceMotion
|
||||
let intensity = animating ? 1.0 : 0.45
|
||||
return content
|
||||
.overlay {
|
||||
if active {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.strokeBorder(UltracodeStyle.strokeGradient(intensity: intensity),
|
||||
lineWidth: animating ? 1.6 : 1.2)
|
||||
.shadow(color: AppTheme.ultracode.opacity(animating ? 0.55 : 0.22),
|
||||
radius: animating ? 9 : 4)
|
||||
// A wider, fainter outer bloom so the glow feels soft rather than a
|
||||
// hard ring — the second shadow reads as ambient light around the box.
|
||||
.shadow(color: AppTheme.ultracode.opacity(animating ? 0.30 : 0),
|
||||
radius: animating ? 16 : 0)
|
||||
.animation(
|
||||
reduceMotion
|
||||
? .default
|
||||
: .easeInOut(duration: 1.8).repeatForever(autoreverses: true),
|
||||
value: pulsing)
|
||||
.allowsHitTesting(false) // purely decorative — never eats composer taps
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: active)
|
||||
.onAppear { pulsing = active }
|
||||
.onChange(of: active) { _, now in pulsing = now }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Wrap a composer (or any rounded box) in the ultracode purple glow while `active`.
|
||||
/// Place it *after* the view's own background so the halo sits around the box.
|
||||
func ultracodeGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View {
|
||||
modifier(UltracodeGlow(active: active, cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
|
||||
/// The effort-menu trigger label when ultracode is selected: a sparkles glyph + "Ultracode"
|
||||
/// in the signature purple, replacing the plain "Effort: xhigh" text so the mode is obvious
|
||||
/// at a glance. The sparkle does a slow twinkle to echo the composer glow (steady under
|
||||
/// Reduce Motion).
|
||||
struct UltracodeEffortLabel: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var twinkle = false
|
||||
|
||||
var body: some View {
|
||||
let on = twinkle && !reduceMotion
|
||||
return HStack(spacing: 4) {
|
||||
Image(systemName: UltracodeStyle.symbol)
|
||||
.opacity(on ? 1.0 : 0.7)
|
||||
.scaleEffect(on ? 1.0 : 0.9)
|
||||
.animation(
|
||||
reduceMotion ? .default : .easeInOut(duration: 1.2).repeatForever(autoreverses: true),
|
||||
value: twinkle)
|
||||
Text("Ultracode")
|
||||
}
|
||||
.foregroundStyle(AppTheme.ultracode)
|
||||
.onAppear { twinkle = true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
/// "Ultracode" — Nucleic's orchestration mode, modeled on Claude Code's ultracode.
|
||||
///
|
||||
/// Ultracode is *not* an API effort level. It pairs the documented `xhigh` effort with
|
||||
/// standing consent for the agent to fan work out to parallel subagents (the `Task` /
|
||||
/// `Workflow` tools) by default. The user selects it in the effort menu like any other
|
||||
/// level, but it never reaches a backend verbatim:
|
||||
///
|
||||
/// - `resolvedEffort` maps the `ultracode` sentinel to its real effort, `xhigh`, so the
|
||||
/// `claude --effort` (and Codex) flag only ever sees a level the API accepts.
|
||||
/// - `combinedSystemPrompt` rides the standing-consent text in on `--append-system-prompt`.
|
||||
/// The CLI doesn't persist that flag, so `SessionController` re-applies it every turn —
|
||||
/// which is exactly the per-turn "mode reminder" the orchestration design calls for, so
|
||||
/// no separate refresher counter is needed here.
|
||||
///
|
||||
/// Keeping the sentinel, the effort mapping, and the consent text in one place lets the UI,
|
||||
/// the core, and the tests agree on what "ultracode" means.
|
||||
public enum OrchestrationMode {
|
||||
/// The sentinel stored in `Session.effort` when the user picks Ultracode. Chosen so it
|
||||
/// never collides with a real API effort level and is obvious in the persisted store.
|
||||
public static let effortSentinel = "ultracode"
|
||||
|
||||
/// The real effort level ultracode runs at. The xhigh ceiling is the most thorough the
|
||||
/// API documents; ultracode adds the fan-out consent on top, not a hidden higher level.
|
||||
public static let resolvedUltracodeEffort = "xhigh"
|
||||
|
||||
/// Whether `effort` selects ultracode (case-insensitive, whitespace-tolerant).
|
||||
public static func isUltracode(_ effort: String?) -> Bool {
|
||||
effort?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == effortSentinel
|
||||
}
|
||||
|
||||
/// The effort actually handed to a backend: `ultracode` resolves to `xhigh`; everything
|
||||
/// else passes through untouched. One-way — the resolved value never flows back into
|
||||
/// `Session.effort`, so the UI keeps showing "Ultracode".
|
||||
public static func resolvedEffort(_ effort: String?) -> String? {
|
||||
isUltracode(effort) ? resolvedUltracodeEffort : effort
|
||||
}
|
||||
|
||||
/// The standing-consent instruction appended to the system prompt while ultracode is on.
|
||||
/// It grants the agent blanket permission to orchestrate multi-agent workflows for the
|
||||
/// session and points at the fan-out / verification patterns to reach for. Re-sent every
|
||||
/// turn by the caller, so it doubles as the mode's recurring reminder.
|
||||
public static let consentPrompt = """
|
||||
Ultracode (orchestration mode) is on: optimize for the most exhaustive, correct \
|
||||
answer rather than the fastest one, and put maximum thoroughness behind every \
|
||||
substantive request. Standing consent: you may fan work out to parallel subagents \
|
||||
(the Task tool, or a Workflow/orchestration tool when available) on every substantive \
|
||||
task without asking first — scout the task yourself, then decompose it into \
|
||||
independent subtasks sized to the problem's natural structure and run them \
|
||||
concurrently, reading their results between phases. Lean on the quality patterns that \
|
||||
fit: adversarial verification (a second wave that tries to refute the first wave's \
|
||||
findings against the source), a completeness critic (one agent hunting for what the \
|
||||
others missed), and multi-phase sequencing (understand, design, implement, review as \
|
||||
separate fan-outs). Work solo only on conversational or trivial turns. The fan-out \
|
||||
multiplies token usage, so keep each subtask scoped to a distinct concern rather than \
|
||||
splitting per line or file.
|
||||
"""
|
||||
|
||||
/// The system prompt appended for a run: the caller's environment guidance (sandbox build
|
||||
/// instructions, etc.) plus, when `effort` selects ultracode, the orchestration consent.
|
||||
/// Returns `nil` when neither applies so the backend flag is omitted entirely; joins the
|
||||
/// two with a blank line when both are present, so the consent reads as its own paragraph.
|
||||
public static func combinedSystemPrompt(environment: String?, effort: String?) -> String? {
|
||||
let parts = [environment, isUltracode(effort) ? consentPrompt : nil]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
return parts.isEmpty ? nil : parts.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,20 @@ public actor SessionController {
|
||||
return Self.sandboxBuildGuidance(allowHostExec: allowsHostExec)
|
||||
}
|
||||
|
||||
/// The effort actually sent to the backend. `ultracode` is a Nucleic-only orchestration
|
||||
/// mode, not an API effort level, so it resolves to its real effort (`xhigh`); every other
|
||||
/// level passes through. The resolved value never flows back into `session.effort`, so the
|
||||
/// UI keeps showing "Ultracode" (`OrchestrationMode`).
|
||||
private var resolvedEffort: String? { OrchestrationMode.resolvedEffort(session.effort) }
|
||||
|
||||
/// The system prompt appended to a run: the sandbox build guidance (when sandboxed) plus,
|
||||
/// in ultracode mode, the orchestration standing-consent text. Re-applied on every start /
|
||||
/// resume because the CLI doesn't persist `--append-system-prompt`, which keeps the consent
|
||||
/// in effect as ultracode's per-turn reminder.
|
||||
private var appendedSystemPrompt: String? {
|
||||
OrchestrationMode.combinedSystemPrompt(environment: environmentSystemPrompt, effort: session.effort)
|
||||
}
|
||||
|
||||
/// The build-environment guidance text. Separated from `environmentSystemPrompt` so it can be
|
||||
/// unit-tested without standing up a full controller. Describes the container's own toolchain
|
||||
/// (Node/npm, Python/pip, make/gcc — see the sandbox image in `ContainerRuntime`) and routes
|
||||
@@ -422,12 +436,12 @@ public actor SessionController {
|
||||
worktree: worktreePath,
|
||||
prompt: prompt,
|
||||
model: session.model,
|
||||
effort: session.effort,
|
||||
effort: resolvedEffort,
|
||||
autoApprove: session.auto,
|
||||
approvalPolicy: .interactive,
|
||||
container: containerSpec(),
|
||||
allowHostExec: allowsHostExec,
|
||||
appendSystemPrompt: environmentSystemPrompt)
|
||||
appendSystemPrompt: appendedSystemPrompt)
|
||||
consume(backend.start(run), injectingUserText: prompt.plainText)
|
||||
}
|
||||
|
||||
@@ -443,7 +457,7 @@ public actor SessionController {
|
||||
worktree: worktreePath,
|
||||
container: containerSpec(),
|
||||
allowHostExec: allowsHostExec,
|
||||
appendSystemPrompt: environmentSystemPrompt)
|
||||
appendSystemPrompt: appendedSystemPrompt)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
}
|
||||
|
||||
@@ -538,18 +552,18 @@ public actor SessionController {
|
||||
let spec = ResumeSpec(
|
||||
sessionID: session.id, backendSessionID: backendSessionID,
|
||||
worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort, autoApprove: session.auto,
|
||||
model: session.model, effort: resolvedEffort, autoApprove: session.auto,
|
||||
container: container, allowHostExec: allowsHostExec,
|
||||
appendSystemPrompt: environmentSystemPrompt)
|
||||
appendSystemPrompt: appendedSystemPrompt)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
} else {
|
||||
// No turn has run yet → this message starts the session.
|
||||
let run = RunSpec(
|
||||
sessionID: session.id, worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort,
|
||||
model: session.model, effort: resolvedEffort,
|
||||
autoApprove: session.auto, approvalPolicy: .interactive,
|
||||
container: container, allowHostExec: allowsHostExec,
|
||||
appendSystemPrompt: environmentSystemPrompt)
|
||||
appendSystemPrompt: appendedSystemPrompt)
|
||||
consume(backend.start(run), injectingUserText: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import NucleicCore
|
||||
|
||||
@Suite("Ultracode orchestration mode")
|
||||
struct OrchestrationModeTests {
|
||||
@Test func detectsUltracodeSentinel() {
|
||||
#expect(OrchestrationMode.isUltracode("ultracode"))
|
||||
#expect(OrchestrationMode.isUltracode(" Ultracode ")) // trimmed, case-insensitive
|
||||
#expect(!OrchestrationMode.isUltracode("xhigh"))
|
||||
#expect(!OrchestrationMode.isUltracode("high"))
|
||||
#expect(!OrchestrationMode.isUltracode(nil))
|
||||
}
|
||||
|
||||
@Test func resolvesUltracodeToXhigh() {
|
||||
// The sentinel never reaches a backend verbatim — it maps to its real effort level.
|
||||
#expect(OrchestrationMode.resolvedEffort("ultracode") == "xhigh")
|
||||
#expect(OrchestrationMode.resolvedEffort("Ultracode") == "xhigh")
|
||||
}
|
||||
|
||||
@Test func passesThroughRealEffortLevels() {
|
||||
for level in ["low", "medium", "high", "xhigh", "max"] {
|
||||
#expect(OrchestrationMode.resolvedEffort(level) == level)
|
||||
}
|
||||
#expect(OrchestrationMode.resolvedEffort(nil) == nil)
|
||||
}
|
||||
|
||||
@Test func appendsConsentOnlyInUltracode() {
|
||||
// Off: nothing to append when there's no environment guidance either.
|
||||
#expect(OrchestrationMode.combinedSystemPrompt(environment: nil, effort: "high") == nil)
|
||||
// On: the consent text is appended.
|
||||
let consentOnly = OrchestrationMode.combinedSystemPrompt(environment: nil, effort: "ultracode")
|
||||
#expect(consentOnly == OrchestrationMode.consentPrompt)
|
||||
}
|
||||
|
||||
@Test func mergesEnvironmentGuidanceWithConsent() {
|
||||
let environment = "Build environment: you are running inside a Linux container."
|
||||
// Not ultracode → only the environment guidance survives.
|
||||
#expect(
|
||||
OrchestrationMode.combinedSystemPrompt(environment: environment, effort: "max") == environment)
|
||||
// Ultracode → both, environment first, consent as its own paragraph.
|
||||
let merged = OrchestrationMode.combinedSystemPrompt(environment: environment, effort: "ultracode")
|
||||
#expect(merged == environment + "\n\n" + OrchestrationMode.consentPrompt)
|
||||
}
|
||||
|
||||
@Test func consentMentionsStandingFanOut() {
|
||||
// The contract that makes ultracode work: standing consent to fan out to subagents.
|
||||
let consent = OrchestrationMode.consentPrompt.lowercased()
|
||||
#expect(consent.contains("standing consent"))
|
||||
#expect(consent.contains("subagent"))
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,16 +58,23 @@ struct ProjectDetailView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
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)
|
||||
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") {
|
||||
if sessions.isEmpty {
|
||||
Text("No sessions yet.").foregroundStyle(.secondary)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user