Promote nvrsion trunk → dev
Squash-merge nucleic/trunk into dev (234 commits of accumulated nvrsion work). Resolved two auto-generated-file conflicts: - cloud/nucleic-edge/wrangler.jsonc: kept dev's real KV namespace ids and the xyz.blakeslee.nucleic.remote APNS topic, with binding names corrected to RELAY_TOKENS/PUSH_TOKENS to match the worker code on both branches. - Package.resolved: took trunk's newer dependency pins. Nucleic-Promote: 1 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efa94c51c8
commit
b50cb60190
@@ -113,7 +113,7 @@ struct BuildBanner: View {
|
||||
case .beta:
|
||||
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
|
||||
label = "Beta"
|
||||
icon = "exclamationmark.bubble.fill"
|
||||
icon = "moon.fill"
|
||||
case .releaseCandidate:
|
||||
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
|
||||
label = "Release Candidate"
|
||||
|
||||
@@ -599,13 +599,18 @@ struct RootView: View {
|
||||
// it settles into a dimmed checkmark — nothing to integrate, so there's nothing
|
||||
// to press.
|
||||
let pending = store.nvrsionHasPendingIntegration(project.id)
|
||||
// When the trunk has work but the last (auto or manual) integration couldn't
|
||||
// complete — a conflict or a hard failure — the button turns into a red ✗ that
|
||||
// stays clickable for a manual retry once the conflict's resolved on the base branch.
|
||||
let failure = pending ? store.nvrsionIntegrationFailure(project.id) : nil
|
||||
let integrateHelp = !pending
|
||||
? "\(project.name)'s nvrsion trunk is fully integrated into "
|
||||
+ "\(project.defaultBranch.value) — nothing to promote."
|
||||
: chatsRunning
|
||||
: failure.map { "\($0) (Couldn't integrate automatically.)" }
|
||||
?? (chatsRunning
|
||||
? "Finish or stop \(project.name)'s running chats before integrating."
|
||||
: "Integrate \(project.name)'s nvrsion trunk into \(project.defaultBranch.value) — "
|
||||
+ "squash the trunk into your real branch as one commit."
|
||||
+ "squash the trunk into your real branch as one commit.")
|
||||
Button {
|
||||
Task {
|
||||
promotingProjects.insert(project.id)
|
||||
@@ -615,6 +620,10 @@ struct RootView: View {
|
||||
} label: {
|
||||
if promoting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if failure != nil {
|
||||
Image(systemName: "xmark.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
} else if pending {
|
||||
Image(systemName: "arrow.up.to.line")
|
||||
.font(.title3)
|
||||
@@ -626,7 +635,9 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(move != nil || promoting || chatsRunning || !pending)
|
||||
// A flagged failure keeps the button live (so the user can retry after resolving),
|
||||
// even though it isn't auto-eligible while a chat runs.
|
||||
.disabled(move != nil || promoting || (chatsRunning && failure == nil) || !pending)
|
||||
.padding(.trailing, 6)
|
||||
.help(integrateHelp)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ struct QuickTodoSheet: View {
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.menuIndicator(.hidden) // we draw our own chevron in the label
|
||||
.fixedSize()
|
||||
Spacer()
|
||||
Text("⌘↵ to add").font(.caption2).foregroundStyle(.tertiary)
|
||||
@@ -94,6 +95,7 @@ struct EditTodoSheet: View {
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.menuIndicator(.hidden) // we draw our own chevron in the label
|
||||
.fixedSize()
|
||||
Spacer()
|
||||
Text("⌘↵ to save").font(.caption2).foregroundStyle(.tertiary)
|
||||
|
||||
@@ -482,6 +482,23 @@ public final class AppStore: ConflictArbiter {
|
||||
/// everything's integrated. Updated optimistically when work lands and after a promote, and
|
||||
/// recomputed from git on launch (`reconcileNvrsionTrunks`). Absent/`false` ⇒ nothing to integrate.
|
||||
public private(set) var nvrsionPendingByID: [ProjectID: Bool] = [:]
|
||||
/// Per-project: the reason a promote couldn't complete — a conflict or a hard failure — or nil
|
||||
/// when the last attempt was clean. Drives the sidebar's red-✗ Integrate state: when set, the
|
||||
/// trunk has work that *could not be integrated automatically* and needs a hand (resolve on the
|
||||
/// base branch, then retry the button). Set on a conflicted/failed promote; cleared on a clean
|
||||
/// one (`.promoted`/`.nothingToPromote`) or once the trunk recomputes to nothing-pending. While
|
||||
/// set, the auto-integrate countdown stands down — it won't keep retrying a wedged promote.
|
||||
public private(set) var nvrsionIntegrateFailureByID: [ProjectID: String] = [:]
|
||||
/// Per-project armed countdown to auto-integrate (NVRSION §6): once a project's trunk has been
|
||||
/// continuously *integratable* — pending work, no chat mid-turn, not relocating — for
|
||||
/// `nvrsionAutoIntegrateDelay`, this fires `promoteNvrsionTrunk` on its own, so a finished batch
|
||||
/// of agents ships itself without a manual press. The task is cancelled the instant availability
|
||||
/// lapses (a chat picks up a turn, a move starts, a manual promote lands), so it only ever fires
|
||||
/// against a genuinely-quiet, still-pending project. Absent ⇒ no countdown in flight.
|
||||
private var nvrsionAutoIntegrateTasks: [ProjectID: Task<Void, Never>] = [:]
|
||||
/// How long a project's trunk must stay continuously integratable before it auto-promotes.
|
||||
/// Internal so tests can shorten it (or drive `fireNvrsionAutoIntegration` directly).
|
||||
var nvrsionAutoIntegrateDelay: Duration = .seconds(3)
|
||||
/// The release-reconcile loop; nil when nothing holds a lock (LOCKING §4.4).
|
||||
private var lockReconcileTask: Task<Void, Never>?
|
||||
/// Re-check cadence for `reconcileLocks` — a slow backstop, since release is also event-driven
|
||||
@@ -2187,6 +2204,9 @@ public final class AppStore: ConflictArbiter {
|
||||
// so it can't have added anything). Optimistic: avoids a git diff on every edit.
|
||||
if case .landed(let sha) = result {
|
||||
nvrsionPendingByID[session.projectID] = true
|
||||
// Newly-pending: arm the auto-integrate countdown (a no-op until this project's
|
||||
// chats fall quiet, since the landing session is itself mid-turn right now).
|
||||
reconsiderNvrsionAutoIntegration(session.projectID)
|
||||
// Surface the land as a transcript note — nvrsion's in-chat ops log. A worktree
|
||||
// session sees autoship's commit/merge lines in its chat; an nvrsion session
|
||||
// commits per edit straight to the trunk, so this is the equivalent record of
|
||||
@@ -2277,21 +2297,35 @@ public final class AppStore: ConflictArbiter {
|
||||
{
|
||||
case .promoted(let sha):
|
||||
lastError = nil
|
||||
// The trunk is now squashed into base and resynced onto it — nothing left to integrate.
|
||||
// The trunk is now squashed into base and resynced onto it — nothing left to integrate,
|
||||
// and a clean run clears any prior conflict/failure (the sidebar drops its red ✗).
|
||||
nvrsionPendingByID[projectID] = false
|
||||
nvrsionIntegrateFailureByID[projectID] = nil
|
||||
reconsiderNvrsionAutoIntegration(projectID) // nothing pending now → stand the timer down
|
||||
lockLog.notice("nvrsion promoted project=\(projectID.rawValue, privacy: .public) base=\(base, privacy: .public) sha=\(sha, privacy: .public)")
|
||||
return true
|
||||
case .nothingToPromote:
|
||||
// Already in sync — settle the icon to its checkmark state in case it was stale.
|
||||
// Already in sync — settle the icon to its checkmark state in case it was stale, and
|
||||
// clear any stale failure (there's nothing left that could be wedged).
|
||||
nvrsionPendingByID[projectID] = false
|
||||
nvrsionIntegrateFailureByID[projectID] = nil
|
||||
reconsiderNvrsionAutoIntegration(projectID)
|
||||
lastError = "Nothing on the nvrsion trunk to promote to \(base)."
|
||||
return false
|
||||
case .conflicted(let files):
|
||||
lastError = "Promoting the nvrsion trunk conflicted in \(Self.lockPathList(files)). "
|
||||
// Can't integrate automatically — flag the project so the sidebar shows a red ✗ and the
|
||||
// auto-integrate countdown stands down until a manual retry resolves it.
|
||||
let reason = "Promoting the nvrsion trunk conflicted in \(Self.lockPathList(files)). "
|
||||
+ "Resolve on \(base) and retry."
|
||||
lastError = reason
|
||||
nvrsionIntegrateFailureByID[projectID] = reason
|
||||
reconsiderNvrsionAutoIntegration(projectID)
|
||||
return false
|
||||
case .failed(let why):
|
||||
lastError = "nvrsion promote failed: \(why)"
|
||||
let reason = "nvrsion promote failed: \(why)"
|
||||
lastError = reason
|
||||
nvrsionIntegrateFailureByID[projectID] = reason
|
||||
reconsiderNvrsionAutoIntegration(projectID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2308,8 +2342,73 @@ public final class AppStore: ConflictArbiter {
|
||||
public func refreshNvrsionPending(_ projectID: ProjectID) async {
|
||||
guard let project = projectsByID[projectID], project.nvrsionActive, let nvrsion = project.nvrsion
|
||||
else { return }
|
||||
nvrsionPendingByID[projectID] = await nvrsionTrunk.hasPendingWork(
|
||||
let pending = await nvrsionTrunk.hasPendingWork(
|
||||
root: project.rootPath, trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
|
||||
nvrsionPendingByID[projectID] = pending
|
||||
// A trunk that's recomputed to nothing-pending can't be wedged — drop any stale failure so
|
||||
// the sidebar settles back to its checkmark rather than a lingering red ✗.
|
||||
if !pending { nvrsionIntegrateFailureByID[projectID] = nil }
|
||||
reconsiderNvrsionAutoIntegration(projectID)
|
||||
}
|
||||
|
||||
/// The reason `projectID`'s last integration attempt couldn't complete — a conflict or hard
|
||||
/// failure — or nil when it's clean. Drives the sidebar's red-✗ Integrate state and its tooltip.
|
||||
public func nvrsionIntegrationFailure(_ projectID: ProjectID) -> String? {
|
||||
nvrsionIntegrateFailureByID[projectID]
|
||||
}
|
||||
|
||||
/// Whether `projectID`'s nvrsion trunk can be integrated *right now*: it's an nvrsion project with
|
||||
/// work waiting on the trunk, no chat in it is mid-turn, and it isn't being relocated. This is the
|
||||
/// same gate the sidebar's Integrate button enforces (minus the view-local promoting spinner), and
|
||||
/// the precondition the auto-integrate countdown watches.
|
||||
public func nvrsionIntegrationAvailable(_ projectID: ProjectID) -> Bool {
|
||||
guard let project = projectsByID[projectID], project.nvrsionActive else { return false }
|
||||
guard moveProgress(of: projectID) == nil else { return false } // not mid-relocation
|
||||
guard nvrsionHasPendingIntegration(projectID) else { return false } // there's work to ship
|
||||
return !isAnySessionRunning(in: projectID) // and no chat is working
|
||||
}
|
||||
|
||||
/// Re-evaluate `projectID`'s auto-integrate countdown after anything that could change its
|
||||
/// availability (work landed, a turn ended, a move began, a promote finished). If it's now
|
||||
/// integratable and no countdown is already running, arm one; if it's no longer integratable,
|
||||
/// cancel any in-flight countdown. Idempotent and cheap — safe to call on every UI event.
|
||||
private func reconsiderNvrsionAutoIntegration(_ projectID: ProjectID) {
|
||||
guard nvrsionIntegrationAvailable(projectID) else {
|
||||
// Availability lapsed — drop any pending countdown so it can't fire against a project
|
||||
// that's gone busy (or has already been promoted).
|
||||
nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
|
||||
return
|
||||
}
|
||||
// A flagged conflict/failure needs a human: stand the countdown down rather than auto-retry a
|
||||
// promote that just failed. A manual press (which clears the flag on success) is the way out.
|
||||
guard nvrsionIntegrateFailureByID[projectID] == nil else {
|
||||
nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
|
||||
return
|
||||
}
|
||||
// Already counting down → let the in-flight timer run rather than restarting it, so a burst
|
||||
// of events while the project stays quiet doesn't keep pushing the deadline out.
|
||||
guard nvrsionAutoIntegrateTasks[projectID] == nil else { return }
|
||||
nvrsionAutoIntegrateTasks[projectID] = Task { [weak self] in
|
||||
let delay = self?.nvrsionAutoIntegrateDelay ?? .seconds(3)
|
||||
try? await Task.sleep(for: delay)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await self.fireNvrsionAutoIntegration(projectID)
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-integrate countdown elapsed: promote the trunk if it's *still* integratable. Re-checks
|
||||
/// on the MainActor at the firing instant because the window may have closed during the wait (a
|
||||
/// chat picked up a turn, a move started, or a manual promote already shipped the trunk). Internal
|
||||
/// so tests can drive it without waiting out the real delay.
|
||||
func fireNvrsionAutoIntegration(_ projectID: ProjectID) async {
|
||||
nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
|
||||
guard nvrsionIntegrationAvailable(projectID) else { return }
|
||||
await promoteNvrsionTrunk(projectID)
|
||||
}
|
||||
|
||||
/// Whether an auto-integrate countdown is currently armed for `projectID` (test hook).
|
||||
func nvrsionAutoIntegrationArmed(_ projectID: ProjectID) -> Bool {
|
||||
nvrsionAutoIntegrateTasks[projectID] != nil
|
||||
}
|
||||
|
||||
private func reconcileLocks() async {
|
||||
@@ -3492,6 +3591,10 @@ public final class AppStore: ConflictArbiter {
|
||||
if case .runFinished = event.kind { maybeRenameSession(sessionID, controller) }
|
||||
let snapshot = await controller.snapshot
|
||||
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
|
||||
// This event may have flipped whether any chat in the project is mid-turn — the gate the
|
||||
// auto-integrate countdown waits on. Re-evaluate it: a turn ending in the last working chat
|
||||
// arms the countdown; a turn starting cancels it.
|
||||
reconsiderNvrsionAutoIntegration(snapshot.session.projectID)
|
||||
// A finished run (or a fresh turn) flips whether the chat is completed; refresh the
|
||||
// unread dot now. Disposition-classified completion is handled in classifyDisposition.
|
||||
await reconcileUnseenCompletion(sessionID, snapshot)
|
||||
|
||||
@@ -304,6 +304,93 @@ struct AppStoreTests {
|
||||
#expect(store.lastError?.contains("Nothing on the nvrsion trunk") == true)
|
||||
}
|
||||
|
||||
@Test func nvrsionTrunkAutoIntegratesOnceTheProjectFallsQuiet() async throws {
|
||||
let repo = try await GitTestRepo(controlled: true)
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
let project = await enableNvrsion(store, created)
|
||||
|
||||
// A scripted session lands made.txt on the trunk and then finishes its turn.
|
||||
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
|
||||
store.openSessionID = session
|
||||
await store.awaitOpenSessionSettled()
|
||||
|
||||
// There's work to integrate and no chat is mid-turn → integration is available, so the
|
||||
// auto-integrate countdown is armed (we don't wait out the real 3 s — we fire it directly).
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
|
||||
#expect(store.nvrsionIntegrationAvailable(project.id) == true)
|
||||
#expect(store.nvrsionAutoIntegrationArmed(project.id) == true)
|
||||
|
||||
// The countdown elapses → it promotes on its own, exactly as the manual button would.
|
||||
let mainBefore = try await repo.revParse("refs/heads/main")
|
||||
await store.fireNvrsionAutoIntegration(project.id)
|
||||
#expect(try await repo.revParse("refs/heads/main") != mainBefore)
|
||||
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
|
||||
// Promoted → nothing left to integrate and the countdown is stood down.
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
|
||||
#expect(store.nvrsionIntegrationAvailable(project.id) == false)
|
||||
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
|
||||
}
|
||||
|
||||
@Test func nvrsionAutoIntegrateFlagsAConflictAndStandsDown() async throws {
|
||||
let repo = try await GitTestRepo(controlled: true)
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
let project = await enableNvrsion(store, created)
|
||||
|
||||
// A scripted session lands made.txt="by agent\n" on the trunk and finishes.
|
||||
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
|
||||
store.openSessionID = session
|
||||
await store.awaitOpenSessionSettled()
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
|
||||
|
||||
// Meanwhile the real branch grows a *different* made.txt — so squashing the trunk into main
|
||||
// will conflict add/add.
|
||||
try repo.write("made.txt", "hand edit\n")
|
||||
try await repo.run(["add", "made.txt"])
|
||||
try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "hand edit"])
|
||||
let mainConflicted = try await repo.revParse("refs/heads/main")
|
||||
|
||||
// The countdown fires → the promote conflicts. Integration couldn't run automatically, so the
|
||||
// project is flagged (sidebar shows a red ✗), main is untouched, and the work still pends.
|
||||
await store.fireNvrsionAutoIntegration(project.id)
|
||||
#expect(store.nvrsionIntegrationFailure(project.id)?.contains("conflicted") == true)
|
||||
#expect(try await repo.revParse("refs/heads/main") == mainConflicted) // promote rolled back
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == true)
|
||||
|
||||
// While flagged the countdown stands down — it must not auto-retry a wedged promote.
|
||||
await store.refreshNvrsionPending(project.id)
|
||||
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
|
||||
|
||||
// Resolve the conflict on the base branch (drop the hand edit), then a manual retry succeeds
|
||||
// and clears the flag — the sidebar drops the red ✗.
|
||||
try await repo.run(["reset", "--hard", "HEAD~1"])
|
||||
#expect(await store.promoteNvrsionTrunk(project.id) == true)
|
||||
#expect(store.nvrsionIntegrationFailure(project.id) == nil)
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
|
||||
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
|
||||
}
|
||||
|
||||
@Test func nvrsionAutoIntegrateStaysDownWithNothingToIntegrate() async throws {
|
||||
let repo = try await GitTestRepo(controlled: true)
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
let project = await enableNvrsion(store, created)
|
||||
|
||||
// A quiet project with nothing landed on its trunk is not integratable, so the countdown
|
||||
// never arms and firing it is a harmless no-op (the gate the running-chat case also relies on).
|
||||
#expect(store.nvrsionHasPendingIntegration(project.id) == false)
|
||||
#expect(store.nvrsionIntegrationAvailable(project.id) == false)
|
||||
#expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
|
||||
|
||||
let mainBefore = try await repo.revParse("refs/heads/main")
|
||||
await store.fireNvrsionAutoIntegration(project.id)
|
||||
#expect(try await repo.revParse("refs/heads/main") == mainBefore)
|
||||
}
|
||||
|
||||
@Test func nvrsionPrelandHookRejectsAnEditFromLanding() async throws {
|
||||
let repo = try await GitTestRepo(controlled: true)
|
||||
defer { repo.cleanup() }
|
||||
|
||||
Generated
+2913
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface Env {
|
||||
// --- Phase A: DAU ---
|
||||
DAU: AnalyticsEngineDataset;
|
||||
NUCLEIC_DAU: AnalyticsEngineDataset;
|
||||
|
||||
// --- Phase B: relay ---
|
||||
ROOMS: DurableObjectNamespace;
|
||||
|
||||
@@ -58,7 +58,7 @@ async function handleHeartbeat(request: Request, env: Env): Promise<Response> {
|
||||
}
|
||||
|
||||
function writeHeartbeat(env: Env, hb: Heartbeat): void {
|
||||
env.DAU.writeDataPoint({
|
||||
env.NUCLEIC_DAU.writeDataPoint({
|
||||
indexes: [hb.installId],
|
||||
blobs: [hb.platform, hb.osVersion, hb.channel, hb.appVersion, hb.build, hb.arch, hb.locale],
|
||||
doubles: [1],
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
// Create each with `wrangler kv namespace create <BINDING>` and paste the returned id.
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "NUCLEIC_RELAY_TOKENS",
|
||||
"binding": "RELAY_TOKENS",
|
||||
"id": "21bdc123f69d4215b09c251d585bc49d"
|
||||
},
|
||||
{
|
||||
"binding": "NUCLEIC_PUSH_TOKENS",
|
||||
"binding": "PUSH_TOKENS",
|
||||
"id": "5cc442cba64c4cc8a3dbe660928edb47"
|
||||
}
|
||||
],
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ same payload shape; a `platform` dimension distinguishes them everywhere downstr
|
||||
not log/store the client IP**.
|
||||
- **Primary store: Workers Analytics Engine** dataset `nucleic_dau`. Write with the install ID
|
||||
as the **index** so unique counts are accurate:
|
||||
`env.DAU.writeDataPoint({ indexes:[installId], blobs:[platform,osVersion,channel,appVersion,arch,locale], doubles:[1] })`.
|
||||
`env.NUCLEIC_DAU.writeDataPoint({ indexes:[installId], blobs:[platform,osVersion,channel,appVersion,arch,locale], doubles:[1] })`.
|
||||
Query via the SQL API (DAU split by platform):
|
||||
`SELECT toStartOfDay(timestamp) d, blob1 platform, count(DISTINCT index1) dau FROM nucleic_dau WHERE timestamp > now() - INTERVAL '30' DAY GROUP BY d, platform`.
|
||||
AE auto-timestamps, retains ~90 days, requires no schema, and stores no IP — aligns with the
|
||||
|
||||
+4
-4
@@ -96,8 +96,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -231,8 +231,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f",
|
||||
"version" : "1.38.0"
|
||||
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
|
||||
"version" : "1.38.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ struct StartChatComposer: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
||||
.keyboardDismissable()
|
||||
Button {
|
||||
if let project = selected {
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
|
||||
@@ -92,6 +93,7 @@ struct QuickTodos: View {
|
||||
TextField("Capture an idea…", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(capture)
|
||||
.keyboardDismissable()
|
||||
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct HomeView: View {
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable { store.refreshSessions() }
|
||||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct ProjectDetailView: View {
|
||||
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder).lineLimit(1...4)
|
||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
|
||||
.keyboardDismissable()
|
||||
Button {
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort)
|
||||
draft = ""
|
||||
@@ -91,6 +92,7 @@ struct ProjectDetailView: View {
|
||||
}
|
||||
.navigationTitle(project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onChange(of: model) { _, newModel in
|
||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
||||
|
||||
@@ -193,6 +193,7 @@ struct SessionDetailView: View {
|
||||
text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.keyboardDismissable()
|
||||
Button {
|
||||
store.sendInput(draft, to: sessionID)
|
||||
draft = ""
|
||||
@@ -228,6 +229,9 @@ struct TranscriptList: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
||||
// be put away without leaving the session.
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
||||
// so every streamed delta keeps the view pinned to the bottom.
|
||||
.onChange(of: events.count) {
|
||||
|
||||
@@ -103,6 +103,26 @@ extension Risk {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -120,6 +140,10 @@ 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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TodosView: View {
|
||||
TextField("Capture an idea…", text: $draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(capture)
|
||||
.keyboardDismissable()
|
||||
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||
}
|
||||
@@ -40,6 +41,7 @@ struct TodosView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("To-dos")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable { store.refreshSessions() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. Collapsed it
|
||||
/// shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
|
||||
/// input, the result, and any files the call touched. The old remote rendered `started` and
|
||||
/// `completed` as two separate one-line rows and dropped results/file-changes entirely.
|
||||
struct ToolGroupRow: View {
|
||||
/// One tool call as a collapsible card — the mobile echo of the Mac's `ToolCallRow`. Collapsed
|
||||
/// it shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
|
||||
/// input, the result, and any files the call touched. When `inGroup` is set the card renders
|
||||
/// flush (no background/border) because an enclosing `ToolBlockCard` supplies the single
|
||||
/// contiguous container — exactly as the Mac groups a run of calls.
|
||||
struct ToolCallCard: View {
|
||||
let group: ToolGroup
|
||||
/// Renders flush inside a `ToolBlockCard` (the block draws the one card) when true.
|
||||
var inGroup: Bool = false
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
let content = VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
|
||||
if expanded { details }
|
||||
}
|
||||
if inGroup {
|
||||
content.padding(.horizontal, 10).padding(.vertical, 8)
|
||||
} else {
|
||||
content
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(
|
||||
group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
@@ -55,44 +63,355 @@ struct ToolGroupRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's
|
||||
/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool.
|
||||
struct OrchestrationCard: View {
|
||||
let group: ToolGroup
|
||||
/// A run of two or more consecutive tool calls rendered as ONE contiguous card — the mobile echo
|
||||
/// of the Mac's `ToolGroupRow`. Collapsed it lists up to five one-line summaries (glyph + tool +
|
||||
/// input gist) with a call count and a "See more"; tapping the block reveals every call in full,
|
||||
/// each its own flush `ToolCallCard` with result and file changes.
|
||||
struct ToolBlockCard: View {
|
||||
let groups: [ToolGroup]
|
||||
@State private var expanded = false
|
||||
|
||||
private var subtitle: String {
|
||||
group.input["description"]?.stringValue
|
||||
?? group.input["prompt"]?.stringValue
|
||||
?? group.input.compactSummary
|
||||
/// A collapsed block shows at most this many summary lines before a "See more".
|
||||
private static let collapsedLineLimit = 5
|
||||
|
||||
private var allFinished: Bool { groups.allSatisfy(\.finished) }
|
||||
private var hiddenCount: Int { max(0, groups.count - Self.collapsedLineLimit) }
|
||||
private var shownGroups: ArraySlice<ToolGroup> {
|
||||
expanded ? groups[...] : groups.prefix(Self.collapsedLineLimit)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(Palette.orchestra)
|
||||
Text(group.finished ? "Orchestrated" : "Orchestrating")
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } }
|
||||
if expanded {
|
||||
ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false)
|
||||
if let result = group.result {
|
||||
ToolBlock(label: "Result", text: result.compactSummary, mono: false)
|
||||
ForEach(groups, id: \.toolCallID) { group in
|
||||
Divider().overlay(Color.primary.opacity(0.06))
|
||||
ToolCallCard(group: group, inGroup: true)
|
||||
}
|
||||
Divider().overlay(Color.primary.opacity(0.06))
|
||||
collapseFooter
|
||||
}
|
||||
}
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.05), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(shownGroups.enumerated()), id: \.element.toolCallID) { index, group in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ToolGlyph.icon(group.name))
|
||||
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
||||
.frame(width: 16)
|
||||
Text(group.name).font(.caption.weight(.semibold))
|
||||
Text(group.input.compactSummary)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if index == 0 { trailingControls }
|
||||
}
|
||||
}
|
||||
if !expanded, hiddenCount > 0 {
|
||||
Text("See \(hiddenCount) more…")
|
||||
.font(.caption.weight(.medium)).foregroundStyle(Palette.accent)
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var trailingControls: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("\(groups.count)")
|
||||
.font(.caption2.weight(.semibold).monospacedDigit()).foregroundStyle(.secondary)
|
||||
if !allFinished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var collapseFooter: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded = false }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.up").font(.caption2.weight(.semibold))
|
||||
Text("Collapse").font(.caption.weight(.medium))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(Palette.accent).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subagent & multi-agent cards
|
||||
//
|
||||
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated work in
|
||||
// the orchestra gold 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 from the projection's nested `children` (a subagent's inner Read/Bash/Edit/thinking),
|
||||
// mirroring the Mac at phone fidelity.
|
||||
|
||||
/// The lifecycle state of a spawned subagent, read from its `Task` group: not finished → still
|
||||
/// working; an error result → failed; otherwise → done.
|
||||
enum SubagentRunState {
|
||||
case running, done, failed
|
||||
|
||||
init(group: ToolGroup) {
|
||||
if !group.finished { self = .running }
|
||||
else if group.isError { self = .failed }
|
||||
else { self = .done }
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .running: "Working"
|
||||
case .done: "Done"
|
||||
case .failed: "Failed"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .running: Palette.orchestra
|
||||
case .done: Palette.success
|
||||
case .failed: Palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The human-facing bits of a `Task` tool call, so the subagent cards agree on what to show.
|
||||
enum Subagent {
|
||||
/// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
|
||||
static func type(_ group: ToolGroup) -> String? {
|
||||
let raw = group.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(_ group: ToolGroup) -> String {
|
||||
if let description = group.input["description"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
|
||||
return description
|
||||
}
|
||||
if let prompt = group.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(_ group: ToolGroup) -> String {
|
||||
let label = taskLabel(group)
|
||||
if let type = type(group) { return "\(type) — \(label)" }
|
||||
return label
|
||||
}
|
||||
|
||||
/// The subagent's returned report, or nil while it's still working / reported nothing.
|
||||
static func report(_ group: ToolGroup) -> String? {
|
||||
guard let result = group.result else { return nil }
|
||||
let text = result.compactSummary
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// A small leading status glyph for a subagent — a gold spinner while it works, a green check or
|
||||
/// red cross once it settles.
|
||||
struct SubagentStatusGlyph: View {
|
||||
let state: SubagentRunState
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .running: ProgressView().controlSize(.mini).tint(Palette.orchestra)
|
||||
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 a subagent card header.
|
||||
struct SubagentStatusChip: View {
|
||||
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)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(state.color.opacity(0.14), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a live
|
||||
/// Working / Done / Failed status, and — on expand — its nested activity stream (or, for a
|
||||
/// replayed transcript with none, its returned report). Mirrors the Mac's `SubagentCard`.
|
||||
struct SubagentCard: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var state: SubagentRunState { SubagentRunState(group: group) }
|
||||
private var report: String? { Subagent.report(group) }
|
||||
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard hasDetail else { return }
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
if expanded, hasDetail {
|
||||
Divider().overlay(Palette.orchestra.opacity(0.2))
|
||||
SubagentActivityStream(children: group.children, fallbackReport: report)
|
||||
}
|
||||
}
|
||||
.background(Palette.orchestra.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
|
||||
(Text("Subagent")
|
||||
+ (Subagent.type(group).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: state)
|
||||
if hasDetail {
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(Subagent.taskLabel(group))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.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
|
||||
/// header sums up the wave (count + overall status); each subagent is its own row that expands
|
||||
/// independently to reveal its activity stream and report. Mirrors the Mac's `OrchestrationCard`.
|
||||
struct OrchestrationCard: View {
|
||||
let groups: [ToolGroup]
|
||||
|
||||
/// Overall wave status: failed if any failed, else running if any is still working, else done.
|
||||
private var overall: SubagentRunState {
|
||||
let states = groups.map { SubagentRunState(group: $0) }
|
||||
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 { groups.filter(\.finished).count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
ForEach(groups, id: \.toolCallID) { group in
|
||||
Divider().overlay(Palette.orchestra.opacity(0.18))
|
||||
OrchestrationAgentRow(group: group)
|
||||
}
|
||||
}
|
||||
.background(Palette.orchestra.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "rectangle.3.group.fill")
|
||||
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Multi-agent fan-out").font(.callout.weight(.semibold))
|
||||
Text("\(groups.count) subagents · \(doneCount) done")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: overall)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// One subagent within a fan-out: a one-line status header that expands on tap to reveal that
|
||||
/// agent's own activity stream — and, failing that, its returned report. Each row tracks its own
|
||||
/// expansion so agents open independently.
|
||||
private struct OrchestrationAgentRow: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var state: SubagentRunState { SubagentRunState(group: group) }
|
||||
private var report: String? { Subagent.report(group) }
|
||||
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard hasDetail else { return }
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
if expanded, hasDetail {
|
||||
SubagentActivityStream(children: group.children, fallbackReport: report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
||||
SubagentStatusGlyph(state: state)
|
||||
Text(Subagent.headline(group))
|
||||
.font(.caption).lineLimit(1).truncationMode(.tail)
|
||||
Spacer(minLength: 6)
|
||||
if hasDetail {
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with the
|
||||
/// same row views the main transcript uses, recursing into nested subagents' own cards. Falls
|
||||
/// back to the returned report when there's no captured inner activity (a replayed transcript).
|
||||
private struct SubagentActivityStream: View {
|
||||
let children: [TranscriptItem]
|
||||
var fallbackReport: String? = nil
|
||||
|
||||
var body: some View {
|
||||
if !children.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(children) { child in
|
||||
TranscriptRow(item: child)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if let fallbackReport {
|
||||
Text(fallbackReport)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +444,7 @@ enum ToolGlyph {
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
|
||||
case "Grep", "Glob", "Search": return "magnifyingglass"
|
||||
case "WebFetch", "WebSearch": return "globe"
|
||||
case "Task", "Agent": return "sparkles"
|
||||
case "Task", "Agent": return "person.2"
|
||||
case "TodoWrite": return "checklist"
|
||||
case "AskUserQuestion": return "questionmark.bubble"
|
||||
default: return "wrench.and.screwdriver"
|
||||
|
||||
@@ -4,8 +4,10 @@ import NucleicProtocol
|
||||
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
|
||||
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
|
||||
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
|
||||
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
|
||||
/// `TranscriptProjection` at phone fidelity.)
|
||||
/// per message, one card per tool call, a single contiguous block for a run of consecutive
|
||||
/// calls, and a subagent spawn carrying its own nested activity — so the view layer stays dumb.
|
||||
/// (Mirrors the desktop's `TranscriptProjection` at phone fidelity, including its event
|
||||
/// partitioning, tool-run coalescing, and subagent nesting.)
|
||||
struct TranscriptItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
/// The seq of the first event that created this item — stable scroll anchor + ordering.
|
||||
@@ -17,9 +19,14 @@ struct TranscriptItem: Identifiable, Equatable {
|
||||
enum Kind: Equatable {
|
||||
case message(role: Role, text: String)
|
||||
case thinking(text: String)
|
||||
/// A single tool call. A subagent spawn (`Task`/`Agent`) carries its inner activity in
|
||||
/// `group.children` and renders as the gold subagent card; every other tool is a plain
|
||||
/// collapsible card.
|
||||
case tool(ToolGroup)
|
||||
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
|
||||
case orchestration(ToolGroup)
|
||||
/// A run of ≥2 consecutive tool calls coalesced into one contiguous block — the phone
|
||||
/// echo of the Mac's `.toolGroup`. A run made entirely of subagent spawns is a
|
||||
/// multi-agent fan-out (the gold orchestration card); any other run is a tool block.
|
||||
case toolBlock([ToolGroup])
|
||||
case sessionStarted(model: String, cwd: String)
|
||||
case usage(Usage)
|
||||
case rateLimit(RateLimit)
|
||||
@@ -30,6 +37,17 @@ struct TranscriptItem: Identifiable, Equatable {
|
||||
case note(text: String, icon: String?, lockEvent: Bool)
|
||||
case raw(type: String, body: String)
|
||||
}
|
||||
|
||||
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
|
||||
/// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside
|
||||
/// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into
|
||||
/// two cards with a gap. Mirrors the desktop projection's `rendersNothing`.
|
||||
var rendersNothing: Bool {
|
||||
if case .thinking(let text) = kind {
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
||||
@@ -42,6 +60,10 @@ struct ToolGroup: Equatable {
|
||||
var finished: Bool = false
|
||||
/// Paths the call touched (from `fileChange` events tagged with this tool call).
|
||||
var fileChanges: [FilePatch] = []
|
||||
/// For a subagent spawn (`Task`/`Agent`): the inner activity it produced — its own tool
|
||||
/// calls, thinking, and prose — projected the same way and nested here, so the card can show
|
||||
/// its live Read/Bash/Edit stream. Empty for every ordinary (non-subagent) tool.
|
||||
var children: [TranscriptItem] = []
|
||||
|
||||
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
|
||||
|
||||
@@ -51,9 +73,167 @@ struct ToolGroup: Equatable {
|
||||
}
|
||||
|
||||
enum TranscriptProjection {
|
||||
/// How deep subagent nesting is followed before deeper descendants are left unattached — a
|
||||
/// safety bound on the recursion (a subagent spawning a subagent spawning a subagent …), far
|
||||
/// past any real fan-out depth. Mirrors the desktop projection.
|
||||
private static let maxSubagentDepth = 4
|
||||
|
||||
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
|
||||
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
|
||||
///
|
||||
/// Work that happens *inside* a spawned subagent arrives in this same stream tagged with the
|
||||
/// parent `Task`'s id (`parentToolCallID`). We partition events by which subagent (if any)
|
||||
/// owns them, project the main agent's own events at the top level, and recursively project
|
||||
/// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
|
||||
/// under its card instead of leaking (and interleaving) into the main transcript.
|
||||
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
||||
let (topLevel, byParent) = partition(events)
|
||||
return project(topLevel, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
|
||||
// MARK: - Subagent partitioning
|
||||
|
||||
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
|
||||
/// the spawning `Task`'s id.
|
||||
private static func partition(_ events: [AgentEvent])
|
||||
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
|
||||
{
|
||||
let parentOf = toolParentMap(events)
|
||||
var topLevel: [AgentEvent] = []
|
||||
var byParent: [String: [AgentEvent]] = [:]
|
||||
for event in events {
|
||||
if let owner = subagentOwner(of: event, parentOf: parentOf) {
|
||||
byParent[owner, default: []].append(event)
|
||||
} else {
|
||||
topLevel.append(event)
|
||||
}
|
||||
}
|
||||
return (topLevel, byParent)
|
||||
}
|
||||
|
||||
/// Maps each tool-call id to its parent subagent's id, built from the call start/complete
|
||||
/// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
|
||||
/// scope from the call it belongs to via this map.
|
||||
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
for event in events {
|
||||
switch event.kind {
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
if let parent = call.parentToolCallID { map[call.toolCallID] = parent }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// The id of the subagent whose scope this event belongs to, or nil for the main agent's own
|
||||
/// turn. Tool calls and the subagent's prose/thinking carry the parent link directly; a tool
|
||||
/// result or file change inherits it from the call it references.
|
||||
private static func subagentOwner(of event: AgentEvent, parentOf: [String: String]) -> String? {
|
||||
switch event.kind {
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
return call.parentToolCallID
|
||||
case .toolResult(let result):
|
||||
return parentOf[result.toolCallID]
|
||||
case .fileChange(let change):
|
||||
return change.toolCallID.flatMap { parentOf[$0] }
|
||||
case .assistantText(let chunk), .thinking(let chunk):
|
||||
return chunk.parentToolCallID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects one scope's events into items, coalesces runs of tool calls, then nests each
|
||||
/// subagent spawn's own activity (drawn from `byParent`) as its `children`, recursing for
|
||||
/// subagents-within-subagents up to `maxSubagentDepth`.
|
||||
private static func project(
|
||||
_ events: [AgentEvent], byParent: [String: [AgentEvent]], depth: Int,
|
||||
showRaw: Bool, showLockEvents: Bool
|
||||
) -> [TranscriptItem] {
|
||||
let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
|
||||
guard depth < maxSubagentDepth else { return items }
|
||||
return items.map {
|
||||
attachSubagentChildren($0, byParent: byParent, depth: depth,
|
||||
showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
}
|
||||
|
||||
/// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out block — projects the
|
||||
/// events it owns into its `children`. Leaves every ordinary tool untouched.
|
||||
private static func attachSubagentChildren(
|
||||
_ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int,
|
||||
showRaw: Bool, showLockEvents: Bool
|
||||
) -> TranscriptItem {
|
||||
func childrenFor(_ group: ToolGroup) -> ToolGroup {
|
||||
guard group.isOrchestration else { return group }
|
||||
var g = group
|
||||
g.children = project(byParent[group.toolCallID] ?? [], byParent: byParent, depth: depth + 1,
|
||||
showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
return g
|
||||
}
|
||||
switch item.kind {
|
||||
case .tool(let group):
|
||||
guard group.isOrchestration else { return item }
|
||||
return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(childrenFor(group)))
|
||||
case .toolBlock(let groups):
|
||||
return TranscriptItem(id: item.id, seq: item.seq, kind: .toolBlock(groups.map(childrenFor)))
|
||||
default:
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-run coalescing
|
||||
|
||||
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
|
||||
///
|
||||
/// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a
|
||||
/// run: an invisible row landing between two tool calls must not split them into two cards
|
||||
/// with a gap. Such items are held aside and re-emitted right after the block, so they still
|
||||
/// render in place while the calls stay one contiguous block. Mirrors the desktop projection.
|
||||
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
|
||||
var out: [TranscriptItem] = []
|
||||
var run: [TranscriptItem] = [] // consecutive `.tool` items
|
||||
var held: [TranscriptItem] = [] // non-rendering rows seen mid-run, held so they don't split it
|
||||
|
||||
func flush() {
|
||||
if run.count == 1 {
|
||||
out.append(run[0])
|
||||
} else if run.count >= 2 {
|
||||
let groups = run.compactMap { item -> ToolGroup? in
|
||||
if case .tool(let g) = item.kind { return g } else { return nil }
|
||||
}
|
||||
out.append(TranscriptItem(id: "toolblock-\(groups[0].toolCallID)",
|
||||
seq: run[0].seq, kind: .toolBlock(groups)))
|
||||
}
|
||||
run.removeAll(keepingCapacity: true)
|
||||
out.append(contentsOf: held)
|
||||
held.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for item in flat {
|
||||
if case .tool = item.kind {
|
||||
run.append(item)
|
||||
} else if item.rendersNothing, !run.isEmpty {
|
||||
held.append(item)
|
||||
} else {
|
||||
flush()
|
||||
out.append(item)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Flat projection (one scope)
|
||||
|
||||
/// Fold one scope's raw events into interleaved, display-ready rows: streaming assistant /
|
||||
/// thinking deltas accumulate into one growing row, and a tool call shows the moment it starts
|
||||
/// (`toolCallStarted`), with its full input/result/file-changes filled in as they arrive.
|
||||
private static func flatItems(
|
||||
_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool
|
||||
) -> [TranscriptItem] {
|
||||
var items: [TranscriptItem] = []
|
||||
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
|
||||
var thinkingIndex: [String: Int] = [:]
|
||||
@@ -145,11 +325,11 @@ enum TranscriptProjection {
|
||||
group.name = call.name
|
||||
if !call.input.isEmptyValue { group.input = call.input }
|
||||
group.finished = group.finished || finished
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
} else {
|
||||
index[call.toolCallID] = items.count
|
||||
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: .tool(group)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +340,7 @@ enum TranscriptProjection {
|
||||
group.result = result.content
|
||||
group.isError = result.isError
|
||||
group.finished = true
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
}
|
||||
|
||||
private static func attachFileChange(
|
||||
@@ -168,22 +348,16 @@ enum TranscriptProjection {
|
||||
) {
|
||||
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
|
||||
group.fileChanges.append(.init(path: change.path, change: change.kind))
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
}
|
||||
// Untagged file changes are folded into the diff stat, not the transcript.
|
||||
}
|
||||
|
||||
/// Extract a tool group from either the plain or orchestration kind.
|
||||
/// Extract a tool group from a `.tool` item (the only kind `flatItems` produces for a call;
|
||||
/// blocks/children are formed later, after coalescing).
|
||||
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
|
||||
switch item.kind {
|
||||
case .tool(let g), .orchestration(let g): return g
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a group in the right kind — orchestration spawns get the gold card.
|
||||
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
|
||||
group.isOrchestration ? .orchestration(group) : .tool(group)
|
||||
if case .tool(let g) = item.kind { return g }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,21 @@ struct TranscriptRow: View {
|
||||
case .thinking(let text):
|
||||
ThinkingRow(text: text)
|
||||
case .tool(let group):
|
||||
ToolGroupRow(group: group)
|
||||
case .orchestration(let group):
|
||||
OrchestrationCard(group: group)
|
||||
// A lone subagent spawn reads as its own gold subagent card (type, task, status, and
|
||||
// its nested activity on expand); every other tool is a plain collapsible card.
|
||||
if group.isOrchestration {
|
||||
SubagentCard(group: group)
|
||||
} else {
|
||||
ToolCallCard(group: group)
|
||||
}
|
||||
case .toolBlock(let groups):
|
||||
// A run made entirely of subagent spawns is a multi-agent fan-out — the gold
|
||||
// orchestration card; any other run is one contiguous tool block.
|
||||
if groups.allSatisfy(\.isOrchestration) {
|
||||
OrchestrationCard(groups: groups)
|
||||
} else {
|
||||
ToolBlockCard(groups: groups)
|
||||
}
|
||||
case .sessionStarted(let model, let cwd):
|
||||
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
|
||||
case .usage(let usage):
|
||||
|
||||
+50
-8
@@ -28,8 +28,11 @@
|
||||
# NUCLEIC_SIGN_ID codesign identity (default: "-", ad-hoc). Set to a Developer ID
|
||||
# to sign for distribution (notarization/TestFlight is separate).
|
||||
#
|
||||
# An app icon is picked up automatically if present at Resources/AppIcon.icns
|
||||
# (or per-channel Resources/AppIcon-<channel>.icns).
|
||||
# An app icon is picked up automatically. Two formats, in priority order:
|
||||
# • Resources/AppIcon.icon — Icon Composer package (macOS 26 Liquid Glass). Carries the
|
||||
# themed appearances (default/dark/clear/tinted); actool compiles it to Assets.car.
|
||||
# • Resources/AppIcon.icns — legacy flat icon, no themed variants.
|
||||
# Per-channel overrides (AppIcon-<channel>.{icon,icns}) take precedence over the generic names.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -134,16 +137,55 @@ else
|
||||
echo " • kernel not bundled — it downloads automatically at runtime (optional: scripts/fetch-kernel.sh)"
|
||||
fi
|
||||
|
||||
# Optional icon.
|
||||
# Optional icon. Prefer a themed Icon Composer package (.icon → Assets.car via actool);
|
||||
# fall back to a flat .icns. Per-channel files win over the generic names.
|
||||
ICON_PLIST=""
|
||||
ICON_SRC=""
|
||||
[ -f "$ROOT/Resources/AppIcon-$CHANNEL.icns" ] && ICON_SRC="$ROOT/Resources/AppIcon-$CHANNEL.icns"
|
||||
[ -z "$ICON_SRC" ] && [ -f "$ROOT/Resources/AppIcon.icns" ] && ICON_SRC="$ROOT/Resources/AppIcon.icns"
|
||||
if [ -n "$ICON_SRC" ]; then
|
||||
cp "$ICON_SRC" "$CONTENTS/Resources/AppIcon.icns"
|
||||
ICON_SET="" # Resources/AppIcon[-<channel>].icon (Icon Composer, themed)
|
||||
[ -d "$ROOT/Resources/AppIcon-$CHANNEL.icon" ] && ICON_SET="$ROOT/Resources/AppIcon-$CHANNEL.icon"
|
||||
[ -z "$ICON_SET" ] && [ -d "$ROOT/Resources/AppIcon.icon" ] && ICON_SET="$ROOT/Resources/AppIcon.icon"
|
||||
ICON_ICNS="" # Resources/AppIcon[-<channel>].icns (legacy flat fallback)
|
||||
[ -f "$ROOT/Resources/AppIcon-$CHANNEL.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon-$CHANNEL.icns"
|
||||
[ -z "$ICON_ICNS" ] && [ -f "$ROOT/Resources/AppIcon.icns" ] && ICON_ICNS="$ROOT/Resources/AppIcon.icns"
|
||||
|
||||
if [ -n "$ICON_SET" ]; then
|
||||
# actool consumes the .icon directly (no .xcassets wrapper) and emits Assets.car into
|
||||
# Contents/Resources. --app-icon names the icon; CFBundleIconName resolves that name
|
||||
# against the compiled catalog at launch and is what enables the themed appearances.
|
||||
# actool's partial plist is unusable on macOS (it omits CFBundleIconName), so we discard
|
||||
# it and write the key ourselves below. ICON_NAME = the .icon basename (matches per-channel).
|
||||
ICON_NAME="$(basename "$ICON_SET" .icon)"
|
||||
echo " • compiling themed app icon: ${ICON_SET#"$ROOT/"} (CFBundleIconName=$ICON_NAME)"
|
||||
ACTOOL_PLIST="$(mktemp -t actool-partial-plist)"
|
||||
xcrun actool "$ICON_SET" \
|
||||
--compile "$CONTENTS/Resources" \
|
||||
--app-icon "$ICON_NAME" \
|
||||
--include-all-app-icons \
|
||||
--output-partial-info-plist "$ACTOOL_PLIST" \
|
||||
--enable-on-demand-resources NO \
|
||||
--development-region en \
|
||||
--target-device mac \
|
||||
--platform macosx \
|
||||
--minimum-deployment-target 26.0 \
|
||||
--output-format human-readable-text --notices --warnings --errors
|
||||
rm -f "$ACTOOL_PLIST"
|
||||
ICON_PLIST="
|
||||
<key>CFBundleIconName</key>
|
||||
<string>$ICON_NAME</string>"
|
||||
# Ship a flat .icns alongside if one exists — harmless belt-and-suspenders for contexts
|
||||
# that look for an icon file rather than the asset catalog.
|
||||
if [ -n "$ICON_ICNS" ]; then
|
||||
cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
|
||||
ICON_PLIST="$ICON_PLIST
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>"
|
||||
fi
|
||||
elif [ -n "$ICON_ICNS" ]; then
|
||||
cp "$ICON_ICNS" "$CONTENTS/Resources/AppIcon.icns"
|
||||
ICON_PLIST="
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>"
|
||||
else
|
||||
echo " • no app icon found (Resources/AppIcon.icon or AppIcon.icns) — using system default"
|
||||
fi
|
||||
|
||||
# Sparkle auto-update keys. Embedded only for the distribution channels (beta/rc/stable) and
|
||||
|
||||
Reference in New Issue
Block a user