# byre for macOS The macOS app under `mac/` is byre's client. The UI's behaviour, shared with the server, is described in `docs/operations.md`; this document covers how the app is built. ## Decision The app is a native SwiftUI client of byre's API. **No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the Tailscale identity headers (see "Identity" in `docs/operations.md`). It never sends identity headers itself, and never shows a login. ## Stack - Swift 6, SwiftUI, Observation (`@Observable`). Minimum macOS is the major version byre's host runs (`sw_vers --productVersion` prints it); there is no back-deployment. - AppKit only through `NSViewRepresentable`, and only where the "Gaps in SwiftUI" table below says so: the terminal. - Foundation's `URLSession` for REST, the SSE streams and the terminal WebSocket. - `UserNotifications` for notifications. - Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, SwiftTerm for the terminal, and Sparkle for updates (its framework is a binary target, loaded from `Contents/Frameworks` through the `@executable_path/../Frameworks` rpath set in `mac/Package.swift`). The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to `mac/.build/Byre`, `mac/.build/byre-hands` and `mac/.build/Sparkle.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/Byre.app` (`Byre` in `Contents/MacOS`, the server and `byre-hands` in `Contents/Helpers`, Sparkle in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order. ## Signing Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate (`codesign --sign "Developer ID Application"`), `--options runtime` (the hardened runtime notarization requires) and `--timestamp`, with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit in its Production environment, production push for sync, and Apple Events for driving Orion) and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. iCloud outside the App Store needs that profile: a Developer ID profile for the App ID `run.fc.byre.mac` with iCloud (CloudKit, container `iCloud.run.fc.byre.mac`) enabled, made at developer.apple.com under Certificates, Identifiers & Profiles → Profiles → Developer ID. It names no devices, so the app runs on any Mac. `security cms -D -i mac/byre.provisionprofile` prints it, `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Only the team's Account Holder can create a Developer ID Application certificate (Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application, signed in as the Account Holder); a Mac that builds the app needs that certificate and its private key in its keychain. A build that isn't notarized still runs on a Mac that didn't download it, such as one it was copied to with `client_upload`, since only downloaded files are quarantined; `mac/package.sh` notarizes deploys and releases (see "Releases" in `docs/operations.md`). Only the release configuration is built. A debug build stops at an `#error` in `ByreApp.swift`, and even a failed one leaves the next release build in the same build directory recompiling every target, since the two configurations share it. The generated API client is its own target, `ByreAPI`, so an edit to the app recompiles only the `Byre` target. SwiftPM builds release targets whole-module, so that is one compile of the whole `Byre` target, and a one-file change still costs that. The app keeps its server origin, drafts and visits in SwiftData (see "Persistence"). SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@Animatable` and the rest as macros implemented in the `SwiftUIMacros` plugin, which ships with Xcode and not with the Command Line Tools (`ls /Library/Developer/CommandLineTools/usr/lib/swift/host/plugins` lists the plugins there). `@State` is reached through its property-wrapper type instead, as `typealias ViewState = State` and `@ViewState var …`; other SwiftUI macros aren't used. `@Observable` and `@Bindable` (Observation's plugin, which the Command Line Tools have), `@Environment` and `@FocusState` work as usual. ## Resources | Value | Source | Swift | | --- | --- | --- | | API types and client | `api/openapi.yaml` | Generated at build time into the `ByreAPI` target by the `swift-openapi-generator` plugin (`mac/Sources/ByreAPI/openapi-generator-config.yaml`, public access, idiomatic naming: `display_name` is `displayName`) from the symlink `mac/Sources/ByreAPI/openapi.yaml` to the spec. App files that use it `import ByreAPI`. The output lands in `~/Library/Caches/byre/build/mac/.build/plugins/outputs/`, not in git | | Palette, light and dark | `mac/Sources/Byre/Palette.swift` | One dynamic `Color` per role, each a light and a dark hex value, resolving with the window's appearance; `Palette.task0` … `Palette.task10` are the task colours | | Font | `mac/fonts/` | The build script copies the woff2 files into the bundle; the app loads each with `CTFontManagerCreateFontDescriptorsFromURL` (see "Gaps in SwiftUI") | | App icon | `mac/icon.svg` | The build script renders it to an `.icns` with `rsvg-convert` and `iconutil` | ## Shape of the code | `mac/Sources/Byre/` | What it is | | --- | --- | | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client | | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus | | `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), the window and its server picker, the connection screen, startup sequence | | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting | | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead | | `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views | | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments | | `Questions.swift` | The question card an open question shows in the transcript: choices, Other, Let the agent decide | | `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs, line comments | | `Files.swift` | Read-only worktree browser | | `Terminal.swift` | The terminal view and its WebSocket | | `Reviews.swift` | Finding rows, finding diffs, Fix and Ignore, Stop for a running review | | `Workflow.swift` | Stage graph and attempts | | `Project.swift` | Project tab and TODO items | | `Notes.swift`, `Workflows.swift`, `Status.swift`, `Settings.swift` | Left-pane tabs | | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | | `Drafts.swift` | Composer, answer and line-comment drafts | | `Notifications.swift` | `UNUserNotificationCenter` delivery | | `Dialogs.swift`, `Format.swift` | Overlays (alerts, modals with `showConfirm` and `showChoice`, the switcher's slot, the More menu, the image view) and the window's key monitor, `ago`, Copy, shared button and layout styles, diff lines | | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay | | `Fonts.swift`, `Palette.swift` | The font and the colours | | `Persistence.swift` | The SwiftData records | ## Store and events There is one `Store` per connected server. `Store` holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: `handleEvent` with a `switch` on event kinds, `taskState`, `setFocus` with an optimistic apply and a latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`. `Feed` holds one task's events: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of `GET /api/me/lead/events` and pages back with `loadOlderLead`. Views read `Store` and `Feed` properties and SwiftUI re-renders them on change. Changes that carry a value are `Listeners` instead, whose `add` returns the function that removes the listener: `Store.events` (every event), `Feed.appended` (each batch of new stored events) and `Feed.streamed` (each `agent.delta`). Applying focus selects its task. API calls go through the generated `Client` over `URLSessionTransport`. A middleware turns any response of 400 or more into `ApiError(status, message)`, the message being the body's `Error.message` for JSON and ` : ` otherwise; the generated client wraps it in `ClientError`, and `errorText` unwraps it. Dates go through one transcoder, ISO 8601 with optional fractional seconds, which reads Go's RFC 3339 times with any number of fraction digits or none. `EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: ` and the whole startup is tried again every 2 s until it succeeds, so a server restarting (after an update replaces its binary, for one) heals by itself; Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task//` opens that tab, `#/task/` and `#/project/` keep the current tab, and anything else clears focus. Focus is shared through the server, so every Mac running the app follows it. ## Window and layout One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement with the toolbar's shared glass background hidden, so the chips sit directly on the title bar): a row of every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), each its colour dot (hollow until its connection is running) and short name (its host up to the first dot, `serverName`), then **+**, which shows the connection screen. Clicking a server shows it. Since every connection runs in the background, each server carries what needs the user there: its count of open inbox items as a badge, with the server's name in the warning colour and tint, and its count of active tasks with an unread turn summary (`Store.unreadSummaries`) as a bare number in the accent colour, in a slot two digits wide that is reserved even when the count is zero, so the picker never shifts as summaries arrive and are read. The current server's name is bold on the surface colour. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id` of its `Store`) so no view state carries from one server to another. A `Connection` remembers the `started_at` its server reported when it connected; whenever its global event stream reconnects, it reads `/api/admin/status` again, and when `started_at` has changed (the server restarted, perhaps on a new build whose migrations rewrote stored events), it builds a new `Store` that loads everything afresh, swaps it in once loaded, and stops the old one; the new `Store` takes over the old one's `Drafts` and `ImageDrafts`, so unsent composer text and pasted images survive, so no events cached from the old server are ever drawn. The content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). The connection screen has no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. - **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. - **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in their fixed order, hiding Board and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane. Overlays live in `Overlays` (`Dialogs.swift`), one environment object for each server. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which handles only key events for its own window and passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. Connections belong to `Servers`, not to the window: every saved server's store and streams run whichever one the window shows, and closing the window leaves them all running (and the app running, `applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming. Clicking the Dock icon with no window showing opens it again (`applicationShouldHandleReopen`). Quit flushes every server's drafts and reports pane focus false before exiting. ## Views - **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review (and stop while the review runs), question bubbles that follow their item's `inbox.updated` events, a finished review as its summary row followed by one row per finding, the agent's `respond_to_finding` calls as their own rows, and notes for the other event kinds. Rows that hold a review (`Row.review`) are kept per review id and updated by each later `review.*` event. It is incremental: a new batch extends the existing rows. At the transcript's top right, the attention buttons (`AttentionBar`, from `ChatModel.attention`) count what needs the user: open questions, findings of the latest review that neither the user nor the agent has answered, and the current ready-for-merge overview; each click scrolls to the next one, wrapping. Jumps from elsewhere (`Store.jumpTo` with a `JumpTarget`: a summary, a question or a snapshot's review) select the task's transcript and scroll there once the row exists. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. - **Questions.** An open question's bubble in the transcript holds its card, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. Once answered or dismissed, the bubble shrinks to the question and who answered what. - **Changes, Files, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: <status>` with New shell, 1011 for a start failure, reconnect after 2 s while the task is active, one socket per open tab and selected task. Colours come from the palette. Ctrl combinations typed in it go to the shell. The cursor is a steady block (`setCursorStyle(.steadyBlock)` when the view is made): SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion (variable refresh rate) display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. A program in the shell can still ask for a blinking cursor with DECSCUSR. - **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Transcript scrolled to its oldest open question when it has open items, to its unread summary when it has one, and at the end otherwise. - **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end. `confirmMerge` (`MergeDialog.swift`): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and jump to the review or the oldest open question in the transcript, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. ## Markdown `Markdown.swift` parses byre's dialect line by line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because the dialect (choice markers, `<details>` sections, `#/task/`, `#/todo/` and `#/suggest/` links, worktree image paths, `NO_REPLY`) matches no library. Inline content becomes an `AttributedString` with code, strong, emphasis and links. Links: `http` and `https` open in the default browser; `#/todo/<project>/<title>` becomes the TODO link with its start and discuss actions or the task's state; `#/suggest/<project>/<title>/<brief>` becomes the same, its start creating the task with the brief and its discuss messaging the lead; any other `#/` link goes through `Store.route` (see "Store and events"); anything else is plain text. The TODO actions are accent link text in the selectable run, so the line stays selectable; while one runs both lose their link and the clicked one reads "starting…" (or "sending…" for a suggestion's discuss). ## Diagrams Mermaid needs a browser DOM, so the app doesn't use it. `Diagram.swift` draws a ```` ```mermaid ```` block or an inbox `diagram` attachment itself when the source is a flowchart, the only kind agents have written so far. It parses: - the header `flowchart` or `graph` with `LR`, `RL`, `TD`/`TB` or `BT`; - nodes `A`, `A[box]`, `A(rounded)`, `A{diamond}` and `A((circle))`, with `"quoted"` labels; - edges `-->`, `---`, `-.->` and `==>`, chained (`A --> B --> C`), with labels written `-- text -->` or `-->|text|`; - `classDef`, `class` and `:::` for fill, stroke and text colour. It lays the nodes out in layers along the header's direction, orders each layer to reduce crossings, and draws with SwiftUI `Canvas`, in the palette's colours. Any other diagram type, or a flowchart line it can't parse, shows the source with `diagram not rendered: <reason>` under it. The layout isn't Mermaid's pixel for pixel. The Workflow tab's stage graph uses the same layout and drawing code, fed from the `WorkflowRun` rather than from Mermaid text. For example, this source from an inbox question: ```text flowchart LR A[ask_human] --> B{blocking?} B -- yes --> C[end turn] B -- no --> D[keep working] ``` draws a box `ask_human`, an arrow to a diamond `blocking?`, and from it an arrow labelled "yes" to a box `end turn` and one labelled "no" to a box `keep working`, in three layers from left to right. ## Images Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/image?path=` for worktree paths and `file://` URLs, `data:image/` URLs decoded in place, other schemes shown as a link with the alt text and never fetched, and "image not available: <src>" on failure. Clicking an image opens it full size in an in-window overlay (a native image view, not a browser). ## Notifications `Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens the window on its server and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running. ## Pane focus `PaneFocus.swift` sends `PUT /api/me/pane-focus` with `focused` (the window is key, the app is active and the keyboard focus is inside the right pane) and `drafting` (any right-pane text field other than the terminal is non-empty) whenever either changes, and again when the global stream reconnects. Deactivating the app, closing the window or quitting sends `focused: false`. ## Drafts `Drafts.swift` keeps drafts in a SwiftData `DraftRecord` (see "Persistence"), one per server and login: the record's `scope` is the server origin, normalized to lower-case scheme and host with the port always written, then the login: `<origin>.<login>`, for example `https://byre.example.ts.net:443.you@example.com`. Its `json` holds composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Forget Server leaves the server's drafts in place, and connecting to it again finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching. ## Keyboard and menus The app menu has About, Check for Updates…, Forget Server (for the server the window shows), Hide, Hide Others and Quit; File has Add Server… ⌘N, which shows the connection screen; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and the window. The Go commands act on the server the window shows (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that server's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server switch the window to the saved server before or after the current one, in saved order, wrapping round; from the connection screen they go to the first. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection On first launch, and from Add Server…, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field. ## Updates `Updater.swift` runs Sparkle's standard updater (`SPUStandardUpdaterController`), started at launch. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`). ## Persistence `Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`). ## Gaps in SwiftUI Places where SwiftUI alone may fall short, each checked side by side against its fallback; the fallback is used only where the check failed: | Need | SwiftUI | Fallback | Spike result | | --- | --- | --- | --- | | Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, sending from `doCommand(by: insertNewline:)`, which the input method never reaches while it holds marked text | No fallback: `onKeyPress(.return)` runs before the `TextEditor`'s `NSTextView` and its input context. It sends, and returns `.ignored` for Shift so the text view adds the newline (checked by hand). Because it runs first, it must also return `.ignored` while the focused text view's `hasMarkedText()` is true, or Return sends the draft mid-composition. An in-process test that set marked text with `setMarkedText` and delivered a Return `NSEvent` to the window showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one sent nothing and passed Return on to the text view. From there the input method handles Return as it does in any `NSTextView`, so the `NSTextView` fallback would behave the same. The guard reads the key window's first responder, which is the composer while the user types | | Image paste and drop into the composer | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | No fallback, but not through these two modifiers: `TextEditor`'s own text view takes ⌘V and drops before `onPasteCommand` and `onDrop` and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (`CommandGroup(replacing: .pasteboard)` with Cut, Copy, Paste and Select All sent to the first responder): Paste attaches the clipboard's image when the composer has focus (`focusedValue`) and otherwise sends the ordinary `paste:`, so text paste works in every field and the terminal. Drops land on a `Color.clear` overlay on the `TextEditor` with `onDrop` and `allowsHitTesting(false)`: it still receives the drag, and clicks and typing pass through to the editor (with hit testing on, the editor can't be clicked). Both attach thumbnails and upload to `POST /api/blobs` | | Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `VStack` in a `ScrollView` | `List` with its own row reuse | No fallback: the stack stays at the bottom while rows arrive and a message streams (`ScrollPosition.scrollTo(edge: .bottom)` on each change while within 80 points of the bottom, tracked with `onScrollGeometryChange`), and stays put when scrolled up. A `LazyVStack` jumped while scrolling up through rows it hadn't shown yet, as it corrected their estimated heights | | Selecting text across blocks and messages | `textSelection(.enabled)` selects within one `Text` only | A selectable `NSTextView` in an `NSViewRepresentable` | Fallback used within a message: `MarkdownView` draws each run of consecutive paragraphs, headings and lists (bullets as text, hanging indents from tab stops and paragraph styles) as one `NSAttributedString` in one non-editable `FlowText` text view, so a drag crosses blocks and bullets. Code blocks, diffs, tables, quotes, diagrams, `<details>`, paragraphs with images and choice lists stay SwiftUI views and end a run. Messages are still separate views, so a drag selects within one run of text in one message and each message is copied on its own | | The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | Fallback used, as planned: SwiftTerm over `URLSessionWebSocketTask` gives a working shell; Ctrl-C, Ctrl-L, Ctrl-K and Ctrl-R reach it. A local `keyDown` monitor takes Ctrl-K/P/L/S// as Go shortcuts except while the first responder is the `TerminalView` | | Font from `mac/fonts/` | Core Text | Convert the files to TTF in the build script if Core Text refuses them | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) | | SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | No fallback: `NSImage(data:)` decodes the SVG from the worktree image endpoint (`_NSSVGImageRep`) and draws it sharp at any size | The font files need one thing beyond loading. The latin and latin-ext files are subsets of one variable font with the same PostScript names, so registering both and looking the font up by name finds only the latin file, and characters such as `ő` and `Ł` fall back to a system font. The app instead makes a descriptor from each file's URL, sets the weight with `CTFontDescriptorCreateCopyWithVariation`, and puts the latin-ext descriptor in the latin one's `kCTFontCascadeListAttribute`; characters in neither file then fall back to the system cascade (`→` to Menlo). `Fonts.swift` does this. SwiftUI gets the result through `Font(CTFont)`. ## Building and checking `mac/bundle.sh` is the compile check for changes under `mac/`, alongside the checks `AGENTS.md` already names. No tests are added. Installing the app is a separate action from deploying the byre service. ## Not built - A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files.