From 860507f547b59a673ea35a0d929bf8b05613bff9 Mon Sep 17 00:00:00 2001
From: SculptTechProject <150788324+SculptTechProject@users.noreply.github.com>
Date: Sat, 12 Sep 2026 23:38:43 +0200
Subject: [PATCH] Add Build Watch, Quiet Focus, and panel customization
---
Info.plist | 4 +-
README.md | 48 ++-
Sources/Broschy/AgentsView.swift | 16 +-
Sources/Broschy/App.swift | 96 +++---
Sources/Broschy/BuildWatchController.swift | 336 +++++++++++++++++++++
Sources/Broschy/BuildWatchModels.swift | 168 +++++++++++
Sources/Broschy/BuildWatchView.swift | 259 ++++++++++++++++
Sources/Broschy/PanelActivity.swift | 34 +++
Sources/Broschy/PanelPreferences.swift | 160 ++++++++++
Sources/Broschy/PanelSettingsView.swift | 44 +++
Sources/Broschy/Views.swift | 96 +++---
Tests/BuildWatchTests.swift | 264 ++++++++++++++++
Tests/PanelActivityTests.swift | 40 +++
Tests/PanelPreferencesTests.swift | 136 +++++++++
docs/DEVELOPMENT.md | 28 +-
docs/assets/build-watch-dark.png | Bin 0 -> 32255 bytes
test.sh | 6 +
17 files changed, 1637 insertions(+), 98 deletions(-)
create mode 100644 Sources/Broschy/BuildWatchController.swift
create mode 100644 Sources/Broschy/BuildWatchModels.swift
create mode 100644 Sources/Broschy/BuildWatchView.swift
create mode 100644 Sources/Broschy/PanelActivity.swift
create mode 100644 Sources/Broschy/PanelPreferences.swift
create mode 100644 Sources/Broschy/PanelSettingsView.swift
create mode 100644 Tests/BuildWatchTests.swift
create mode 100644 Tests/PanelActivityTests.swift
create mode 100644 Tests/PanelPreferencesTests.swift
create mode 100644 docs/assets/build-watch-dark.png
diff --git a/Info.plist b/Info.plist
index ea1b6bd..b5bfff9 100644
--- a/Info.plist
+++ b/Info.plist
@@ -10,8 +10,8 @@
CFBundleDevelopmentRegionen
CFBundleLocalizationsen
CFBundlePackageTypeAPPL
- CFBundleShortVersionString0.7.1
- CFBundleVersion12
+ CFBundleShortVersionString0.8.0
+ CFBundleVersion13
LSMinimumSystemVersion14.0
LSUIElement
NSHighResolutionCapable
diff --git a/README.md b/README.md
index 8136780..9b20954 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Broschy
A little more life around your notch.
-Agents, focus, music, command results, and quick notes — one glance away.
+Agents, focus, music, builds, and quick notes — one glance away.
@@ -14,7 +14,7 @@ Agents, focus, music, command results, and quick notes — one glance away.
-Get started · Agents · Spotify · Terminal signals · Contribute
+Get started · Agents · Spotify · Build Watch · Settings · Contribute
Broschy is a small, native macOS companion that lives around your MacBook's notch. Hover to open it, take care of something, and get back to your work. A translucent compact strip keeps the essentials visible without opening a full window.
@@ -35,7 +35,7 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc
| **Agents** | Monitor local Codex, Claude Code, and OpenCode sessions. See who is working, waiting for you, or ready for review. |
| **Focus** | Keep one task in view. Start a 25, 50, or 90-minute session, pause it, and pick up where you left off. The timer accounts for sleep. |
| **Music** | See the current Spotify track and artwork. Play, pause, skip, seek, and adjust Spotify's volume. The compact view keeps the title, artist, elapsed time, and animated playback bars nearby. |
-| **Signals** | Run a build or test through the bundled CLI and see when it finishes. Success, failure, and cancellation appear in the panel. |
+| **Signals** | Watch GitHub Actions for a repository or pull request with Build Watch, or track local builds and tests through the bundled CLI in Commands. |
| **Later** | Capture a thought or your next step in a quick note that saves automatically. |
### Made for the edge of your screen
@@ -43,12 +43,13 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc
- **Liquid Glass, expanded and compact.** Native glass on macOS 26, with a standard macOS material on earlier versions.
- **Light and Dark, automatically.** Text, accents, and surfaces follow the system appearance, including Auto.
- **Motion with a purpose.** Smooth open/close transitions and small playback indicators; Reduce Motion and Reduce Transparency are respected.
-- **One place to look.** Agent attention comes first; active focus sessions, recent command results, and working agents also take priority over music in the compact view.
+- **Your layout.** Choose which tabs appear, including which modules can use the compact notch.
+- **Quiet Focus.** Keep a running focus session in view while responses and build results remain available in the panel. Active agent questions and permission requests can still interrupt.
- **Native and small.** SwiftUI, AppKit, a bundled CLI, and no third-party Swift dependencies. English throughout.
## Get started
-**Current version: 0.7.1.** Broschy is an early, source-first release. Build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The build produces an app for your Mac's architecture.
+**Current version: 0.8.0.** Broschy is an early, source-first release. Build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The build produces an app for your Mac's architecture.
```sh
git clone https://github.com/SculptTechProject/Broschy.git
@@ -66,11 +67,20 @@ The build script assembles `build/Broschy.app` and signs it locally with an ad h
| Open the panel | Hover over the notch or click the compact strip |
| Toggle the panel | **Control + Option + N**, or the menu bar icon |
| Keep it open | Click the pin |
+| Settings | The header gear, **Settings…** in the menu bar menu, or **Command + ,** |
| Hide it | **Esc** or the chevron in the panel |
| Quit Broschy | **⋯ → Quit Broschy**, the menu bar menu, or **Command + Q** while the panel has focus |
Hiding the panel keeps Broschy running. To reopen it after quitting, launch `Broschy.app`. Broschy has no Dock icon and does not automatically start at login. On a screen without a notch, it appears at the center of the top edge.
+## Settings
+
+Open **Settings…** to choose your visible tabs and turn **Quiet Focus** on or off. All five tabs are visible by default. Hiding a module also removes its compact indicator; its timer, playback, monitoring, or other background work continues. At least one tab stays visible, and hiding the selected tab moves the panel to a visible one.
+
+Quiet Focus is **off by default** and applies only while the countdown is running. It holds agent responses, reviewable errors, and command or workflow results out of the compact notch. Active agent questions and permission requests can still interrupt. Responses and errors remain in the full **Agents → Needs you** queue. Pausing or finishing the timer restores normal compact priority.
+
+These preferences affect Broschy's presentation. They do not change the macOS Focus mode or notifications from other apps. Resetting them restores the default layout and turns Quiet Focus off; connections, notes, timers, and history are retained.
+
## Agents

Sample sessions in Dark appearance.
@@ -93,9 +103,23 @@ The connection uses local Apple Events. **⋯ → Disconnect Spotify** stops mon
Playback bars indicate whether music is playing; they do not analyze audio or use the microphone. Spotify volume is separate from system volume. Browser playback, music search, and choosing a Spotify Connect device are not supported.
+## Build Watch
+
+
Sample workflow runs in Dark appearance.
+
+In **Signals → Build Watch**, connect one GitHub.com repository or pull request:
+
+1. Install [GitHub CLI](https://cli.github.com/) (`gh`) and sign in with `gh auth login` if needed.
+2. Enter `owner/repository`, a repository URL, or a pull request URL.
+3. Leave the branch blank to use the repository's default branch, or enter a branch name. A pull request follows its current head commit.
+
+Broschy uses your existing GitHub CLI access and checks up to ten recent workflow runs about every 30 seconds. Failed requests retry less often. A workflow observed active and then completed can show its result in the notch for 12 seconds; existing completed runs on first connection are silent, and repeated results are deduplicated. Runs that finish entirely between polls appear in the list but do not trigger a compact result. Compact visibility and Quiet Focus still apply.
+
+Open a workflow in GitHub to inspect it. Build Watch only reads status: it does not download workflow logs, rerun jobs, cancel workflows, or merge pull requests. GitHub Enterprise hosts and watching several targets at once are outside this version. Disconnect to stop polling.
+
## Terminal signals
-From the repository directory:
+The **Signals → Commands** subtab keeps the local CLI workflow. From the repository directory:
```sh
# Run a command and show its result in Broschy.
@@ -108,14 +132,16 @@ From the repository directory:
./build/Broschy.app/Contents/MacOS/broschy-cli notify --title "Ready to review" --status succeeded
```
-You can also use **Signals → Copy command** for an example with the correct path to your copy of the app. The CLI forwards the command's input/output and exit status. Arguments are executed directly; pass a shell explicitly if you need a pipeline.
+You can also use **Signals → Commands → Copy command** for an example with the correct path to your copy of the app. The CLI forwards the command's input/output and exit status. Arguments are executed directly; pass a shell explicitly if you need a pipeline.
-Broschy sees commands you run through its CLI and results you explicitly send. It does not inspect other terminal sessions, IDEs, or cloud CI services. Control + C records cancellation; a forced kill or shutdown can leave a command marked Running. Cancellation is forwarded to the direct child process.
+Commands shows work run through Broschy’s CLI and results you explicitly send. It does not inspect other terminal sessions or IDEs; GitHub workflow monitoring is handled separately by Build Watch. Control + C records cancellation; a forced kill or shutdown can leave a command marked Running. Cancellation is forwarded to the direct child process.
-## Your data stays on your Mac
+## Local storage and connections
The task, timer, note, and command history are stored locally. Broschy has no account, analytics, or cloud sync. Agent snapshots contain session and project identifiers, status, generic activity labels, and timestamps; prompts and tool contents are not saved. Integration setup retains local configuration backups. Command records contain a label, working directory, status, timestamps, and an exit code when available; command arguments and output are not saved. Album artwork is fetched over HTTPS from URLs returned by Spotify. No listening history or Spotify tokens are stored.
+Build Watch is opt-in and contacts GitHub.com through `gh`, using the credentials GitHub CLI already manages. Broschy does not request, extract, or save GitHub tokens. The selected target, enabled state, and a bounded set of completion identifiers are saved in local preferences; fetched workflow metadata stays in memory. Full CI logs are not downloaded or stored. Layout and Quiet Focus preferences are also stored locally.
+
For compatibility with earlier installations, the storage folder remains:
```text
@@ -130,11 +156,11 @@ Use **Open Broschy Data** from the menu bar to find it. The legacy bundle identi
## Development
```sh
-bash test.sh # Isolated core, agent bridge, installer, and mocked Spotify checks
+bash test.sh # Isolated core, agents, preferences, priority, and mocked integration checks
bash build.sh # Release app bundle, icon, and local signature
```
-Tests use temporary data and mocked Spotify responses. They do not control your player. UI appearance, physical notch placement, and macOS Automation prompts still need manual checks. See [development notes](docs/DEVELOPMENT.md), [design notes](DESIGN.md), and [contributing](CONTRIBUTING.md).
+Tests use isolated data and mocked Spotify and Build Watch responses. They cover saved layout preferences, Quiet Focus, and compact priority without controlling your player or changing GitHub workflows. UI appearance, physical notch placement, and macOS Automation prompts still need manual checks. See [development notes](docs/DEVELOPMENT.md), [design notes](DESIGN.md), and [contributing](CONTRIBUTING.md).
Multi-display setups, Stage Manager, full-screen apps, and an auto-hidden menu bar need broader device testing. These are the main areas where feedback is useful.
diff --git a/Sources/Broschy/AgentsView.swift b/Sources/Broschy/AgentsView.swift
index a6fb775..ef50305 100644
--- a/Sources/Broschy/AgentsView.swift
+++ b/Sources/Broschy/AgentsView.swift
@@ -111,9 +111,9 @@ struct AgentsView: View {
}
.padding(.bottom, 10)
.onAppear { setup.refresh() }
- .onChange(of: showsSetup) { _, presented in state.hasPresentedPopover = presented }
+ .onChange(of: showsSetup) { _, presented in state.hasAgentPopover = presented }
.onChange(of: state.expanded) { _, expanded in if !expanded { showsSetup = false } }
- .onDisappear { state.hasPresentedPopover = false }
+ .onDisappear { showsSetup = false; state.hasAgentPopover = false }
}
private var hasConnections: Bool { setup.states.values.contains { $0.installed } }
@@ -260,11 +260,12 @@ struct AgentConnectionsView: View {
struct CompactAgentsLeading: View {
@ObservedObject var monitor: AgentMonitor
+ let attention: [AgentSession]
let reduceMotion: Bool
let isVisible: Bool
var body: some View {
HStack(spacing: 7) {
- Image(systemName: monitor.needsYouCount > 0 ? "hand.raised.fill" : "circle.dotted")
+ Image(systemName: !attention.isEmpty ? "hand.raised.fill" : "circle.dotted")
.font(.system(size: 16, weight: .medium))
.symbolEffect(.pulse, options: .repeating, isActive: isVisible && !reduceMotion && monitor.hasCompactActivity)
VStack(alignment: .leading, spacing: 1) {
@@ -272,19 +273,20 @@ struct CompactAgentsLeading: View {
Text("\(monitor.workingCount) working").font(.system(size: 9)).foregroundStyle(Ink.muted)
}
}
- .foregroundStyle(monitor.needsYouCount > 0 ? Ink.primary : Ink.success)
+ .foregroundStyle(!attention.isEmpty ? Ink.primary : Ink.success)
.accessibilityHidden(true)
}
}
struct CompactAgentsTrailing: View {
@ObservedObject var monitor: AgentMonitor
+ let attention: [AgentSession]
var body: some View {
VStack(alignment: .leading, spacing: 1) {
- Text(monitor.needsYouCount > 0 ? "\(monitor.needsYouCount) need\(monitor.needsYouCount == 1 ? "s" : "") you" : "In progress")
+ Text(!attention.isEmpty ? "\(attention.count) need\(attention.count == 1 ? "s" : "") you" : "In progress")
.font(.system(size: 10, weight: .semibold))
- .foregroundStyle(monitor.needsYouCount > 0 ? Ink.primary : Ink.text)
- Text(monitor.attention.first?.title ?? monitor.visibleSessions.first(where: { $0.effectiveStatus() == .working })?.title ?? "Agents")
+ .foregroundStyle(!attention.isEmpty ? Ink.primary : Ink.text)
+ Text(attention.first?.title ?? monitor.visibleSessions.first(where: { $0.effectiveStatus() == .working })?.title ?? "Agents")
.font(.system(size: 9)).foregroundStyle(Ink.muted)
}
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 13)
diff --git a/Sources/Broschy/App.swift b/Sources/Broschy/App.swift
index 662c5e6..90c5d17 100644
--- a/Sources/Broschy/App.swift
+++ b/Sources/Broschy/App.swift
@@ -13,14 +13,18 @@ final class PanelState: ObservableObject {
@Published var reduceMotion = false
@Published var reduceTransparency = false
@Published var pinned = false
- @Published var hasPresentedPopover = false
+ @Published var hasAgentPopover = false
+ @Published var showsSettings = false
+ var hasPresentedPopover: Bool { hasAgentPopover || showsSettings }
@Published var tab = 0
+ @Published var signalPage = 0
@Published var notchWidth: CGFloat = 180
@Published var notchHeight: CGFloat = 32
@Published var panelWidth: CGFloat = 448
- @Published var showsCompactMusic = false
- @Published var showsCompactAgents = false
- var compactWidth: CGFloat { min(panelWidth, notchWidth + ((showsCompactMusic || showsCompactAgents) ? 264 : 128)) }
+ @Published var compactContent = CompactContent.idle
+ var showsCompactMusic: Bool { compactContent == .music }
+ var showsCompactAgents: Bool { compactContent == .agents }
+ var compactWidth: CGFloat { min(panelWidth, notchWidth + (compactContent.usesWideWings ? 264 : 128)) }
@Published var shortcutAvailable = false
@Published var selectedMinutes = 25
var open: (() -> Void)?
@@ -46,6 +50,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let state = PanelState()
let spotify = SpotifyController()
let agents = AgentMonitor()
+ let preferences = PanelPreferences()
+ let builds = BuildWatchController()
var panel: NotchPanel!
var statusItem: NSStatusItem!
var observers: [NSObjectProtocol] = []
@@ -54,8 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
var transitionWork: DispatchWorkItem?
var transitionGeneration: UInt = 0
var wantsExpanded = false
- var pendingCompactMusic = false
- var pendingCompactAgents = false
+ var pendingCompactContent = CompactContent.idle
var accessibilityObserver: NSObjectProtocol?
var hotKey: EventHotKeyRef?
var hotKeyHandler: EventHandlerRef?
@@ -86,7 +91,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} else {
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
}
- panel.contentView = InteractiveHostingView(rootView: NotchRootView(store: store, state: state, spotify: spotify, agents: agents))
+ panel.contentView = InteractiveHostingView(rootView: NotchRootView(store: store, state: state, spotify: spotify, agents: agents, preferences: preferences, builds: builds))
state.open = { [weak self] in self?.expand(focus: true) }
state.close = { [weak self] in self?.collapse() }
state.hover = { [weak self] inside in self?.hover(inside) }
@@ -94,28 +99,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
state.selectedMinutes = store.timerState.totalSeconds / 60
if CommandLine.arguments.contains("--music") { state.tab = 3 }
if CommandLine.arguments.contains("--agents") { state.tab = 4 }
+ state.tab = preferences.safeSelection(PanelTab(rawValue: state.tab)).rawValue
registerShortcut()
updateScreen()
panel.orderFrontRegardless()
- Publishers.CombineLatest4(spotify.$status, spotify.$snapshot, store.$timerState, store.$jobs)
- .combineLatest(agents.$sessions)
+ // Read the settled models on the next main-queue turn: objectWillChange
+ // fires before @Published has assigned its new value.
+ Publishers.MergeMany([spotify.objectWillChange.eraseToAnyPublisher(),
+ store.objectWillChange.eraseToAnyPublisher(),
+ agents.objectWillChange.eraseToAnyPublisher(),
+ preferences.objectWillChange.eraseToAnyPublisher(),
+ builds.objectWillChange.eraseToAnyPublisher()])
.receive(on: DispatchQueue.main)
- .sink { [weak self] values, sessions in
- guard let self else { return }
- let (status, track, timer, jobs) = values
- let recentResult = jobs.contains { job in
- guard let finished = job.finishedAt else { return false }
- return Date().timeIntervalSince(finished) < 12
- }
- let focusOrJob = timer.didFinish || (timer.hasStarted && timer.remainingSeconds > 0)
- || jobs.contains(where: { $0.status == .running }) || recentResult
- let visible = sessions.filter { $0.updatedAt > Date().addingTimeInterval(-86400) && $0.effectiveStatus() != .closed }
- let agentActivity = visible.contains(where: { $0.needsAttention() })
- || (!focusOrJob && visible.contains(where: { $0.effectiveStatus() == .working }))
- let music = !agentActivity && !focusOrJob && status == .connected && track?.title.isEmpty == false
- self.updateCompactActivity(music: music, agents: agentActivity)
- }
+ .sink { [weak self] in self?.refreshCompactActivity() }
.store(in: &subscriptions)
+ refreshCompactActivity()
accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.refreshAccessibilityOptions() }
@@ -244,6 +242,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func collapse() {
hoverWork?.cancel()
+ state.showsSettings = false
guard wantsExpanded else { return }
wantsExpanded = false
transitionWork?.cancel()
@@ -272,8 +271,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard !wantsExpanded, transitionGeneration == generation else { return }
transitionWork = nil
withoutAnimation {
- state.showsCompactMusic = pendingCompactMusic
- state.showsCompactAgents = pendingCompactAgents
+ state.compactContent = pendingCompactContent
state.canvasExpanded = false
}
// Ordering out releases AppKit/window-server focus correctly; doing this
@@ -283,16 +281,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
panel.orderFrontRegardless()
}
- func updateCompactActivity(music: Bool, agents: Bool) {
- pendingCompactMusic = music
- pendingCompactAgents = agents
+ func refreshCompactActivity() {
+ let safeTab = preferences.safeSelection(PanelTab(rawValue: state.tab)).rawValue
+ if state.tab != safeTab { state.tab = safeTab }
+ let now = Date()
+ let timer = store.timerState
+ let attention = AgentAttentionPolicy.compactAttention(from: agents.visibleSessions,
+ visibleTabs: preferences.visibleTabs, quietFocusEnabled: preferences.quietFocusEnabled,
+ focusActive: timer.isRunning, now: now)
+ let localActivity = store.jobs.contains { job in
+ job.status == .running || job.finishedAt.map { (0..<12).contains(now.timeIntervalSince($0)) } == true
+ }
+ let recentBuild = builds.isLive && builds.recentCompletion.map {
+ (0..<12).contains(now.timeIntervalSince($0.observedAt))
+ } == true
+ let content = CompactContent.choose(visibleTabs: preferences.visibleTabs,
+ quietFocusRunning: preferences.quietFocusEnabled && timer.isRunning,
+ agentAttention: !attention.isEmpty, workingAgents: agents.workingCount > 0,
+ focusActive: timer.didFinish || (timer.hasStarted && timer.remainingSeconds > 0),
+ localActivity: localActivity, recentBuild: recentBuild, activeBuild: builds.activeCount > 0,
+ musicAvailable: spotify.status == .connected && spotify.snapshot?.title.isEmpty == false)
+ updateCompactActivity(content)
+ }
+
+ func updateCompactActivity(_ content: CompactContent) {
+ pendingCompactContent = content
// Keep the closing surface stable; apply new activity when it settles.
guard wantsExpanded || !state.canvasExpanded else { return }
- guard state.showsCompactMusic != music || state.showsCompactAgents != agents else { return }
- withoutAnimation {
- state.showsCompactMusic = music
- state.showsCompactAgents = agents
- }
+ guard state.compactContent != content else { return }
+ withoutAnimation { state.compactContent = content }
position()
}
@@ -315,6 +332,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let appMenuItem = NSMenuItem()
let appMenu = NSMenu(title: "Broschy")
let terminate = NSMenuItem(title: "Quit Broschy", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
+ let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
+ settings.target = self
+ appMenu.addItem(settings)
+ appMenu.addItem(.separator())
appMenu.addItem(terminate)
appMenuItem.submenu = appMenu
main.addItem(appMenuItem)
@@ -332,6 +353,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let show = NSMenuItem(title: "Show / Hide Panel", action: #selector(togglePanel), keyEquivalent: "")
show.target = self
menu.addItem(show)
+ let customize = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: "")
+ customize.target = self
+ menu.addItem(customize)
menu.addItem(.separator())
let folder = NSMenuItem(title: "Open Broschy Data", action: #selector(showData), keyEquivalent: "")
folder.target = self
@@ -346,6 +370,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc func togglePanel() {
if wantsExpanded { collapse() } else { expand(focus: true) }
}
+ @objc func showSettings() {
+ expand(focus: true)
+ state.showsSettings = true
+ }
@objc func showData() {
guard let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
// Keep the existing support folder so a rename does not orphan notes, timers, or jobs.
diff --git a/Sources/Broschy/BuildWatchController.swift b/Sources/Broschy/BuildWatchController.swift
new file mode 100644
index 0000000..d46bff3
--- /dev/null
+++ b/Sources/Broschy/BuildWatchController.swift
@@ -0,0 +1,336 @@
+import Combine
+import Darwin
+import Foundation
+
+@MainActor
+final class BuildWatchController: ObservableObject {
+ @Published private(set) var target: BuildWatchTarget?
+ @Published private(set) var isEnabled = false
+ @Published private(set) var status: BuildWatchConnectionStatus = .disconnected
+ @Published private(set) var runs: [BuildWatchRun] = []
+ @Published private(set) var isRefreshing = false
+ @Published private(set) var isLive = false
+ @Published private(set) var lastUpdatedAt: Date?
+ @Published private(set) var errorMessage: String?
+ @Published private(set) var inputError: String?
+ @Published private(set) var resolvedBranch: String?
+ @Published private(set) var recentCompletion: BuildWatchCompletion?
+
+ private let defaults: UserDefaults
+ private let perform: (BuildWatchRequest, BuildWatchCancellation) -> BuildWatchOutcome
+ private let clock: () -> Date
+ private let queue = DispatchQueue(label: "app.broschy.build-watch", qos: .utility)
+ private let cancellation = BuildWatchCancellation()
+ private var ticker: Timer?
+ private var generation = 0
+ private var inFlight = false
+ private var pendingRefresh = false
+ private var nextRefresh = Date.distantPast
+ private var failureCount = 0
+ private var baselineScope: String?
+ private var observedActive = Set()
+ private var seenCompletions: [String]
+
+ init(defaults: UserDefaults = .standard, startTicker: Bool = true, clock: @escaping () -> Date = Date.init,
+ perform: @escaping (BuildWatchRequest, BuildWatchCancellation) -> BuildWatchOutcome = BuildWatchTransport.fetch) {
+ self.defaults = defaults
+ self.clock = clock
+ self.perform = perform
+ if let data = defaults.data(forKey: "buildWatchTarget"), data.count <= 2_048,
+ let stored = try? JSONDecoder().decode(BuildWatchTarget.self, from: data), stored.isValid {
+ target = stored
+ isEnabled = defaults.bool(forKey: "buildWatchEnabled")
+ }
+ seenCompletions = (defaults.stringArray(forKey: "buildWatchSeenCompletions") ?? [])
+ .filter { $0.range(of: "^[a-f0-9]{64}:[0-9]+:[0-9]+$", options: .regularExpression) != nil }.suffix(30).map { $0 }
+ if startTicker {
+ let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.tick() }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ ticker = timer
+ if isEnabled { refresh() }
+ }
+ }
+
+ deinit { ticker?.invalidate(); cancellation.invalidate() }
+
+ var activeCount: Int { isLive ? runs.filter(\.isActive).count : 0 }
+ var hasCompactActivity: Bool { activeCount > 0 || recentCompletion != nil }
+
+ @discardableResult
+ func connect(_ input: String, branch: String = "") -> Bool {
+ let parsed: BuildWatchTarget
+ do { parsed = try BuildWatchTarget.parse(input, branch: branch) }
+ catch { inputError = (error as? BuildWatchError ?? .invalidTarget).message; return false }
+ generation += 1
+ cancellation.setGeneration(generation)
+ target = parsed
+ isEnabled = true
+ status = .connecting
+ inputError = nil
+ errorMessage = nil
+ runs = []; isLive = false; lastUpdatedAt = nil; resolvedBranch = nil; recentCompletion = nil
+ baselineScope = nil; observedActive = []; failureCount = 0
+ defaults.set(try? JSONEncoder().encode(parsed), forKey: "buildWatchTarget")
+ defaults.set(true, forKey: "buildWatchEnabled")
+ refresh()
+ return true
+ }
+
+ func disconnect() {
+ generation += 1; cancellation.setGeneration(generation)
+ isEnabled = false; status = .disconnected; isRefreshing = false; isLive = false
+ runs = []; recentCompletion = nil; lastUpdatedAt = nil; resolvedBranch = nil
+ errorMessage = nil; inputError = nil; pendingRefresh = false
+ baselineScope = nil; observedActive = []
+ defaults.set(false, forKey: "buildWatchEnabled")
+ }
+
+ func tick(now: Date? = nil) {
+ let now = now ?? clock()
+ if let completion = recentCompletion, now.timeIntervalSince(completion.observedAt) >= 12 { recentCompletion = nil }
+ // A sleeping Mac must not show an old running badge while a fresh poll
+ // is outstanding after wake.
+ if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) > 75 { isLive = false }
+ if isEnabled && !inFlight && now >= nextRefresh { refresh() }
+ }
+
+ func refresh() {
+ guard isEnabled, let target else { return }
+ if inFlight { pendingRefresh = true; return }
+ let request = BuildWatchRequest(target: target, generation: generation)
+ inFlight = true; isRefreshing = true
+ if lastUpdatedAt == nil { status = .connecting }
+ let worker = perform, cancellation = cancellation
+ queue.async { [weak self] in
+ let outcome = worker(request, cancellation)
+ DispatchQueue.main.async {
+ guard let self else { return }
+ self.inFlight = false
+ if self.isEnabled && self.generation == request.generation {
+ self.isRefreshing = false
+ self.accept(outcome, target: request.target, now: self.clock())
+ }
+ let pending = self.pendingRefresh
+ self.pendingRefresh = false
+ if pending && self.isEnabled { self.refresh() }
+ }
+ }
+ }
+
+ private func accept(_ outcome: BuildWatchOutcome, target: BuildWatchTarget, now: Date) {
+ switch outcome {
+ case .failure(let error):
+ status = .failure; errorMessage = error.message; isLive = false; recentCompletion = nil
+ failureCount += 1
+ nextRefresh = now.addingTimeInterval([60.0, 120.0, 300.0][min(2, failureCount - 1)])
+ case .success(let snapshot):
+ let newScope = target.identity + ":" + snapshot.scopeIdentity
+ if baselineScope == newScope {
+ let completed = snapshot.runs.filter { run in
+ run.isCompleted && observedActive.contains(run.completionKey)
+ && !seenCompletions.contains(target.identity + ":" + run.completionKey)
+ }.sorted { $0.updatedAt > $1.updatedAt }
+ if let run = completed.first { recentCompletion = .init(run: run, observedAt: now) }
+ for run in completed { seenCompletions.append(target.identity + ":" + run.completionKey) }
+ seenCompletions = Array(seenCompletions.suffix(30))
+ defaults.set(seenCompletions, forKey: "buildWatchSeenCompletions")
+ } else {
+ baselineScope = newScope
+ recentCompletion = nil
+ }
+ observedActive = Set(snapshot.runs.filter(\.isActive).map(\.completionKey))
+ runs = Array(snapshot.runs.prefix(10)); resolvedBranch = snapshot.resolvedBranch
+ lastUpdatedAt = now; status = .connected; errorMessage = nil; isLive = true
+ failureCount = 0; nextRefresh = now.addingTimeInterval(30)
+ }
+ }
+}
+
+final class BuildWatchCancellation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var generation = 0
+ private var invalidated = false
+ func setGeneration(_ value: Int) { lock.lock(); generation = value; lock.unlock() }
+ func invalidate() { lock.lock(); invalidated = true; lock.unlock() }
+ func isCurrent(_ value: Int) -> Bool { lock.lock(); defer { lock.unlock() }; return !invalidated && generation == value }
+}
+
+enum BuildWatchTransport {
+ typealias API = (String, BuildWatchRequest, BuildWatchCancellation) throws -> Data
+
+ static func fetch(_ request: BuildWatchRequest, _ cancellation: BuildWatchCancellation) -> BuildWatchOutcome {
+ let deadline = DispatchTime.now().uptimeNanoseconds + 15_000_000_000
+ return fetch(request, cancellation) { endpoint, request, cancellation in
+ let now = DispatchTime.now().uptimeNanoseconds
+ guard now < deadline else { throw BuildWatchError.timedOut }
+ return try api(endpoint, request: request, cancellation: cancellation,
+ timeout: TimeInterval(deadline - now) / 1_000_000_000)
+ }
+ }
+
+ static func fetch(_ request: BuildWatchRequest, _ cancellation: BuildWatchCancellation, api: API) -> BuildWatchOutcome {
+ do {
+ guard request.target.isValid else { throw BuildWatchError.invalidTarget }
+ guard cancellation.isCurrent(request.generation) else { throw BuildWatchError.cancelled }
+ let target = request.target
+ let base = "repos/\(target.owner)/\(target.repository)"
+ let branch: String, scope: String, filter: URLQueryItem
+ if let number = target.pullRequest {
+ let data = try api(base + "/pulls/\(number)", request, cancellation)
+ let pr = try JSONDecoder().decode(PullRequestResponse.self, from: data)
+ guard pr.number == number, BuildWatchTarget.validSHA(pr.head.sha), BuildWatchTarget.validBranch(pr.head.ref),
+ pr.base.repo.full_name.lowercased() == "\(target.owner)/\(target.repository)".lowercased() else { throw BuildWatchError.invalidResponse }
+ branch = pr.head.ref; scope = pr.head.sha.lowercased(); filter = URLQueryItem(name: "head_sha", value: pr.head.sha)
+ } else {
+ if let selected = target.branch { branch = selected }
+ else {
+ let data = try api(base, request, cancellation)
+ let repository = try JSONDecoder().decode(RepositoryResponse.self, from: data)
+ guard repository.full_name.lowercased() == "\(target.owner)/\(target.repository)".lowercased(),
+ BuildWatchTarget.validBranch(repository.default_branch) else { throw BuildWatchError.invalidResponse }
+ branch = repository.default_branch
+ }
+ scope = branch; filter = URLQueryItem(name: "branch", value: branch)
+ }
+ guard cancellation.isCurrent(request.generation) else { throw BuildWatchError.cancelled }
+ var query = URLComponents()
+ query.queryItems = [filter, URLQueryItem(name: "per_page", value: "10")]
+ // Form-style query decoders treat a literal '+' as a space.
+ let encodedQuery = (query.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
+ let data = try api(base + "/actions/runs?" + encodedQuery, request, cancellation)
+ let runs = try decodeRuns(data, target: target, expectedBranch: target.pullRequest == nil ? branch : nil,
+ expectedSHA: target.pullRequest == nil ? nil : scope)
+ guard cancellation.isCurrent(request.generation) else { throw BuildWatchError.cancelled }
+ return .success(.init(runs: runs, resolvedBranch: branch, scopeIdentity: scope))
+ } catch { return .failure(error as? BuildWatchError ?? .invalidResponse) }
+ }
+
+ static func decodeRuns(_ data: Data, target: BuildWatchTarget, expectedBranch: String?, expectedSHA: String?) throws -> [BuildWatchRun] {
+ guard data.count <= 1_048_576 else { throw BuildWatchError.oversized }
+ let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
+ let response = try decoder.decode(RunsResponse.self, from: data)
+ guard response.workflow_runs.count <= 100 else { throw BuildWatchError.invalidResponse }
+ var ids = Set()
+ return try response.workflow_runs.prefix(10).map { raw in
+ guard raw.id > 0, ids.insert(raw.id).inserted, raw.run_attempt > 0, raw.run_attempt <= 10_000,
+ BuildWatchTarget.validSHA(raw.head_sha), BuildWatchTarget.validBranch(raw.head_branch),
+ expectedBranch == nil || raw.head_branch == expectedBranch,
+ expectedSHA == nil || raw.head_sha.lowercased() == expectedSHA?.lowercased(),
+ let url = BuildWatchRun.validatedURL(raw.html_url, target: target, id: raw.id),
+ raw.updated_at >= raw.created_at else { throw BuildWatchError.invalidResponse }
+ let name = raw.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Workflow"
+ guard name.utf8.count <= 512, !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { throw BuildWatchError.invalidResponse }
+ return BuildWatchRun(id: raw.id, name: name.isEmpty ? "Workflow" : String(name.prefix(100)), headBranch: raw.head_branch,
+ headSHA: raw.head_sha, htmlURL: url, createdAt: raw.created_at, updatedAt: raw.updated_at,
+ status: BuildWatchRunStatus(rawValue: raw.status) ?? .unknown,
+ conclusion: raw.conclusion.map { BuildWatchConclusion(rawValue: $0) ?? .unknown }, attempt: raw.run_attempt)
+ }.sorted { $0.createdAt > $1.createdAt }
+ }
+
+ private struct RepositoryResponse: Decodable { let full_name: String; let default_branch: String }
+ private struct PullRequestResponse: Decodable {
+ let number: Int; let head: Head; let base: Base
+ struct Head: Decodable { let sha: String; let ref: String }
+ struct Base: Decodable { let repo: Repo }
+ struct Repo: Decodable { let full_name: String }
+ }
+ private struct RunsResponse: Decodable { let workflow_runs: [RunResponse] }
+ private struct RunResponse: Decodable {
+ let id: Int64; let name: String?; let head_branch: String; let head_sha: String; let html_url: String
+ let created_at: Date; let updated_at: Date; let status: String; let conclusion: String?; let run_attempt: Int
+ }
+
+ static func executableURL() -> URL? {
+ var paths = ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]
+ for directory in (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":") where directory.hasPrefix("/") {
+ paths.append(String(directory) + "/gh")
+ }
+ for path in paths {
+ let url = URL(fileURLWithPath: path).resolvingSymlinksInPath()
+ guard FileManager.default.isExecutableFile(atPath: url.path),
+ (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { continue }
+ return url
+ }
+ return nil
+ }
+
+ private static func api(_ endpoint: String, request: BuildWatchRequest, cancellation: BuildWatchCancellation,
+ timeout: TimeInterval) throws -> Data {
+ guard let executable = executableURL() else { throw BuildWatchError.missingCLI }
+ return try runAPI(endpoint, executable: executable, request: request, cancellation: cancellation, timeout: timeout)
+ }
+
+ static func runAPI(_ endpoint: String, executable: URL, request: BuildWatchRequest,
+ cancellation: BuildWatchCancellation, timeout: TimeInterval = 15) throws -> Data {
+ guard cancellation.isCurrent(request.generation) else { throw BuildWatchError.cancelled }
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = ["api", "--hostname", "github.com", "--method", "GET", "--include",
+ "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28", endpoint]
+ var environment = ProcessInfo.processInfo.environment
+ environment["GH_PROMPT_DISABLED"] = "1"; environment["GH_PAGER"] = "cat"; environment["PAGER"] = "cat"
+ environment["GIT_TERMINAL_PROMPT"] = "0"; environment["GH_NO_UPDATE_NOTIFIER"] = "1"
+ process.environment = environment
+ let stdout = Pipe(), stderr = Pipe()
+ process.standardInput = FileHandle.nullDevice; process.standardOutput = stdout; process.standardError = stderr
+ do { try process.run() } catch { throw BuildWatchError.missingCLI }
+ let descriptors = [stdout.fileHandleForReading.fileDescriptor, stderr.fileHandleForReading.fileDescriptor]
+ for fd in descriptors { _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) }
+ var output = Data(), errors = Data(), open = [true, true]
+ let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(max(0.05, min(15, timeout)) * 1_000_000_000)
+ var failure: BuildWatchError?
+ while open.contains(true) || process.isRunning {
+ if !cancellation.isCurrent(request.generation) { failure = .cancelled; break }
+ if DispatchTime.now().uptimeNanoseconds >= deadline { failure = .timedOut; break }
+ var polls = descriptors.enumerated().map { pollfd(fd: open[$0.offset] ? $0.element : -1, events: Int16(POLLIN | POLLHUP), revents: 0) }
+ _ = poll(&polls, UInt32(polls.count), 50)
+ for index in 0..<2 where open[index] && polls[index].revents != 0 {
+ var buffer = [UInt8](repeating: 0, count: 8_192)
+ let count = read(descriptors[index], &buffer, buffer.count)
+ if count == 0 { open[index] = false }
+ else if count > 0 {
+ if index == 0 { output.append(contentsOf: buffer.prefix(count)) }
+ else { errors.append(contentsOf: buffer.prefix(count)) }
+ if output.count > 1_048_576 || errors.count > 8_192 { failure = .oversized; break }
+ } else if errno != EAGAIN && errno != EINTR { failure = .network; break }
+ }
+ if failure != nil { break }
+ }
+ if failure != nil && process.isRunning {
+ process.terminate()
+ let killAt = DispatchTime.now().uptimeNanoseconds + 200_000_000
+ while process.isRunning && DispatchTime.now().uptimeNanoseconds < killAt { usleep(5_000) }
+ if process.isRunning { kill(process.processIdentifier, SIGKILL) }
+ }
+ process.waitUntilExit()
+ try? stdout.fileHandleForReading.close(); try? stderr.fileHandleForReading.close()
+ if let failure { throw failure }
+ return try responseBody(output, stderr: errors, exitCode: process.terminationStatus)
+ }
+
+ static func responseBody(_ data: Data, stderr: Data, exitCode: Int32) throws -> Data {
+ guard data.count <= 1_048_576, stderr.count <= 8_192 else { throw BuildWatchError.oversized }
+ let crlf = Data("\r\n\r\n".utf8), lf = Data("\n\n".utf8)
+ let split = data.range(of: crlf) ?? data.range(of: lf)
+ let header = split.map { String(decoding: data[..<$0.lowerBound], as: UTF8.self) } ?? ""
+ let firstLine = header.components(separatedBy: .newlines).first ?? ""
+ let code = firstLine.hasPrefix("HTTP/") ? firstLine.split(separator: " ").dropFirst().first.flatMap { Int($0) } : nil
+ switch code {
+ case 401: throw BuildWatchError.authentication
+ case 403, 429: throw BuildWatchError.forbidden
+ case 404: throw BuildWatchError.notFound
+ case 200:
+ guard exitCode == 0, let split else { throw BuildWatchError.network }
+ return data.subdata(in: split.upperBound.. Self {
+ let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard text.utf8.count <= 512 else { throw BuildWatchError.invalidTarget }
+ var pieces: [String]
+ if text.contains("://") {
+ guard let url = URLComponents(string: text), url.scheme == "https", url.host?.lowercased() == "github.com",
+ url.user == nil, url.password == nil, url.port == nil, url.query == nil, url.fragment == nil,
+ !url.percentEncodedPath.contains("%") else { throw BuildWatchError.invalidTarget }
+ pieces = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
+ } else {
+ pieces = text.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
+ }
+ guard pieces.count == 2 || (pieces.count == 4 && pieces[2] == "pull"),
+ validOwner(pieces[0]), validRepository(pieces[1]) else { throw BuildWatchError.invalidTarget }
+ let pullRequest: Int?
+ if pieces.count == 4 {
+ guard let number = Int(pieces[3]), number > 0, number <= Int32.max,
+ String(number) == pieces[3] else { throw BuildWatchError.invalidTarget }
+ pullRequest = number
+ } else { pullRequest = nil }
+ let ref = branch.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard ref.isEmpty || validBranch(ref) else { throw BuildWatchError.invalidBranch }
+ return Self(owner: pieces[0], repository: pieces[1], branch: pullRequest == nil && !ref.isEmpty ? ref : nil, pullRequest: pullRequest)
+ }
+
+ var isValid: Bool {
+ Self.validOwner(owner) && Self.validRepository(repository)
+ && (branch == nil || Self.validBranch(branch!))
+ && (pullRequest == nil || (pullRequest! > 0 && pullRequest! <= Int32.max && branch == nil))
+ }
+
+ static func validOwner(_ value: String) -> Bool {
+ value.range(of: "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$", options: .regularExpression) != nil
+ }
+ static func validRepository(_ value: String) -> Bool {
+ ![".", ".."].contains(value) && value.range(of: "^[A-Za-z0-9_.-]{1,100}$", options: .regularExpression) != nil
+ }
+ static func validBranch(_ value: String) -> Bool {
+ !value.isEmpty && value.utf8.count <= 255 && !value.hasPrefix("-")
+ && !value.hasPrefix("/") && !value.hasSuffix("/") && !value.hasSuffix(".")
+ && !value.contains("..") && !value.contains("//") && !value.contains("@{") && value != "@"
+ && !value.unicodeScalars.contains { CharacterSet.controlCharacters.union(.whitespacesAndNewlines).union(CharacterSet(charactersIn: "~^:?*[\\")).contains($0) }
+ && value.split(separator: "/").allSatisfy { !$0.hasPrefix(".") && !$0.hasSuffix(".lock") }
+ }
+ static func validSHA(_ value: String) -> Bool {
+ value.range(of: "^[0-9a-fA-F]{40,64}$", options: .regularExpression) != nil
+ }
+}
+
+enum BuildWatchRunStatus: String, Codable { case queued, inProgress = "in_progress", waiting, requested, pending, completed, unknown }
+enum BuildWatchConclusion: String, Codable {
+ case success, failure, neutral, cancelled, skipped, timedOut = "timed_out", actionRequired = "action_required"
+ case stale, startupFailure = "startup_failure", unknown
+}
+enum BuildWatchRunState { case queued, running, waiting, success, failure, cancelled, skipped, neutral, unknown }
+
+struct BuildWatchRun: Equatable, Identifiable {
+ let id: Int64
+ let name: String
+ let headBranch: String
+ let headSHA: String
+ let htmlURL: URL
+ let createdAt: Date
+ let updatedAt: Date
+ let status: BuildWatchRunStatus
+ let conclusion: BuildWatchConclusion?
+ let attempt: Int
+
+ var completionKey: String { "\(id):\(attempt)" }
+ var isActive: Bool { [.queued, .inProgress, .waiting, .requested, .pending].contains(status) }
+ var isCompleted: Bool { status == .completed }
+ var state: BuildWatchRunState {
+ switch status {
+ case .queued, .requested, .pending: return .queued
+ case .inProgress: return .running
+ case .waiting: return .waiting
+ case .unknown: return .unknown
+ case .completed:
+ switch conclusion {
+ case .success: return .success
+ case .failure, .timedOut, .actionRequired, .startupFailure, .stale: return .failure
+ case .cancelled: return .cancelled
+ case .skipped: return .skipped
+ case .neutral: return .neutral
+ case .unknown, .none: return .unknown
+ }
+ }
+ }
+ var statusText: String {
+ switch status {
+ case .queued, .requested, .pending: return "Queued"
+ case .inProgress: return "Running"
+ case .waiting: return "Waiting for approval"
+ case .unknown: return "Status unavailable"
+ case .completed:
+ switch conclusion {
+ case .success: return "Passed"
+ case .failure, .startupFailure: return "Failed"
+ case .timedOut: return "Timed out"
+ case .actionRequired: return "Action required"
+ case .stale: return "Stale"
+ case .cancelled: return "Cancelled"
+ case .skipped: return "Skipped"
+ case .neutral: return "Neutral"
+ case .unknown, .none: return "Conclusion unavailable"
+ }
+ }
+ }
+
+ static func validatedURL(_ value: String, target: BuildWatchTarget, id: Int64) -> URL? {
+ guard let components = URLComponents(string: value), components.scheme == "https",
+ components.host?.lowercased() == "github.com", components.user == nil, components.password == nil,
+ components.port == nil, components.query == nil, components.fragment == nil,
+ components.percentEncodedPath.lowercased() == "/\(target.owner)/\(target.repository)/actions/runs/\(id)".lowercased() else { return nil }
+ return components.url
+ }
+}
+
+struct BuildWatchCompletion: Equatable {
+ let run: BuildWatchRun
+ let observedAt: Date
+}
+
+enum BuildWatchConnectionStatus { case disconnected, connecting, connected, failure }
+enum BuildWatchError: Error, Equatable {
+ case invalidTarget, invalidBranch, missingCLI, authentication, forbidden, notFound, timedOut, cancelled, oversized, invalidResponse, network
+ var message: String {
+ switch self {
+ case .invalidTarget: return "Enter owner/repository or a GitHub pull request URL."
+ case .invalidBranch: return "Enter a valid branch name, or leave it blank for the default branch."
+ case .missingCLI: return "Install GitHub CLI, then sign in with gh auth login."
+ case .authentication: return "Sign in to GitHub CLI with gh auth login, then try again."
+ case .forbidden: return "GitHub denied access or reached a rate limit. Check this account’s repository access."
+ case .notFound: return "Repository or pull request not found. Check the target and your GitHub access."
+ case .timedOut: return "GitHub took too long to respond. Broschy will try again."
+ case .oversized, .invalidResponse: return "GitHub returned an unreadable response. Broschy will try again."
+ case .cancelled: return "Watch stopped."
+ case .network: return "Could not reach GitHub. Check your connection and GitHub CLI sign-in."
+ }
+ }
+}
+
+struct BuildWatchRequest { let target: BuildWatchTarget; let generation: Int }
+struct BuildWatchSnapshot {
+ let runs: [BuildWatchRun]
+ let resolvedBranch: String
+ /// Branch for a repository, exact head SHA for a PR. Changing PR heads must
+ /// never celebrate a completed workflow from the previous commit.
+ let scopeIdentity: String
+}
+enum BuildWatchOutcome { case success(BuildWatchSnapshot), failure(BuildWatchError) }
diff --git a/Sources/Broschy/BuildWatchView.swift b/Sources/Broschy/BuildWatchView.swift
new file mode 100644
index 0000000..56e9cf3
--- /dev/null
+++ b/Sources/Broschy/BuildWatchView.swift
@@ -0,0 +1,259 @@
+import AppKit
+import SwiftUI
+
+extension BuildWatchRunState {
+ var symbol: String {
+ switch self {
+ case .queued: return "clock"
+ case .running: return "circle.dotted"
+ case .waiting: return "hand.raised"
+ case .success: return "checkmark.circle.fill"
+ case .failure: return "exclamationmark.circle.fill"
+ case .cancelled: return "stop.circle"
+ case .skipped, .neutral: return "minus.circle"
+ case .unknown: return "questionmark.circle"
+ }
+ }
+ var tint: Color {
+ switch self {
+ case .success: return Ink.success
+ case .failure: return Ink.failure
+ case .queued, .running, .waiting: return Ink.primary
+ default: return Ink.muted
+ }
+ }
+}
+
+extension BuildWatchController {
+ var compactRun: BuildWatchRun? {
+ guard isLive else { return nil }
+ if let completion = recentCompletion, (0..<12).contains(Date().timeIntervalSince(completion.observedAt)) {
+ return completion.run
+ }
+ return runs.first(where: \.isActive)
+ }
+}
+
+struct SignalsHubView: View {
+ @ObservedObject var store: FlowStore
+ @ObservedObject var state: PanelState
+ @ObservedObject var builds: BuildWatchController
+
+ var body: some View {
+ VStack(spacing: 4) {
+ Picker("Signal source", selection: $state.signalPage) {
+ Text("Build Watch").tag(0)
+ Text("Commands").tag(1)
+ }
+ .pickerStyle(.segmented).labelsHidden().controlSize(.small).padding(.top, 12)
+ if state.signalPage == 0 { BuildWatchView(builds: builds) }
+ else { SignalsView(store: store) }
+ }
+ }
+}
+
+struct BuildWatchView: View {
+ @ObservedObject var builds: BuildWatchController
+ @State private var editingTarget = false
+ @State private var repository = ""
+ @State private var branch = ""
+ @Environment(\.flowReduceMotion) private var reduceMotion
+
+ var body: some View {
+ Group {
+ if !builds.isEnabled || editingTarget { setup }
+ else { connected }
+ }
+ .onAppear { prefill() }
+ .onChange(of: builds.target) { _, _ in prefill() }
+ }
+
+ private var setup: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Your next green build.")
+ .font(.system(size: 19, weight: .semibold, design: .rounded))
+ Spacer()
+ if editingTarget {
+ Button("Cancel") { editingTarget = false }
+ .buttonStyle(.link).font(.system(size: 11))
+ }
+ }
+ Text("Watch GitHub Actions for a repository or pull request.")
+ .font(.system(size: 11)).foregroundStyle(Ink.muted)
+ .fixedSize(horizontal: false, vertical: true)
+ VStack(spacing: 7) {
+ TextField("owner/repository or GitHub PR URL", text: $repository)
+ .accessibilityLabel("GitHub repository or pull request")
+ TextField("Branch · blank uses the default", text: $branch)
+ .accessibilityLabel("Branch, optional for repositories")
+ .disabled(repository.contains("/pull/"))
+ }
+ .textFieldStyle(.roundedBorder).font(.system(size: 12))
+ .onSubmit { connect() }
+ if let error = builds.inputError {
+ Text(error).font(.system(size: 10)).foregroundStyle(Ink.failure)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ HStack(spacing: 12) {
+ Button(action: connect) {
+ Label(editingTarget ? "Save watch" : "Watch builds", systemImage: "antenna.radiowaves.left.and.right")
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 14).frame(height: 32)
+ }
+ .buttonStyle(FlowButtonStyle(primary: true))
+ .disabled(repository.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ Text("Uses GitHub CLI (gh)\nand its existing sign-in.")
+ .font(.system(size: 10)).foregroundStyle(Ink.muted)
+ }
+ .padding(.top, 1)
+ }
+ .padding(.vertical, 12)
+ }.scrollIndicators(.hidden)
+ }
+
+ private var connected: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 6) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(builds.target?.displayName ?? "Build Watch")
+ .font(.system(size: 12, weight: .semibold)).lineLimit(1)
+ .help(builds.target?.displayName ?? "")
+ HStack(spacing: 4) {
+ Circle().fill(builds.isLive ? Ink.success : Ink.muted).frame(width: 4, height: 4)
+ Text(connectionDetail).font(.system(size: 10)).foregroundStyle(Ink.muted).lineLimit(1)
+ }
+ }
+ Spacer(minLength: 4)
+ Button { builds.refresh() } label: {
+ Image(systemName: "arrow.clockwise").frame(width: 26, height: 28)
+ }
+ .buttonStyle(QuietButtonStyle()).disabled(builds.isRefreshing)
+ .help("Refresh builds").accessibilityLabel("Refresh builds")
+ Menu {
+ Button("Change repository…") { prefill(); editingTarget = true }
+ Button("Open on GitHub") {
+ if let url = builds.target?.webURL { NSWorkspace.shared.open(url) }
+ }
+ Divider()
+ Button("Disconnect Build Watch") { builds.disconnect() }
+ } label: { Image(systemName: "ellipsis").frame(width: 24, height: 28) }
+ .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize()
+ .help("Build Watch options").accessibilityLabel("Build Watch options")
+ }
+ if let error = builds.errorMessage {
+ HStack(alignment: .top, spacing: 7) {
+ Image(systemName: "exclamationmark.triangle").foregroundStyle(Ink.failure)
+ Text(error).font(.system(size: 10)).foregroundStyle(Ink.muted)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(8).frame(maxWidth: .infinity, alignment: .leading)
+ .background(Ink.inset, in: RoundedRectangle(cornerRadius: 9))
+ }
+ if builds.runs.isEmpty {
+ VStack(spacing: 9) {
+ Image(systemName: builds.isRefreshing ? "arrow.triangle.2.circlepath" : "checklist")
+ .font(.system(size: 25, weight: .light)).foregroundStyle(Ink.primary)
+ .symbolEffect(.pulse, options: .repeating, isActive: builds.isRefreshing && !reduceMotion)
+ Text(builds.isRefreshing ? "Checking GitHub…" : builds.status == .failure ? "Waiting for a connection." : "No workflow runs yet.")
+ .font(.system(size: 14, weight: .medium, design: .rounded))
+ if builds.status != .failure {
+ Text(builds.isRefreshing ? "Your workflow status will appear here." : "Push a change to this branch to get started.")
+ .font(.system(size: 11)).foregroundStyle(Ink.muted)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(builds.runs) { run in
+ row(run)
+ if run.id != builds.runs.last?.id { Rectangle().fill(Ink.separator).frame(height: 0.5) }
+ }
+ }
+ }.scrollIndicators(.hidden)
+ }
+ }.padding(.top, 9).padding(.bottom, 8)
+ }
+
+ private func row(_ run: BuildWatchRun) -> some View {
+ let stale = !builds.isLive
+ let color = stale ? Ink.muted : run.state.tint
+ return Button { NSWorkspace.shared.open(run.htmlURL) } label: {
+ HStack(spacing: 10) {
+ Image(systemName: stale && run.isActive ? "questionmark.circle" : run.state.symbol)
+ .font(.system(size: 15)).foregroundStyle(color)
+ .frame(width: 32, height: 32)
+ .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
+ VStack(alignment: .leading, spacing: 3) {
+ Text(run.name).font(.system(size: 12, weight: .medium)).lineLimit(1)
+ Text("\(run.headBranch) · \(run.headSHA.prefix(7))")
+ .font(.system(size: 10)).foregroundStyle(Ink.muted).lineLimit(1)
+ }
+ Spacer(minLength: 4)
+ VStack(alignment: .trailing, spacing: 3) {
+ Text(stale && run.isActive ? "Last seen active" : run.statusText)
+ .font(.system(size: 10, weight: .medium)).foregroundStyle(color).lineLimit(1)
+ Text(run.updatedAt, style: .relative)
+ .font(.system(size: 9)).foregroundStyle(Ink.muted).lineLimit(1)
+ }
+ Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(Ink.muted)
+ }
+ .padding(.vertical, 9).contentShape(Rectangle())
+ }
+ .buttonStyle(QuietButtonStyle())
+ .help("\(run.name) · \(run.statusText) · Open workflow on GitHub")
+ .accessibilityLabel("\(run.name), \(stale && run.isActive ? "last seen active" : run.statusText). Open workflow on GitHub.")
+ }
+
+ private var connectionDetail: String {
+ if builds.isRefreshing { return "Checking GitHub…" }
+ if !builds.isLive {
+ return builds.lastUpdatedAt == nil ? "Not connected · retrying automatically" : "Last fetched results · reconnecting"
+ }
+ return "\(builds.resolvedBranch ?? "Default branch") · checks every 30 seconds"
+ }
+ private func prefill() {
+ repository = builds.target?.repositoryInput ?? ""
+ branch = builds.target?.branch ?? ""
+ }
+ private func connect() {
+ if builds.connect(repository, branch: branch) { editingTarget = false }
+ }
+}
+
+struct CompactBuildLeading: View {
+ @ObservedObject var builds: BuildWatchController
+ let isVisible: Bool
+ let reduceMotion: Bool
+ var body: some View {
+ HStack(spacing: 7) {
+ Image(systemName: builds.compactRun?.state.symbol ?? "checklist")
+ .font(.system(size: 16, weight: .medium))
+ .symbolEffect(.pulse, options: .repeating,
+ isActive: isVisible && !reduceMotion && builds.activeCount > 0)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Build Watch").font(.system(size: 10, weight: .semibold))
+ Text(builds.compactRun?.statusText ?? "GitHub Actions")
+ .font(.system(size: 9)).foregroundStyle(Ink.muted).lineLimit(1)
+ }
+ }
+ .foregroundStyle(builds.compactRun?.state.tint ?? Ink.muted)
+ .padding(.horizontal, 8).accessibilityHidden(true)
+ }
+}
+
+struct CompactBuildTrailing: View {
+ @ObservedObject var builds: BuildWatchController
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(builds.compactRun?.name ?? "GitHub Actions")
+ .font(.system(size: 10, weight: .semibold))
+ Text(builds.target?.repository ?? "Build Watch")
+ .font(.system(size: 9)).foregroundStyle(Ink.muted)
+ }
+ .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 13)
+ .accessibilityHidden(true)
+ }
+}
diff --git a/Sources/Broschy/PanelActivity.swift b/Sources/Broschy/PanelActivity.swift
new file mode 100644
index 0000000..87dd992
--- /dev/null
+++ b/Sources/Broschy/PanelActivity.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// One decision drives the compact content, its width, and its click destination.
+enum CompactContent: Equatable {
+ case idle, agents, focus, localSignal, buildWatch, music
+
+ var destination: PanelTab? {
+ switch self {
+ case .idle: return nil
+ case .agents: return .agents
+ case .focus: return .focus
+ case .localSignal, .buildWatch: return .signals
+ case .music: return .music
+ }
+ }
+
+ var usesWideWings: Bool { self == .agents || self == .music || self == .buildWatch }
+
+ static func choose(visibleTabs: Set, quietFocusRunning: Bool,
+ agentAttention: Bool, workingAgents: Bool, focusActive: Bool,
+ localActivity: Bool, recentBuild: Bool, activeBuild: Bool,
+ musicAvailable: Bool) -> CompactContent {
+ if visibleTabs.contains(.agents), agentAttention { return .agents }
+ if !quietFocusRunning {
+ if visibleTabs.contains(.signals), localActivity { return .localSignal }
+ if visibleTabs.contains(.signals), recentBuild { return .buildWatch }
+ }
+ if visibleTabs.contains(.focus), focusActive { return .focus }
+ if !quietFocusRunning, visibleTabs.contains(.signals), activeBuild { return .buildWatch }
+ if !quietFocusRunning, visibleTabs.contains(.agents), workingAgents { return .agents }
+ if visibleTabs.contains(.music), musicAvailable { return .music }
+ return .idle
+ }
+}
diff --git a/Sources/Broschy/PanelPreferences.swift b/Sources/Broschy/PanelPreferences.swift
new file mode 100644
index 0000000..15cd238
--- /dev/null
+++ b/Sources/Broschy/PanelPreferences.swift
@@ -0,0 +1,160 @@
+import AgentBridge
+import Combine
+import CoreFoundation
+import Foundation
+
+/// IDs stay compatible with the panel's existing selection and launch options.
+enum PanelTab: Int, CaseIterable, Identifiable {
+ case focus = 0
+ case signals = 1
+ case later = 2
+ case music = 3
+ case agents = 4
+
+ var id: Int { rawValue }
+
+ static let displayOrder: [PanelTab] = [.agents, .focus, .music, .signals, .later]
+
+ var displayTitle: String {
+ switch self {
+ case .focus: return "Focus"
+ case .signals: return "Signals"
+ case .later: return "Later"
+ case .music: return "Music"
+ case .agents: return "Agents"
+ }
+ }
+
+ var systemSymbol: String {
+ switch self {
+ case .focus: return "scope"
+ case .signals: return "terminal"
+ case .later: return "text.alignleft"
+ case .music: return "music.note"
+ case .agents: return "circle.dotted"
+ }
+ }
+}
+
+@MainActor
+final class PanelPreferences: ObservableObject {
+ static let visibleTabsKey = "broschy.panel.visibleTabs"
+ static let quietFocusKey = "broschy.panel.quietFocusEnabled"
+ static let defaultVisibleTabs = Set(PanelTab.allCases)
+
+ @Published private(set) var visibleTabs: Set
+ @Published var quietFocusEnabled: Bool {
+ didSet {
+ guard quietFocusEnabled != oldValue else { return }
+ defaults.set(quietFocusEnabled, forKey: Self.quietFocusKey)
+ }
+ }
+
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ let savedTabs = defaults.object(forKey: Self.visibleTabsKey)
+ let savedQuietFocus = defaults.object(forKey: Self.quietFocusKey)
+ visibleTabs = Self.decodeVisibleTabs(savedTabs)
+ quietFocusEnabled = Self.decodeBoolean(savedQuietFocus) ?? false
+
+ // Repair only Broschy's own malformed keys, leaving unrelated settings intact.
+ if let savedTabs, (savedTabs as? [Int]) != encodedVisibleTabs {
+ persistVisibleTabs()
+ }
+ if savedQuietFocus != nil && Self.decodeBoolean(savedQuietFocus) == nil {
+ defaults.set(false, forKey: Self.quietFocusKey)
+ }
+ }
+
+ var orderedVisibleTabs: [PanelTab] {
+ PanelTab.displayOrder.filter { visibleTabs.contains($0) }
+ }
+
+ var fallbackTab: PanelTab { orderedVisibleTabs.first ?? .focus }
+
+ func safeSelection(_ requested: PanelTab?) -> PanelTab {
+ if let requested, visibleTabs.contains(requested) { return requested }
+ return fallbackTab
+ }
+
+ /// Hiding a module only changes presentation; it does not stop its background work.
+ @discardableResult
+ func setVisible(_ visible: Bool, for tab: PanelTab) -> Bool {
+ guard visibleTabs.contains(tab) != visible else { return true }
+ if !visible && visibleTabs.count == 1 { return false }
+ if visible { visibleTabs.insert(tab) }
+ else { visibleTabs.remove(tab) }
+ persistVisibleTabs()
+ return true
+ }
+
+ func resetDefaults() {
+ visibleTabs = Self.defaultVisibleTabs
+ quietFocusEnabled = false
+ defaults.removeObject(forKey: Self.visibleTabsKey)
+ defaults.removeObject(forKey: Self.quietFocusKey)
+ }
+
+ private var encodedVisibleTabs: [Int] { orderedVisibleTabs.map(\.rawValue) }
+
+ private func persistVisibleTabs() {
+ defaults.set(encodedVisibleTabs, forKey: Self.visibleTabsKey)
+ }
+
+ private static func decodeVisibleTabs(_ value: Any?) -> Set {
+ guard let values = value as? [Any] else { return defaultVisibleTabs }
+ let tabs = Set(values.compactMap { value -> PanelTab? in
+ guard let number = value as? NSNumber,
+ CFGetTypeID(number) != CFBooleanGetTypeID(),
+ let rawValue = Int(exactly: number.doubleValue) else { return nil }
+ return PanelTab(rawValue: rawValue)
+ })
+ return tabs.isEmpty ? defaultVisibleTabs : tabs
+ }
+
+ private static func decodeBoolean(_ value: Any?) -> Bool? {
+ guard let number = value as? NSNumber,
+ CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil }
+ return number.boolValue
+ }
+}
+
+/// Quiet Focus changes compact interruptions, never the underlying Agents queue.
+enum AgentAttentionPolicy {
+ static func requiresInterruption(
+ _ session: AgentSession,
+ quietFocusEnabled: Bool,
+ focusActive: Bool,
+ now: Date = Date(),
+ ownerLiveness: AgentProcessLiveness? = nil
+ ) -> Bool {
+ let status = session.effectiveStatus(at: now, ownerLiveness: ownerLiveness)
+ var activeRequest = status == .needsAttention
+ if status == .error && !session.pendingRequestIDs.isEmpty {
+ // Preserve the error while applying request freshness and process-exit
+ // rules to the unanswered question/permission that accompanies it.
+ var pending = session
+ pending.status = .needsAttention
+ activeRequest = pending.effectiveStatus(at: now, ownerLiveness: ownerLiveness) == .needsAttention
+ }
+ if quietFocusEnabled && focusActive { return activeRequest }
+ return activeRequest || ((status == .ready || status == .error) && session.acknowledgedAt == nil)
+ }
+
+ /// Pass the monitor's visible sessions so its retention and ordering remain unchanged.
+ static func compactAttention(
+ from sessions: [AgentSession],
+ visibleTabs: Set,
+ quietFocusEnabled: Bool,
+ focusActive: Bool,
+ now: Date = Date()
+ ) -> [AgentSession] {
+ guard visibleTabs.contains(.agents) else { return [] }
+ return sessions.filter {
+ requiresInterruption($0, quietFocusEnabled: quietFocusEnabled,
+ focusActive: focusActive, now: now)
+ }
+ }
+}
diff --git a/Sources/Broschy/PanelSettingsView.swift b/Sources/Broschy/PanelSettingsView.swift
new file mode 100644
index 0000000..b2a56bc
--- /dev/null
+++ b/Sources/Broschy/PanelSettingsView.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+struct PanelSettingsView: View {
+ @ObservedObject var preferences: PanelPreferences
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("Make it yours.").font(.system(size: 19, weight: .semibold, design: .rounded))
+ Spacer()
+ Image(systemName: "slider.horizontal.3").foregroundStyle(Ink.muted)
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ Toggle(isOn: $preferences.quietFocusEnabled) {
+ Label("Quiet Focus", systemImage: "moon")
+ .font(.system(size: 12, weight: .medium))
+ }.toggleStyle(.switch).controlSize(.small)
+ Text("During a running timer, only agent questions and permission requests interrupt the notch. Results stay in their tabs.")
+ .font(.system(size: 11)).foregroundStyle(Ink.muted)
+ .fixedSize(horizontal: false, vertical: true).lineSpacing(2)
+ }
+ Rectangle().fill(Ink.separator).frame(height: 1)
+ VStack(alignment: .leading, spacing: 10) {
+ Text("IN YOUR PANEL").font(.system(size: 9, weight: .semibold)).tracking(1).foregroundStyle(Ink.muted)
+ ForEach(PanelTab.displayOrder) { tab in
+ Toggle(isOn: Binding(get: { preferences.visibleTabs.contains(tab) },
+ set: { preferences.setVisible($0, for: tab) })) {
+ Label(tab.displayTitle, systemImage: tab.systemSymbol)
+ .font(.system(size: 12)).frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .toggleStyle(.switch).controlSize(.mini)
+ .disabled(preferences.visibleTabs.count == 1 && preferences.visibleTabs.contains(tab))
+ }
+ Text("Hidden tabs also leave the compact notch. Connections keep running. Keep at least one tab visible.")
+ .font(.system(size: 10)).foregroundStyle(Ink.muted).lineSpacing(2)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Button("Reset panel settings") { preferences.resetDefaults() }
+ .buttonStyle(.link).font(.system(size: 11))
+ .help("Show all tabs and turn Quiet Focus off. Your data and connections stay unchanged.")
+ }
+ .foregroundStyle(Ink.text).padding(20).frame(width: 320)
+ }
+}
diff --git a/Sources/Broschy/Views.swift b/Sources/Broschy/Views.swift
index 59a17f6..68fb2bd 100644
--- a/Sources/Broschy/Views.swift
+++ b/Sources/Broschy/Views.swift
@@ -1,3 +1,4 @@
+import AgentBridge
import AppKit
import SwiftUI
@@ -40,11 +41,13 @@ struct NotchRootView: View {
@ObservedObject var state: PanelState
@ObservedObject var spotify: SpotifyController
@ObservedObject var agents: AgentMonitor
+ @ObservedObject var preferences: PanelPreferences
+ @ObservedObject var builds: BuildWatchController
var body: some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
expandedHeader
- ExpandedContent(store: store, state: state, spotify: spotify, agents: agents)
+ ExpandedContent(store: store, state: state, spotify: spotify, agents: agents, preferences: preferences, builds: builds)
.frame(height: 350)
.opacity(state.expanded ? 1 : 0)
.offset(y: state.expanded || state.reduceMotion ? 0 : -12)
@@ -100,6 +103,14 @@ struct NotchRootView: View {
.frame(maxWidth: .infinity)
Color.clear.frame(width: state.notchWidth)
HStack(spacing: 2) {
+ Button { state.showsSettings.toggle() } label: {
+ Image(systemName: "gearshape").frame(width: 26, height: 26)
+ }
+ .buttonStyle(QuietButtonStyle())
+ .help("Settings · ⌘,").accessibilityLabel("Settings")
+ .popover(isPresented: $state.showsSettings, arrowEdge: .bottom) {
+ PanelSettingsView(preferences: preferences)
+ }
Menu {
Button("Hide panel") { state.close?() }
Divider()
@@ -133,15 +144,20 @@ struct NotchRootView: View {
var compact: some View {
Button {
- if state.showsCompactAgents { state.tab = 4 }
- else if compactTrack != nil { state.tab = 3 }
+ if let destination = state.compactContent.destination {
+ state.tab = preferences.safeSelection(destination).rawValue
+ if state.compactContent == .localSignal { state.signalPage = 1 }
+ if state.compactContent == .buildWatch { state.signalPage = 0 }
+ }
state.open?()
} label: {
VStack(spacing: 0) {
HStack(spacing: 0) {
Group {
if state.showsCompactAgents {
- CompactAgentsLeading(monitor: agents, reduceMotion: state.reduceMotion, isVisible: !state.expanded)
+ CompactAgentsLeading(monitor: agents, attention: compactAttention, reduceMotion: state.reduceMotion, isVisible: !state.expanded)
+ } else if state.compactContent == .buildWatch {
+ CompactBuildLeading(builds: builds, isVisible: !state.expanded, reduceMotion: state.reduceMotion)
} else if let track = compactTrack {
CompactMusicArtwork(track: track, isVisible: !state.expanded,
reduceMotion: state.reduceMotion, height: state.notchHeight)
@@ -156,7 +172,9 @@ struct NotchRootView: View {
Color.clear.frame(width: state.notchWidth)
Group {
if state.showsCompactAgents {
- CompactAgentsTrailing(monitor: agents)
+ CompactAgentsTrailing(monitor: agents, attention: compactAttention)
+ } else if state.compactContent == .buildWatch {
+ CompactBuildTrailing(builds: builds)
} else if let track = compactTrack {
CompactMusicDetails(track: track, isVisible: !state.expanded,
reduceMotion: state.reduceMotion, height: state.notchHeight)
@@ -172,7 +190,7 @@ struct NotchRootView: View {
}
.frame(height: state.notchHeight)
GeometryReader { proxy in
- if store.timerState.isRunning || store.timerState.didFinish {
+ if state.compactContent == .focus {
Capsule().fill(Ink.primary)
.frame(width: max(2, proxy.size.width * timerProgress), height: 2)
}
@@ -195,8 +213,13 @@ struct NotchRootView: View {
var compactTrack: SpotifySnapshot? {
!state.showsCompactAgents && state.showsCompactMusic && spotify.status == .connected ? spotify.snapshot : nil
}
+ var compactAttention: [AgentSession] {
+ AgentAttentionPolicy.compactAttention(from: agents.visibleSessions, visibleTabs: preferences.visibleTabs,
+ quietFocusEnabled: preferences.quietFocusEnabled, focusActive: store.timerState.isRunning)
+ }
var compactLabel: String {
- if state.showsCompactAgents { return "Open Agents. \(agents.workingCount) working, \(agents.needsYouCount) need your attention." }
+ if state.compactContent == .buildWatch { return "Open Build Watch. \(builds.compactRun?.statusText ?? "GitHub Actions")" }
+ if state.showsCompactAgents { return "Open Agents. \(agents.workingCount) working, \(compactAttention.count) need your attention." }
guard let track = compactTrack else { return "Open Broschy. \(compactText)" }
return "\(track.isPlaying ? "Playing" : "Paused"): \(track.title) by \(track.artist). Open Music."
}
@@ -208,31 +231,25 @@ struct NotchRootView: View {
return job
}
var compactSymbol: String {
- if let job = recentResult { return job.status == .succeeded ? "checkmark.circle.fill" : "exclamationmark.circle.fill" }
- if runningJob != nil { return "terminal" }
- if store.timerState.didFinish { return "checkmark.circle.fill" }
- if store.timerState.hasStarted && store.timerState.remainingSeconds > 0 { return "timer" }
- if spotify.status == .connected && spotify.snapshot?.isPlaying == true { return "waveform" }
- if let job = store.jobs.first { return job.status == .succeeded ? "checkmark.circle" : "exclamationmark.circle" }
- return "circle.hexagongrid.fill"
+ switch state.compactContent {
+ case .focus: return store.timerState.didFinish ? "checkmark.circle.fill" : preferences.quietFocusEnabled && store.timerState.isRunning ? "moon.fill" : "timer"
+ case .localSignal: return recentResult.map { $0.status == .succeeded ? "checkmark.circle.fill" : "exclamationmark.circle.fill" } ?? "terminal"
+ default: return "circle.hexagongrid.fill"
+ }
}
var compactText: String {
- if let job = recentResult { return job.status == .succeeded ? "OK" : "Check" }
- if runningJob != nil { return "Running" }
- if store.timerState.didFinish { return "Done" }
- if store.timerState.hasStarted && store.timerState.remainingSeconds > 0 { return clockString(store.timerState.remainingSeconds) }
- if spotify.status == .connected && spotify.snapshot?.isPlaying == true { return "Music" }
- if let job = store.jobs.first { return job.status == .succeeded ? "OK" : "Check" }
- return "Broschy"
+ switch state.compactContent {
+ case .focus: return store.timerState.didFinish ? "Done" : clockString(store.timerState.remainingSeconds)
+ case .localSignal: return recentResult.map { $0.status == .succeeded ? "OK" : "Check" } ?? "Running"
+ default: return "Broschy"
+ }
}
var compactColor: Color {
- if let job = recentResult { return job.status == .succeeded ? Ink.success : Ink.failure }
- if runningJob != nil || (store.timerState.hasStarted && store.timerState.remainingSeconds > 0) { return Ink.primary }
- if store.timerState.didFinish { return Ink.success }
- if spotify.status == .connected && spotify.snapshot?.isPlaying == true { return Ink.success }
- if store.jobs.first?.status == .succeeded { return Ink.success }
- if store.jobs.first?.status == .failed { return Ink.failure }
- return Ink.text
+ switch state.compactContent {
+ case .focus: return store.timerState.didFinish ? Ink.success : Ink.primary
+ case .localSignal: return recentResult.map { $0.status == .succeeded ? Ink.success : Ink.failure } ?? Ink.primary
+ default: return Ink.text
+ }
}
var timerProgress: Double {
guard store.timerState.totalSeconds > 0 else { return 0 }
@@ -245,17 +262,17 @@ struct ExpandedContent: View {
@ObservedObject var state: PanelState
@ObservedObject var spotify: SpotifyController
@ObservedObject var agents: AgentMonitor
+ @ObservedObject var preferences: PanelPreferences
+ @ObservedObject var builds: BuildWatchController
@Environment(\.flowReduceMotion) var reduceMotion
@Namespace private var tabSelection
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 3) {
- tab("Agents", symbol: "circle.dotted", index: 4)
- tab("Focus", symbol: "scope", index: 0)
- tab("Music", symbol: "music.note", index: 3)
- tab("Signals", symbol: "terminal", index: 1)
- tab("Later", symbol: "text.alignleft", index: 2)
+ ForEach(preferences.orderedVisibleTabs) { item in
+ tab(item.displayTitle, symbol: item.systemSymbol, index: item.rawValue)
+ }
}
.padding(4)
.modifier(ControlSurface(radius: 14))
@@ -263,11 +280,11 @@ struct ExpandedContent: View {
.padding(.horizontal, 20)
Group {
switch state.tab {
- case 1: SignalsView(store: store)
+ case 1: SignalsHubView(store: store, state: state, builds: builds)
case 2: LaterView(store: store)
case 3: MusicView(spotify: spotify)
case 4: AgentsView(monitor: agents, state: state)
- default: FocusView(store: store, state: state)
+ default: FocusView(store: store, state: state, preferences: preferences)
}
}
.id(state.tab)
@@ -283,7 +300,7 @@ struct ExpandedContent: View {
.help(store.storageError ?? "")
} else {
Image(systemName: "lock.shield").font(.system(size: 9))
- Text(state.tab == 3 ? "Spotify on this Mac" : state.tab == 4 ? "Local events · last 24 hours" : "Stored locally")
+ Text(state.tab == 3 ? "Spotify on this Mac" : state.tab == 4 ? "Local events · last 24 hours" : state.tab == 1 && state.signalPage == 0 ? "GitHub Actions · read only" : "Stored locally")
}
Spacer()
HStack(spacing: 5) {
@@ -305,7 +322,7 @@ struct ExpandedContent: View {
HStack(spacing: 4) {
Image(systemName: symbol).font(.system(size: 10))
Text(title).font(.system(size: 11, weight: .medium)).lineLimit(1)
- if (index == 1 && store.jobs.contains(where: { $0.status == .running })) || (index == 4 && agents.needsYouCount > 0) {
+ if (index == 1 && (builds.activeCount > 0 || store.jobs.contains(where: { $0.status == .running }))) || (index == 4 && agents.needsYouCount > 0) {
Circle().fill(Ink.primary).frame(width: 4, height: 4)
}
}
@@ -334,6 +351,7 @@ struct ExpandedContent: View {
struct FocusView: View {
@ObservedObject var store: FlowStore
@ObservedObject var state: PanelState
+ @ObservedObject var preferences: PanelPreferences
@FocusState private var editing: Bool
@Namespace private var durationSelection
@Environment(\.flowReduceMotion) var reduceMotion
@@ -436,7 +454,7 @@ struct FocusView: View {
return min(1, max(0, 1 - Double(store.timerState.remainingSeconds) / Double(store.timerState.totalSeconds)))
}
var time: String { clockString(active || store.timerState.didFinish ? store.timerState.remainingSeconds : state.selectedMinutes * 60) }
- var subtitle: String { store.timerState.didFinish ? "Session complete. Take a breather." : (store.timerState.isRunning ? "Focus in progress" : (active ? "Timer paused" : "One thing at a time.")) }
+ var subtitle: String { store.timerState.didFinish ? "Session complete. Take a breather." : (store.timerState.isRunning ? (preferences.quietFocusEnabled ? "Quiet Focus · fewer interruptions" : "Focus in progress") : (active ? "Timer paused" : "One thing at a time.")) }
var buttonTitle: String { store.timerState.isRunning ? "Pause" : (active ? "Resume" : "Start focus") }
}
@@ -484,7 +502,7 @@ struct SignalsView: View {
}
}
}
- .padding(.top, 20).padding(.bottom, 12)
+ .padding(.top, 8).padding(.bottom, 8)
}
func copyExample() {
diff --git a/Tests/BuildWatchTests.swift b/Tests/BuildWatchTests.swift
new file mode 100644
index 0000000..b1462f4
--- /dev/null
+++ b/Tests/BuildWatchTests.swift
@@ -0,0 +1,264 @@
+import Foundation
+import Darwin
+
+/// Fixture-only coverage. No real gh command, account or repository is contacted.
+@main
+struct BuildWatchTests {
+ static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
+ precondition(condition(), message)
+ }
+ static func rejects(_ expected: BuildWatchError? = nil, _ operation: () throws -> Void) {
+ do { try operation(); preconditionFailure("Expected rejection") }
+ catch { if let expected { expect(error as? BuildWatchError == expected, "Unexpected rejection") } }
+ }
+ @MainActor static func eventually(_ condition: () -> Bool) async {
+ for _ in 0..<300 {
+ if condition() { return }
+ try? await Task.sleep(for: .milliseconds(10))
+ }
+ preconditionFailure("Fixture worker did not finish")
+ }
+
+ static let sha = String(repeating: "a", count: 40)
+ static let otherSHA = String(repeating: "b", count: 40)
+ static let timestamp = Date(timeIntervalSince1970: 1_750_000_000)
+
+ static func rawRun(id: Int = 1, status: String = "in_progress", conclusion: String? = nil,
+ attempt: Int = 1, branch: String = "main", sha: String = sha) -> [String: Any] {
+ var record: [String: Any] = ["id": id, "name": "CI", "head_branch": branch, "head_sha": sha,
+ "html_url": "https://github.com/owner/repository/actions/runs/\(id)",
+ "created_at": "2025-06-15T15:06:40Z", "updated_at": "2025-06-15T15:07:40Z",
+ "status": status, "run_attempt": attempt]
+ record["conclusion"] = conclusion ?? NSNull()
+ return record
+ }
+ static func data(_ object: Any) throws -> Data { try JSONSerialization.data(withJSONObject: object) }
+ static func run(id: Int64 = 1, status: BuildWatchRunStatus = .inProgress, conclusion: BuildWatchConclusion? = nil,
+ attempt: Int = 1, headSHA: String = sha) -> BuildWatchRun {
+ .init(id: id, name: "CI", headBranch: "main", headSHA: headSHA,
+ htmlURL: URL(string: "https://github.com/owner/repository/actions/runs/\(id)")!,
+ createdAt: timestamp, updatedAt: timestamp, status: status, conclusion: conclusion, attempt: attempt)
+ }
+ static func snapshot(_ runs: [BuildWatchRun], scope: String = "main") -> BuildWatchOutcome {
+ .success(.init(runs: runs, resolvedBranch: "main", scopeIdentity: scope))
+ }
+
+ @MainActor static func main() async throws {
+ if CommandLine.arguments.dropFirst().first == "api" {
+ // This same test binary acts as a fake gh. No shell, network, real
+ // credentials, or user GitHub configuration is involved.
+ let expectedPrefix = ["api", "--hostname", "github.com", "--method", "GET", "--include",
+ "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28"]
+ guard Array(CommandLine.arguments.dropFirst().prefix(10)) == expectedPrefix,
+ ProcessInfo.processInfo.environment["GH_PROMPT_DISABLED"] == "1",
+ ProcessInfo.processInfo.environment["GH_PAGER"] == "cat",
+ ProcessInfo.processInfo.environment["GIT_TERMINAL_PROMPT"] == "0",
+ FileHandle.standardInput.readDataToEndOfFile().isEmpty else { exit(64) }
+ let mode = URL(fileURLWithPath: CommandLine.arguments[0]).lastPathComponent
+ if mode == "gh-timeout" { try? await Task.sleep(for: .seconds(10)); exit(0) }
+ if mode == "gh-oversized" { FileHandle.standardOutput.write(Data(repeating: 65, count: 1_048_600)); exit(0) }
+ if mode == "gh-stderr-oversized" { FileHandle.standardError.write(Data(repeating: 65, count: 9_000)); exit(1) }
+ if mode == "gh-auth" {
+ FileHandle.standardError.write(Data("PRIVATE_FIXTURE_DIAGNOSTIC".utf8))
+ FileHandle.standardOutput.write(Data("HTTP/2.0 401\r\n\r\n{}".utf8)); exit(1)
+ }
+ FileHandle.standardOutput.write(Data("HTTP/2.0 200\r\nContent-Type: application/json\r\n\r\n{\"workflow_runs\":[]}".utf8))
+ exit(0)
+ }
+ let target = try BuildWatchTarget.parse("owner/repository")
+ expect(target.branch == nil && target.pullRequest == nil, "Blank branch must select repository default")
+ let parsedPR = try BuildWatchTarget.parse("https://github.com/owner/repository/pull/17")
+ expect(parsedPR.pullRequest == 17, "PR parsing")
+ for invalid in ["https://evil.example/owner/repository", "https://github.com.evil.example/owner/repository", "https://user@github.com/owner/repository", "http://github.com/owner/repository", "owner/repository/extra", "https://github.com/owner/repository/pull/0", "https://github.com/owner/repository?token=private", "https://github.com/owner/%2e%2e", "../repository"] {
+ rejects(.invalidTarget) { _ = try BuildWatchTarget.parse(invalid) }
+ }
+ for branch in ["main?token=secret", "../main", "refs//main", "main\nsecret", "feature.lock", "-f"] {
+ rejects(.invalidBranch) { _ = try BuildWatchTarget.parse("owner/repository", branch: branch) }
+ }
+ expect(BuildWatchTarget.validBranch("feature/a+b&c"), "Safe branch characters must be encoded, not interpreted as arguments")
+ for url in ["https://evil.example/owner/repository/actions/runs/1", "https://github.com/owner/other/actions/runs/1", "https://github.com/owner/repository/actions/runs/2", "https://user:secret@github.com/owner/repository/actions/runs/1", "https://github.com/owner/repository/actions/runs/1?next=evil", "file:///tmp/run"] {
+ expect(BuildWatchRun.validatedURL(url, target: target, id: 1) == nil, "Run URL must stay on exact GitHub repository/run")
+ }
+ expect(run(status: .completed, conclusion: nil).state == .unknown, "Missing conclusion must not be successful")
+ expect(run(status: .completed, conclusion: .unknown).state == .unknown, "Unknown conclusion must not be successful")
+ expect(run(status: .queued, conclusion: .success).state == .queued, "Pending status must outrank old success")
+ expect(run(status: .completed, conclusion: .success).state == .success, "Completed success")
+ expect(run(status: .completed, conclusion: .cancelled).state == .cancelled, "Cancellation differs from success")
+ let parsed = try BuildWatchTransport.decodeRuns(data(["workflow_runs": [rawRun(status: "new_future_state")]]), target: target, expectedBranch: "main", expectedSHA: nil)
+ expect(parsed.first?.state == .unknown, "Unknown GitHub status must remain unknown")
+ rejects { _ = try BuildWatchTransport.decodeRuns(data(["workflow_runs": [rawRun(branch: "other")]]), target: target, expectedBranch: "main", expectedSHA: nil) }
+ rejects { _ = try BuildWatchTransport.decodeRuns(data(["workflow_runs": [rawRun(sha: otherSHA)]]), target: target, expectedBranch: nil, expectedSHA: sha) }
+ print("PASS: target/branch/run URL validation and conservative status/conclusion decoding")
+
+ let request = BuildWatchRequest(target: target, generation: 0)
+ let cancellation = BuildWatchCancellation()
+ var paths: [String] = []
+ let defaultOutcome = BuildWatchTransport.fetch(request, cancellation) { path, _, _ in
+ paths.append(path)
+ if path == "repos/owner/repository" { return try data(["full_name": "owner/repository", "default_branch": "trunk"]) }
+ return try data(["workflow_runs": [rawRun(branch: "trunk")]])
+ }
+ guard case .success(let defaultSnapshot) = defaultOutcome else { fatalError("Default branch fixture failed") }
+ expect(defaultSnapshot.resolvedBranch == "trunk" && paths.last?.contains("branch=trunk") == true, "Default branch must be resolved from metadata")
+ let plusTarget = try BuildWatchTarget.parse("owner/repository", branch: "feature/c++")
+ var plusEndpoint = ""
+ _ = BuildWatchTransport.fetch(.init(target: plusTarget, generation: 0), cancellation) { path, _, _ in
+ plusEndpoint = path
+ return try data(["workflow_runs": [rawRun(branch: "feature/c++")]])
+ }
+ expect(plusEndpoint.contains("branch=feature/c%2B%2B"), "Literal plus signs in branches must survive form-style query decoding")
+ let prTarget = try BuildWatchTarget.parse("https://github.com/owner/repository/pull/17")
+ paths = []
+ let prOutcome = BuildWatchTransport.fetch(.init(target: prTarget, generation: 0), cancellation) { path, _, _ in
+ paths.append(path)
+ if path.contains("/pulls/") {
+ return try data(["number": 17, "head": ["sha": sha, "ref": "same-name-in-a-fork"], "base": ["repo": ["full_name": "owner/repository"]]])
+ }
+ return try data(["workflow_runs": [rawRun(branch: "same-name-in-a-fork")]])
+ }
+ guard case .success(let prSnapshot) = prOutcome else { fatalError("PR fixture failed") }
+ expect(prSnapshot.scopeIdentity == sha && paths.last?.contains("head_sha=\(sha)") == true && paths.last?.contains("branch=") == false,
+ "Fork PR watches must use exact head SHA in the base repository")
+ let movedPR = BuildWatchTransport.fetch(.init(target: prTarget, generation: 0), cancellation) { _, _, _ in
+ try data(["number": 17, "head": ["sha": sha, "ref": "main"], "base": ["repo": ["full_name": "other/repository"]]])
+ }
+ guard case .failure(.invalidResponse) = movedPR else { fatalError("Wrong base repo must fail") }
+ for (code, error) in [(401, BuildWatchError.authentication), (403, .forbidden), (404, .notFound), (429, .forbidden)] {
+ rejects(error) { _ = try BuildWatchTransport.responseBody(Data("HTTP/2.0 \(code)\r\nContent-Type: application/json\r\n\r\n{}".utf8), stderr: Data(), exitCode: 1) }
+ }
+ rejects(.authentication) { _ = try BuildWatchTransport.responseBody(Data(), stderr: Data("SECRET: run gh auth login".utf8), exitCode: 1) }
+ print("PASS: repository default branch, PR head SHA/base binding, and safe HTTP/auth error classification")
+
+ let processDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("broschy-build-watch-" + UUID().uuidString)
+ try FileManager.default.createDirectory(at: processDirectory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: processDirectory) }
+ for name in ["gh-ok", "gh-timeout", "gh-oversized", "gh-stderr-oversized", "gh-auth"] {
+ try FileManager.default.copyItem(at: URL(fileURLWithPath: CommandLine.arguments[0]), to: processDirectory.appendingPathComponent(name))
+ }
+ let endpoint = "repos/owner/repository/actions/runs?branch=main&per_page=10"
+ let processResult = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-ok"), request: request, cancellation: cancellation)
+ expect(String(decoding: processResult, as: UTF8.self) == "{\"workflow_runs\":[]}", "Real argv transport must enforce GET, GitHub host, disabled prompts, no stdin and strip headers")
+ let beganTimeout = Date()
+ rejects(.timedOut) { _ = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-timeout"), request: request, cancellation: cancellation, timeout: 0.15) }
+ expect(Date().timeIntervalSince(beganTimeout) < 2, "Subprocess timeout must terminate a stalled command")
+ rejects(.oversized) { _ = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-oversized"), request: request, cancellation: cancellation) }
+ rejects(.oversized) { _ = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-stderr-oversized"), request: request, cancellation: cancellation) }
+ rejects(.authentication) { _ = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-auth"), request: request, cancellation: cancellation) }
+ expect(!BuildWatchError.authentication.message.contains("PRIVATE_FIXTURE_DIAGNOSTIC"), "Raw CLI diagnostics must not reach the UI")
+ let processCancellation = BuildWatchCancellation()
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { processCancellation.invalidate() }
+ let beganCancellation = Date()
+ rejects(.cancelled) { _ = try BuildWatchTransport.runAPI(endpoint, executable: processDirectory.appendingPathComponent("gh-timeout"), request: request, cancellation: processCancellation) }
+ expect(Date().timeIntervalSince(beganCancellation) < 2, "Retarget/stop must cancel a live subprocess")
+ print("PASS: real Process argv/GET/host/stdin settings, timeout, cancellation, bounded output, private diagnostic suppression")
+
+ let suite = "app.broschy.build-watch-tests." + UUID().uuidString
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let fixture = FixtureTransport()
+ var time = timestamp
+ let controller = BuildWatchController(defaults: defaults, startTicker: false, clock: { time }, perform: fixture.perform)
+ controller.tick(now: time)
+ expect(fixture.count == 0 && !controller.isEnabled, "No network before explicit opt-in")
+ fixture.enqueue(snapshot([run(status: .completed, conclusion: .success)]))
+ expect(controller.connect("owner/repository"), "Valid connection")
+ await eventually { !controller.isRefreshing }
+ expect(controller.recentCompletion == nil && controller.isLive && controller.activeCount == 0, "First load must not celebrate historical runs")
+ fixture.enqueue(snapshot([run(id: 2)]))
+ time = time.addingTimeInterval(29); controller.tick(now: time)
+ expect(fixture.count == 1, "Polling must not run before 30 seconds")
+ time = time.addingTimeInterval(1); controller.tick(now: time)
+ await eventually { !controller.isRefreshing }
+ expect(controller.activeCount == 1, "Active workflow count")
+ fixture.enqueue(snapshot([run(id: 2, status: .completed, conclusion: .success)]))
+ controller.refresh(); await eventually { !controller.isRefreshing }
+ expect(controller.recentCompletion?.run.id == 2, "Observed active-to-completed transition must produce a completion")
+ time = time.addingTimeInterval(12); controller.tick(now: time)
+ expect(controller.recentCompletion == nil, "Completion capsule expires after 12 seconds")
+ fixture.enqueue(snapshot([run(id: 2, status: .completed, conclusion: .success)]))
+ controller.refresh(); await eventually { !controller.isRefreshing }
+ expect(controller.recentCompletion == nil, "Repeated completion must not alert twice")
+ fixture.enqueue(snapshot([run(id: 2, attempt: 2)]))
+ controller.refresh(); await eventually { !controller.isRefreshing }
+ fixture.enqueue(snapshot([run(id: 2, status: .completed, conclusion: .failure, attempt: 2)]))
+ controller.refresh(); await eventually { !controller.isRefreshing }
+ expect(controller.recentCompletion?.run.attempt == 2 && controller.recentCompletion?.run.state == .failure, "Rerun attempts must be separately observed and reported")
+
+ fixture.enqueue(snapshot([run(id: 3)])); controller.refresh(); await eventually { !controller.isRefreshing }
+ fixture.enqueue(.failure(.timedOut)); controller.refresh(); await eventually { !controller.isRefreshing }
+ expect(!controller.isLive && controller.activeCount == 0 && controller.runs.first?.id == 3 && controller.errorMessage == BuildWatchError.timedOut.message,
+ "Failed polls retain a labeled stale snapshot but must clear live/running indicators")
+ let failedCount = fixture.count
+ time = time.addingTimeInterval(59); controller.tick(now: time)
+ expect(fixture.count == failedCount, "First error backs off 60 seconds")
+ fixture.enqueue(.failure(.network)); time = time.addingTimeInterval(1); controller.tick(now: time)
+ await eventually { !controller.isRefreshing }
+ let secondFailedCount = fixture.count
+ time = time.addingTimeInterval(119); controller.tick(now: time)
+ expect(fixture.count == secondFailedCount, "Second error backs off 120 seconds")
+ fixture.enqueue(.failure(.network)); time = time.addingTimeInterval(1); controller.tick(now: time)
+ await eventually { !controller.isRefreshing }
+ let thirdFailedCount = fixture.count
+ time = time.addingTimeInterval(299); controller.tick(now: time)
+ expect(fixture.count == thirdFailedCount, "Later errors back off 300 seconds")
+ print("PASS: opt-in, 30-second polling, baseline, transition/rerun dedup, completion expiry, stale failure state, bounded backoff")
+
+ let restarted = BuildWatchController(defaults: defaults, startTicker: false, clock: { time }, perform: fixture.perform)
+ fixture.enqueue(snapshot([run(id: 2, attempt: 2)])); restarted.refresh(); await eventually { !restarted.isRefreshing }
+ fixture.enqueue(snapshot([run(id: 2, status: .completed, conclusion: .failure, attempt: 2)])); restarted.refresh(); await eventually { !restarted.isRefreshing }
+ expect(restarted.recentCompletion == nil, "Persisted completion IDs prevent replay after restart")
+ fixture.enqueue(snapshot([run(id: 5)], scope: sha)); expect(restarted.connect("https://github.com/owner/repository/pull/17"), "PR connect")
+ await eventually { !restarted.isRefreshing }
+ fixture.enqueue(snapshot([run(id: 5, status: .completed, conclusion: .success, headSHA: otherSHA)], scope: otherSHA))
+ restarted.refresh(); await eventually { !restarted.isRefreshing }
+ expect(restarted.recentCompletion == nil, "New PR head establishes a baseline instead of celebrating an old head")
+
+ let blocked = FixtureTransport()
+ blocked.blockNext = true
+ blocked.enqueue(snapshot([run(id: 99)]))
+ let retargeted = BuildWatchController(defaults: UserDefaults(suiteName: suite + ".retarget")!, startTicker: false, perform: blocked.perform)
+ defer { UserDefaults(suiteName: suite + ".retarget")?.removePersistentDomain(forName: suite + ".retarget") }
+ expect(retargeted.connect("owner/repository"), "Start blocked request")
+ await eventually { blocked.count == 1 }
+ blocked.enqueue(snapshot([run(id: 100)]))
+ expect(retargeted.connect("owner/new-repository"), "Retarget pending request")
+ blocked.release.signal()
+ await eventually { !retargeted.isRefreshing && blocked.count == 2 }
+ expect(retargeted.target?.repository == "new-repository" && retargeted.runs.first?.id == 100,
+ "Old responses must not overwrite retargeted state")
+ blocked.blockNext = true; blocked.enqueue(snapshot([run(id: 101)])); retargeted.refresh()
+ await eventually { blocked.count == 3 }
+ retargeted.disconnect(); blocked.release.signal()
+ await eventually { !retargeted.isRefreshing }
+ try? await Task.sleep(for: .milliseconds(50))
+ expect(!retargeted.isEnabled && retargeted.runs.isEmpty && !retargeted.isLive && retargeted.status == .disconnected,
+ "Disconnect invalidates a late successful response")
+ let preferences = defaults.dictionaryRepresentation()
+ expect(preferences["buildWatchTarget"] is Data && preferences["buildWatchSeenCompletions"] is [String], "Minimal configuration/dedup persistence")
+ expect(!preferences.keys.contains { $0.lowercased().contains("token") }, "No credentials are stored")
+ print("PASS: restart dedup, PR head changes, retarget/disconnect stale-response cancellation, minimal preferences")
+ }
+}
+
+final class FixtureTransport: @unchecked Sendable {
+ private let lock = NSLock()
+ private var outcomes: [BuildWatchOutcome] = []
+ private var requests = 0
+ private var block = false
+ let release = DispatchSemaphore(value: 0)
+ var count: Int { lock.lock(); defer { lock.unlock() }; return requests }
+ var blockNext: Bool {
+ get { lock.lock(); defer { lock.unlock() }; return block }
+ set { lock.lock(); block = newValue; lock.unlock() }
+ }
+ func enqueue(_ outcome: BuildWatchOutcome) { lock.lock(); outcomes.append(outcome); lock.unlock() }
+ func perform(_ request: BuildWatchRequest, _ cancellation: BuildWatchCancellation) -> BuildWatchOutcome {
+ lock.lock()
+ requests += 1
+ let outcome = outcomes.isEmpty ? BuildWatchOutcome.failure(.network) : outcomes.removeFirst()
+ let shouldBlock = block; block = false
+ lock.unlock()
+ if shouldBlock { _ = release.wait(timeout: .now() + 5) }
+ return outcome
+ }
+}
diff --git a/Tests/PanelActivityTests.swift b/Tests/PanelActivityTests.swift
new file mode 100644
index 0000000..02642e5
--- /dev/null
+++ b/Tests/PanelActivityTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+@main
+struct PanelActivityTests {
+ static func main() {
+ func choose(_ tabs: Set = Set(PanelTab.allCases), quiet: Bool = false,
+ attention: Bool = false, working: Bool = false, focus: Bool = false,
+ local: Bool = false, recent: Bool = false, build: Bool = false,
+ music: Bool = true) -> CompactContent {
+ CompactContent.choose(visibleTabs: tabs, quietFocusRunning: quiet,
+ agentAttention: attention, workingAgents: working, focusActive: focus,
+ localActivity: local, recentBuild: recent, activeBuild: build, musicAvailable: music)
+ }
+ precondition(choose(attention: true, focus: true, local: true) == .agents)
+ precondition(choose(quiet: true, attention: true, focus: true) == .agents)
+ precondition(choose(quiet: true, focus: true, local: true, recent: true, build: true) == .focus)
+ precondition(choose(focus: true, local: true) == .localSignal)
+ precondition(choose(focus: true, recent: true) == .buildWatch)
+ precondition(choose(focus: true, build: true) == .focus)
+ precondition(choose(working: true, build: true) == .buildWatch)
+ precondition(choose(working: true) == .agents)
+ precondition(choose() == .music)
+ precondition(choose([.later], attention: true, working: true, focus: true,
+ local: true, recent: true, build: true) == .idle)
+ precondition(choose([.music], attention: true, focus: true, local: true, recent: true) == .music)
+ precondition(choose([.signals], quiet: true, local: true, recent: true, build: true) == .idle)
+ precondition(choose([.agents, .music], quiet: true, working: true, focus: true) == .music)
+ precondition(choose([.agents], quiet: true, working: true, focus: true) == .idle)
+ precondition(choose([.agents, .music], quiet: true, attention: true, working: true, focus: true) == .agents)
+ // Pausing Focus releases deferred completions (caller passes quiet=false).
+ precondition(choose(quiet: false, focus: true, recent: true) == .buildWatch)
+ for content in [CompactContent.agents, .focus, .localSignal, .buildWatch, .music] {
+ precondition(content.destination != nil)
+ }
+ precondition(CompactContent.buildWatch.destination == .signals)
+ precondition(CompactContent.localSignal.destination == .signals)
+ precondition(CompactContent.idle.destination == nil)
+ print("Panel activity priority checks passed")
+ }
+}
diff --git a/Tests/PanelPreferencesTests.swift b/Tests/PanelPreferencesTests.swift
new file mode 100644
index 0000000..3b83a6a
--- /dev/null
+++ b/Tests/PanelPreferencesTests.swift
@@ -0,0 +1,136 @@
+import AgentBridge
+import CoreFoundation
+import Foundation
+
+@main
+enum PanelPreferencesTests {
+ @MainActor
+ static func main() {
+ let suite = "Broschy.PanelPreferencesTests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ defer { defaults.removePersistentDomain(forName: suite) }
+
+ let unrelated: [String: Any] = ["spotifyIntegrationEnabled": true, "customPreference": "Keep this"]
+ unrelated.forEach { defaults.set($0.value, forKey: $0.key) }
+ func checkUnrelated() {
+ precondition(defaults.bool(forKey: "spotifyIntegrationEnabled"))
+ precondition(defaults.string(forKey: "customPreference") == "Keep this")
+ }
+
+ let initial = PanelPreferences(defaults: defaults)
+ precondition(initial.visibleTabs == Set(PanelTab.allCases))
+ precondition(initial.orderedVisibleTabs == [.agents, .focus, .music, .signals, .later])
+ precondition(!initial.quietFocusEnabled, "Existing interruption behavior should remain the default.")
+ precondition(defaults.object(forKey: PanelPreferences.visibleTabsKey) == nil,
+ "Loading defaults should not write a redundant preference.")
+ precondition(initial.safeSelection(.music) == .music)
+ precondition(initial.safeSelection(nil) == .agents)
+ precondition(PanelTab.focus.rawValue == 0 && PanelTab.signals.rawValue == 1
+ && PanelTab.later.rawValue == 2 && PanelTab.music.rawValue == 3
+ && PanelTab.agents.rawValue == 4, "Visual ordering must not change existing IDs.")
+
+ precondition(initial.setVisible(false, for: .agents))
+ precondition(initial.setVisible(false, for: .music))
+ initial.quietFocusEnabled = true
+ let restored = PanelPreferences(defaults: defaults)
+ precondition(restored.visibleTabs == [.focus, .signals, .later] && restored.quietFocusEnabled)
+ precondition(restored.orderedVisibleTabs == [.focus, .signals, .later])
+ precondition(restored.safeSelection(.agents) == .focus,
+ "A hidden selected tab must fall back to a visible tab.")
+ precondition(restored.setVisible(false, for: .focus))
+ precondition(restored.fallbackTab == .signals)
+ precondition(restored.setVisible(false, for: .signals))
+ let savedLastTab = defaults.array(forKey: PanelPreferences.visibleTabsKey) as? [Int]
+ precondition(!restored.setVisible(false, for: .later), "At least one module must remain visible.")
+ precondition(restored.visibleTabs == [.later])
+ precondition(defaults.array(forKey: PanelPreferences.visibleTabsKey) as? [Int] == savedLastTab)
+ precondition(restored.setVisible(true, for: .later), "Setting an unchanged visibility is harmless.")
+ precondition(restored.setVisible(true, for: .music))
+ precondition(restored.orderedVisibleTabs == [.music, .later])
+ checkUnrelated()
+ restored.resetDefaults()
+ precondition(restored.visibleTabs == Set(PanelTab.allCases) && !restored.quietFocusEnabled)
+ precondition(defaults.object(forKey: PanelPreferences.visibleTabsKey) == nil
+ && defaults.object(forKey: PanelPreferences.quietFocusKey) == nil)
+ checkUnrelated()
+
+ let invalidValues: [Any] = ["all", true, [Any](), [99, -1], [true, false], ["Music", "0"], [0.5, 3.75]]
+ for value in invalidValues {
+ defaults.set(value, forKey: PanelPreferences.visibleTabsKey)
+ let repaired = PanelPreferences(defaults: defaults)
+ precondition(repaired.visibleTabs == Set(PanelTab.allCases), "Invalid or empty visibility must recover to a usable panel.")
+ precondition(PanelPreferences(defaults: defaults).visibleTabs == repaired.visibleTabs)
+ checkUnrelated()
+ }
+ defaults.set([3, 3, 99, "bad", true, 0] as [Any], forKey: PanelPreferences.visibleTabsKey)
+ let partiallyValid = PanelPreferences(defaults: defaults)
+ precondition(partiallyValid.visibleTabs == [.focus, .music], "Keep valid tab IDs while dropping duplicates and malformed values.")
+ precondition(defaults.array(forKey: PanelPreferences.visibleTabsKey) as? [Int] == [0, 3])
+ for invalidQuiet: Any in ["true", 1, [true]] {
+ defaults.set(invalidQuiet, forKey: PanelPreferences.quietFocusKey)
+ precondition(!PanelPreferences(defaults: defaults).quietFocusEnabled)
+ }
+ checkUnrelated()
+ print("PASS: stable tab IDs, custom layout persistence, selection fallback, last-tab guard, malformed preference recovery, unrelated settings preserved")
+
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ func sample(_ id: String, _ status: AgentStatus, age: TimeInterval = 0,
+ pending: [String] = [], acknowledged: Bool = false,
+ owner: AgentProcessIdentity? = nil) -> AgentSession {
+ AgentSession(provider: .codex, sessionID: id, cwd: "/sample/project", status: status,
+ detail: "Working", updatedAt: now.addingTimeInterval(-age),
+ pendingRequestIDs: pending, acknowledgedAt: acknowledged ? now : nil,
+ ownerProcess: owner)
+ }
+ let waiting = sample("waiting", .needsAttention, pending: ["permission-1"])
+ let ready = sample("ready", .ready)
+ let error = sample("error", .error)
+ let working = sample("working", .working)
+ let reviewed = sample("reviewed", .ready, acknowledged: true)
+ let closed = sample("closed", .closed)
+ let stale = sample("stale", .needsAttention, age: 76, pending: ["old-permission"])
+ let sessions = [ready, waiting, error, working, reviewed, closed, stale]
+ let originalSessions = sessions
+
+ let normal = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases),
+ quietFocusEnabled: false, focusActive: true, now: now)
+ precondition(normal.map(\.sessionID) == ["ready", "waiting", "error"])
+ let quiet = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases),
+ quietFocusEnabled: true, focusActive: true, now: now)
+ precondition(quiet.map(\.sessionID) == ["waiting"], "Only an active question or permission request should interrupt Quiet Focus.")
+ let paused = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: Set(PanelTab.allCases),
+ quietFocusEnabled: true, focusActive: false, now: now)
+ precondition(paused == normal, "Pausing or finishing Focus should reveal queued responses and errors.")
+ let hidden = AgentAttentionPolicy.compactAttention(from: sessions, visibleTabs: [.focus, .music],
+ quietFocusEnabled: false, focusActive: false, now: now)
+ precondition(hidden.isEmpty, "A hidden Agents module must never supply a compact interruption.")
+ precondition(sessions == originalSessions && ready.needsAttention(at: now) && error.needsAttention(at: now),
+ "Quiet Focus must not acknowledge or remove items from the full Agents queue.")
+
+ let errorWithRequest = sample("error-request", .error, pending: ["question-1"])
+ precondition(AgentAttentionPolicy.requiresInterruption(errorWithRequest, quietFocusEnabled: true,
+ focusActive: true, now: now),
+ "A fresh unanswered request accompanying an error still needs a decision.")
+ let staleErrorRequest = sample("stale-error-request", .error, age: 76, pending: ["question-2"])
+ precondition(!AgentAttentionPolicy.requiresInterruption(staleErrorRequest, quietFocusEnabled: true,
+ focusActive: true, now: now))
+ precondition(AgentAttentionPolicy.requiresInterruption(staleErrorRequest, quietFocusEnabled: false,
+ focusActive: true, now: now),
+ "The error stays available for review after its active request becomes unverified.")
+ let owner = AgentProcessIdentity(pid: 123, startedAtSeconds: 1_700_000_000,
+ startedAtMicroseconds: 0, executableName: "codex")
+ let liveErrorRequest = sample("live-error-request", .error, age: 300, pending: ["question-3"], owner: owner)
+ precondition(AgentAttentionPolicy.requiresInterruption(liveErrorRequest, quietFocusEnabled: true,
+ focusActive: true, now: now, ownerLiveness: .alive))
+ precondition(!AgentAttentionPolicy.requiresInterruption(liveErrorRequest, quietFocusEnabled: true,
+ focusActive: true, now: now, ownerLiveness: .exited))
+ precondition(!AgentAttentionPolicy.requiresInterruption(liveErrorRequest, quietFocusEnabled: true,
+ focusActive: true, now: now, ownerLiveness: .unknown))
+ let expiredLiveRequest = sample("expired-live-request", .error, age: 1_801, pending: ["question-4"], owner: owner)
+ precondition(!AgentAttentionPolicy.requiresInterruption(expiredLiveRequest, quietFocusEnabled: true,
+ focusActive: true, now: now, ownerLiveness: .alive))
+ precondition(errorWithRequest.status == .error && errorWithRequest.pendingRequestIDs == ["question-1"])
+ print("PASS: Quiet Focus interruptions, paused-focus reveal, hidden Agents, unchanged review queue, pending-error request freshness and exact-process liveness")
+ }
+}
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 2790527..a0b9026 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -5,12 +5,12 @@
Broschy uses Swift Package Manager, SwiftUI for views, and AppKit for a borderless panel aligned with the screen's safe area. The main executable is `Broschy`; `broschy-cli` writes local command-result records. The package targets macOS 14, while compiling native Liquid Glass support requires the macOS 26 SDK from Xcode 26 or later.
```text
-Sources/Broschy/ App, panel behavior, views, storage, Spotify
+Sources/Broschy/ App, panel, preferences, storage, Spotify, Build Watch
Sources/BroschyCLI/ Command wrapper, result notifications, agent hook entry point
Sources/AgentBridge/ Normalized agent sessions, process identity, atomic snapshots
Integrations/opencode/ Metadata-only OpenCode event adapter
scripts/ Reversible agent configuration installer
-Tests/ Core, CLI, agent lifecycle, installer, and Spotify checks
+Tests/ Core, agents, installer, preferences, priority, integration mocks
Resources/ App icon and macOS ICNS bundle
build.sh Assemble and locally sign the app
test.sh Run Swift and Python/Node checks with isolated data
@@ -43,14 +43,32 @@ An exited process closes active sessions immediately without relying on an exit
Codex CLI 0.144.5 does not dispatch SessionEnd/Interrupt hooks. Keep process checks and age limits even when newer provider versions expose more events. Existing snapshots without process identity remain readable and use the unverified fallback. See [Agents](AGENTS.md) for installation and privacy details.
+## Layout and Quiet Focus
+
+`PanelTab` preserves existing IDs (`Focus = 0`, `Signals = 1`, `Later = 2`, `Music = 3`, `Agents = 4`) while displaying visible tabs in Agents, Focus, Music, Signals, Later order. `PanelPreferences` stores only the visibility set and Quiet Focus flag in the existing UserDefaults domain. It sanitizes invalid saved values, prevents an empty layout, and supplies a safe visible selection when a tab is hidden. Reset removes only these two preference keys.
+
+All modules are visible and Quiet Focus is off by default. The compact selector filters hidden modules before choosing an activity or its click destination. Hiding a module changes presentation without stopping background work. Settings are available through the header gear, the menu bar's **Settings…** item, and Command-comma.
+
+Quiet Focus uses `timer.isRunning`; a paused countdown releases its suppression. `AgentAttentionPolicy` keeps active questions and permissions eligible to interrupt while holding response/error review items in the full Agents queue. An error with a pending request must also pass the request's process-liveness and freshness checks. Local command and Build Watch results defer to the running focus timer. Ending or pausing Focus restores normal priority, subject to each result's existing expiry. This policy does not alter macOS Focus or other apps' notifications.
+
+## Build Watch
+
+`BuildWatchController` manages one opt-in GitHub.com target in **Signals → Build Watch**. **Signals → Commands** retains the local CLI integration. Targets accept a repository with its default or specified branch, or a pull request. Repository queries filter by branch; PR queries resolve the current head SHA before reading workflow runs. A changed PR head creates a fresh baseline.
+
+The transport runs installed `gh api` with explicit GET requests to GitHub.com. GitHub CLI handles the existing authentication; Broschy does not extract or store tokens. It looks for `gh` in common Homebrew locations and absolute PATH entries, disables interactive prompts, and keeps network work off the main thread. Each poll shares a 15-second deadline across its requests; each response is bounded to 1 MiB of stdout and 8 KiB of diagnostics. Diagnostics are classified into generic errors and are not surfaced or logged verbatim.
+
+Successful polls schedule the next read after 30 seconds. Failures back off to 60, 120, then 300 seconds, clear live activity and the compact completion, and retain the last rows as stale context. Data older than 75 seconds stops contributing a live running indicator. Disconnect and target changes invalidate in-flight responses.
+
+The panel keeps up to ten runs. A 12-second compact result requires observing a run active and then completed within the same target/branch or PR-head scope. Initial completed runs remain silent. Run ID plus attempt distinguishes reruns; a bounded set of 30 completion identifiers prevents repeat announcements. The target, enabled flag, and these identifiers persist in UserDefaults. Fetched workflow metadata remains in memory. The integration does not download full logs or perform workflow, repository, or pull-request mutations.
+
## Verification
-`bash test.sh` checks timer deadlines and sleep, pause/resume, persisted state and recovery, CLI exit status and cancellation, metadata normalization, Apple Event encoding, error handling, request ordering, and stale response rejection. It uses separate temporary directories and mocked Spotify responses. Agent checks cover redaction, concurrent requests, stale acknowledgments, process exit without a terminal event, PID reuse, activity expiry, and provider-specific event normalization. Installer tests preserve existing configuration; Node checks exercise the OpenCode plugin without model requests.
+`bash test.sh` checks timer deadlines and sleep, pause/resume, persisted state and recovery, CLI exit status and cancellation, metadata normalization, Apple Event encoding, error handling, request ordering, and stale response rejection. It uses separate temporary directories and mocked Spotify responses. Agent checks cover redaction, concurrent requests, stale acknowledgments, process exit without a terminal event, PID reuse, activity expiry, and provider-specific event normalization. Installer tests preserve existing configuration; Node checks exercise the OpenCode plugin without model requests. Mocked Build Watch checks cover target parsing, response validation, polling, stale replies, and completion handling. Preference and priority checks cover relaunch, malformed values, the final visible tab, Quiet Focus, hidden modules, and compact destinations. These checks do not establish live GitHub account access.
GitHub Actions runs those checks, builds the release bundle, validates its property list, verifies the local signature, and exercises the CLI help path on a macOS 26 runner. It does not test a physical notch or connect to Spotify.
-Manual checks on macOS 26 / Apple Silicon have covered compact and expanded layouts, light/dark appearance in an isolated preview window, reduced motion/transparency, real Spotify metadata and artwork, and play/pause. Next/previous and seeking have encoding checks; they have not been exercised as part of the documented live-player check. No Instruments FPS measurement or older-macOS device run is claimed.
+Manual checks on macOS 26 / Apple Silicon have covered compact and expanded layouts, light/dark appearance in an isolated preview window, reduced motion/transparency, real Spotify metadata and artwork, and play/pause. Build Watch has also fetched live results from the public Broschy repository using the default branch. UI checks covered hiding the selected tab, last-tab protection, restoring the layout, compact music fallback, and light/dark workflow rows. The live check did not dispatch a new workflow. Next/previous and seeking have encoding checks; they have not been exercised as part of the documented live-player check. No Instruments FPS measurement or older-macOS device run is claimed.
## Before changing panel behavior
-Check hover and keyboard activation, quick open/close reversals, the camera gap, text entry, pinning, and the fallback layout without a physical notch. Keep the temporarily expanded drawing canvas used during the close animation. Playback bars must stop animating when paused, hidden, or Reduce Motion is active.
+Check hover and keyboard activation, quick open/close reversals, the camera gap, text entry, pinning, Settings, hidden-tab selection, compact click destinations, and the fallback layout without a physical notch. Keep the temporarily expanded drawing canvas used during the close animation. Playback bars must stop animating when paused, hidden, or Reduce Motion is active.
diff --git a/docs/assets/build-watch-dark.png b/docs/assets/build-watch-dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..4ee72d2a4cfcbbe8151f7f9417a75a1cd0000db5
GIT binary patch
literal 32255
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
z
zU^ez(&_gR+|{2FQFy~#l)d+8S9cmx+cD0R9;
za=ao14Da;ef8rX34nBydVUTtby=f0$Q;IZbWO67IPKpimndQ5<|6G7n>-ohag)lQf)}
zaQ)hcp9ImuCjm3)gN32iG^5!9F*PVFMYT{eEUa28rpwDFtt5I-0k<-1n5`^1sik8x
zz&r_+Pj6e`a|+8Ao6{N~ce@6zwluEzfo@uA
z4UG$(hfXS{c{xr{;F0n!1ei7tree%%<2doOj!a#3?E1~=#LLya;r6Tie70SwnZ;tM`LKD&ezfx&WLxx5~M{)Fpo<*8+5$zD$HYAx<
z`s5T&z!MmF(Mw}yKpJ%9M`H)Gk!XV3Gc>o(!5wx|+&NqyMlo@>zu;WHzeKT>h7h*e
zFS2(vf}jE^T7v4*tYfmGXv3S}q4sHiLix?8fSTU#T`wEKUR&Pq!l3;ZKiKZye0r(#
zHbtc9(-=?k%0nH*Aq_4s2kDBTmrc7Mn}d6n6kJQ7$+likmqOA(DS{hk#KMZ~;|fgg
zL7+Gj>i+&xoj|?|z4UcQ4os;y^vGmZPbEyIWj16kK32@d1gECP(h1aV-ipo~)4cbI
zvgCDJa}iSKL3g3eWrmdAuoKX&^-Hw$zS6_vYtFN$F)iUhNQ=d8ptx}gS>?91l}zkA
zLGw=MG7DeF88smciwltX0}CXVzevd~;Qa+`LDTE<9%!MGU2rozF&w{^|7|kx
zr`f=N=Tn}@Qip`A+blA+QYT<=#pw76V4nQ_^PRMNBbegdSEv2o0BI=im1}gEcnIP^
zbLPU^&xSR?CS+6FG-X@Qp~*acnAM^nnx{PE`PBs8iqKXC2+`Ez?N*JqlFmx9t4)9Q
zBK70Ts;Vy(`GmqP+Ico!LfYxdqeE9E=-XGkB|(G~oG+ZS?Vfbl4QRFkbHX{;&W9><
zKXcubi{yJh;8JN(HAVY`3-m9p^yUF>U3|y*=P*CF$`4VzlbIiQrgQWJSopc|_q7D`b^#1BF>4RN_Qh}qn+|wK;{R`vtE`|kS)&IiT
z2(_vI-1%q#PElU1Aw)F7h;D9s>o@)2_xZaK+~jwYDx-Ul>9i#IscprO#xr&qp6r}L
z^WWcVah`gZirC-UlpQp}vB@K(Pswy?Ej%-ZYqLE{!nj=NR`<<7bYdfo`oy{C;q5{&
z-^UkX3MV~M4vrzK5l7BbxZ4{~?yl}oHguK;0fE`Li4Mkt&YM}*wEJodEKfL?oG3ii
zMQiV0Up`1O4)|^1?zWl@nTjxi<@7J(n{C0LEUPj;irk1}-$juyCWVr4JKt|XI}~Ei
z(U2xLa{i<1xc4MR3Vv+v<&Vz#v;3v$Y6ZX^gloU
zKZk|UJ}*%{It48aJ=zkye}{5Cv^Wk6AUe(yXKI~`_TFW&3(wOWH1$hdPtxk+?>spp
zz1i&4lQ0cH-o)((e8t9VLnYlvkyePvzC|R~+r}BiKzh`xa$7QK&LCE!O*4X*)vEa&
K#A!6YE&T_kjQbD(
literal 0
HcmV?d00001
diff --git a/test.sh b/test.sh
index 0de88d9..133ccf0 100644
--- a/test.sh
+++ b/test.sh
@@ -10,4 +10,10 @@ swiftc -swift-version 5 -D SPOTIFY_TESTS -parse-as-library Sources/Broschy/Spoti
"$test_dir/spotify-checks"
swiftc -swift-version 5 -parse-as-library -I "$test_dir" -L "$test_dir" -lAgentBridge -Xlinker -rpath -Xlinker "$test_dir" Sources/BroschyCLI/AgentCommand.swift Tests/AgentBridgeTests.swift -o "$test_dir/agent-checks"
"$test_dir/agent-checks" "$test_dir"
+swiftc -swift-version 5 -parse-as-library -I "$test_dir" -L "$test_dir" -lAgentBridge -Xlinker -rpath -Xlinker "$test_dir" Sources/Broschy/PanelPreferences.swift Tests/PanelPreferencesTests.swift -o "$test_dir/preferences-checks"
+"$test_dir/preferences-checks"
+swiftc -swift-version 5 -parse-as-library -I "$test_dir" -L "$test_dir" -lAgentBridge -Xlinker -rpath -Xlinker "$test_dir" Sources/Broschy/PanelPreferences.swift Sources/Broschy/PanelActivity.swift Tests/PanelActivityTests.swift -o "$test_dir/activity-checks"
+"$test_dir/activity-checks"
+swiftc -swift-version 5 -parse-as-library Sources/Broschy/BuildWatchModels.swift Sources/Broschy/BuildWatchController.swift Tests/BuildWatchTests.swift -o "$test_dir/build-watch-checks"
+"$test_dir/build-watch-checks"
python3 Tests/test_agent_integrations.py