Files
byre/docs/macos.md
T

209 lines
36 KiB
Markdown
Raw Normal View History

2026-10-16 14:55:17 -07:00
# byre for macOS
This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting, the identity banner, every other tab (Changes, Files, Terminal, Inbox, Reviews, Workflow with its stage graph drawn natively, Project, Status and Settings), every overlay (the switcher, keyboard shortcuts, alerts, confirmations, the Merge and Discard dialogs, the More menu and the full-size image view), the Go menu with its Ctrl variants, and notifications. The rest of the work is under "M8 — macOS client" in `TODO.md`.
2026-10-16 14:55:17 -07:00
## Decision
The app is a native SwiftUI client. It talks to the same server and API as the web client in `web/`, and reproduces that client's layout and behaviour in SwiftUI views. **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.
2026-10-16 14:55:17 -07:00
The server doesn't change. The app connects to byre's `tailscale serve` HTTPS URL, the same one a browser uses, so its requests get the same Tailscale identity headers (see "Identity" in `docs/operations.md`). It never sends identity headers itself, and never shows a login.
2026-10-16 14:55:17 -07:00
The first version copies the web client: the same two equal panes, tabs, labels, empty states, task colours, palette, font, keyboard shortcuts and confirmations, driven by the same events. Where the platform forces a difference, it is listed under "Differences from the web client". The app adds nothing the web client lacks.
2026-10-16 14:55:17 -07:00
## Stack
2026-10-16 14:55:17 -07:00
- 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, and SwiftTerm for the terminal.
2026-10-16 14:55:17 -07:00
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 and assembles `mac/.build/byre.app` (the executable, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac`, the `web/fonts/` files and an `.icns` rendered from `web/favicon-tile.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier.
The app is signed with the team's Apple Development certificate (`codesign --sign "Apple Development"`), with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit, and development push for sync), and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. The profile is a development profile Xcode manages: it allows the team's Apple Development certificates and the Macs registered to the team, so it runs only on those Macs, and its CloudKit environment is Development. `security cms -D -i mac/byre.provisionprofile` prints it, `ProvisionedDevices` and `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Building on a Mac not yet in the profile, or one without an Apple Development certificate, means regenerating it with Xcode's automatic signing: an Xcode app project with bundle identifier `run.fc.byre.mac`, the team, and an entitlements file naming the iCloud container with CloudKit, built with `xcodebuild -allowProvisioningUpdates -allowProvisioningDeviceRegistration` while the team's Apple ID is signed in to Xcode, creates the certificate, registers the Mac and writes the new profile to `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`; copy it over `mac/byre.provisionprofile` and commit it.
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 recompiling every target, since the two configurations share `mac/.build`. 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.
2026-10-16 14:55:17 -07:00
## One source for shared values
2026-10-16 14:55:17 -07:00
Values the web client defines are generated into Swift, never copied by hand, so a change in `web/` or the spec reaches the app on its next build:
2026-10-16 14:55:17 -07:00
| Value | Source | Swift |
2026-10-16 14:55:17 -07:00
| --- | --- | --- |
| 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 `mac/.build/plugins/outputs/`, not in git |
2026-10-16 22:46:54 -07:00
| Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` (`mac/palette`) writes `Palette.swift`: one dynamic `Color` per hex property, `--accent-tint` as `Palette.accentTint`, resolving light or dark with the window's appearance. Properties that aren't a hex colour (`--task`, `--shadow`, `--font`) are left out. The file is committed, so `swift build` needs no Go; a property missing from either block fails the generator |
| Font | `web/fonts/` | The build script copies the woff2 files into the bundle; the app loads each with `CTFontManagerCreateFontDescriptorsFromURL` (see "Gaps in SwiftUI") |
| App icon | `web/favicon-tile.svg` | The build script renders it to an `.icns` with `rsvg-convert` and `iconutil` |
2026-10-16 14:55:17 -07:00
Behaviour constants (the model list in `web/src/task.ts`, reconnect and refresh timings in `api.ts`, `changes.ts` and `files.ts`, the image limits in `task.ts`) live in TypeScript, where nothing can generate them. The Swift port keeps each in one place, named as in the TypeScript, and a change to one in `web/` needs the same change in `mac/`.
2026-10-16 14:55:17 -07:00
## Shape of the code
2026-10-16 14:55:17 -07:00
The Swift files follow the TypeScript ones, so a change in one points at the same place in the other:
2026-10-16 14:55:17 -07:00
| `web/src/` | `mac/Sources/Byre/` | What it is |
2026-10-16 14:55:17 -07:00
| --- | --- | --- |
| `api.ts` | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client |
| `state.ts` | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus |
| `app.ts` | `ByreApp.swift`, `Startup.swift` | The `App`, window, menus, startup sequence, identity banner |
| `panes.ts` | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting |
| `task.ts`, `lead.ts` | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead |
| `markdown.ts` | `Markdown.swift`, `MarkdownView.swift` | A line-for-line port of the parser to a block tree, and its views |
| `mermaid.ts` | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments |
| `inbox.ts` | `Inbox.swift` | One item at a time, choices, Other, Let the agent decide |
| `changes.ts` | `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs |
| `files.ts` | `Files.swift` | Read-only worktree browser |
| `terminal.ts` | `Terminal.swift` | The terminal view and its WebSocket |
| `reviews.ts` | `Reviews.swift` | Review cards, finding diffs, Fix and Ignore |
| `workflow.ts` | `Workflow.swift` | Stage graph and attempts |
2026-10-17 10:54:06 -07:00
| `project.ts`, `todolink.ts` | `Project.swift`, `MarkdownView.swift`, `Store.swift` | Project tab and TODO links |
| `status.ts`, `settings.ts` | `Status.swift`, `Settings.swift` | Left-pane tabs |
| `switcher.ts` | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) |
| `drafts.ts` | `Drafts.swift` | Composer and answer drafts |
| `notifications.ts` | `Notifications.swift` | `UNUserNotificationCenter` delivery |
| `dom.ts` | `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 |
2026-10-16 23:30:57 -07:00
| `images.ts` | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay |
2026-10-16 14:55:17 -07:00
## Store and events
2026-10-16 14:55:17 -07:00
`Store` holds what `state` in `web/src/state.ts` holds (me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads, connection) and ports its functions one for one: `handleEvent` with the same `switch` on event kinds, `taskState` with the same precedence and labels, `setFocus` with the optimistic apply and the latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`.
2026-10-16 14:55:17 -07:00
2026-10-16 22:46:54 -07:00
`Feed` ports the class of the same name: 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`.
2026-10-16 14:55:17 -07:00
2026-10-16 22:46:54 -07:00
The web client's topics become Observation: views read `Store` and `Feed` properties and SwiftUI re-renders them on change. The topics that carry a value are `Listeners` instead, whose `add` returns the function that removes the listener: `Store.events` (every event, the `event` topic), `Feed.appended` (each batch of new stored events, `transcript` and `lead.transcript`) and `Feed.streamed` (each `agent.delta`, `stream` and `lead.stream`). Applying focus selects its task, which is what the web client's hash route does.
2026-10-16 14:55:17 -07:00
2026-10-16 22:46:54 -07:00
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 `<status> <reason>: <body text>` otherwise, as `request` in `api.ts` does; 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`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold (40 s, checked every 5 s) and 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. As in the web client 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 follows `main` in `app.ts`: `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: <error>`, as the web page does; Change Server… leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`, as `state.ts` does.
2026-10-16 22:46:54 -07:00
The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
2026-10-16 14:55:17 -07:00
2026-10-17 10:54:06 -07:00
There is no URL hash. The web client mirrors focus into `#/task/<id>`; the app keeps focus in `Store` only. In-app `#/` links go through `Store.route`, a port of `route` in `app.ts`: `#/task/<id>/<tab>` opens that tab, `#/task/<id>` and `#/project/<id>` keep the current tab, and anything else clears focus. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do.
2026-10-16 14:55:17 -07:00
## Window and layout
2026-10-16 14:55:17 -07:00
One window. The panes follow the "Web 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. The title bar is the standard one; no byre controls go in it or in a toolbar.
2026-10-16 14:55:17 -07:00
- **Left pane:** tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip.
- **Right pane:** the task bar (task picker `<project> ▸ <title> ▾` and the preview links), then the task's tabs in the server's focus order, hiding Inbox, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges, as in `taskPane`. The pane's accent is the selected task's colour.
2026-10-16 14:55:17 -07:00
2026-10-17 08:51:59 -07:00
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, as in the web client; the merge dialog is centered in the right pane.
2026-10-16 14:55:17 -07:00
Overlays live in `Overlays` (`Dialogs.swift`), one environment object for the window. 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 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.
Closing the window leaves the app running with its store and streams (`applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting.
2026-10-16 14:55:17 -07:00
## Views
2026-10-16 14:55:17 -07:00
Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states.
2026-10-16 14:55:17 -07:00
- **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as 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 notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up. While the user is scrolling (`onScrollPhaseChange`), content height changes from rows the `LazyVStack` lays out late only update whether it is stuck and never pull it back to the bottom. 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 the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips.
- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), 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 up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error.
- **Inbox.** The selected task's oldest open item, 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, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it.
- **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, 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`, following `terminal.ts`: 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 the same delay while the task is active, one socket per open tab and selected task. Colours come from the palette as in the web client. Ctrl combinations typed in it go to the shell.
2026-10-17 08:34:49 -07:00
- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: the same ordering (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 Inbox when it has items, its unread summary when it has one, and its Transcript otherwise.
2026-10-17 10:54:06 -07:00
- **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, and labelled and disabled as in `taskHead`. `confirmMerge` (`MergeDialog.swift`) is ported whole: 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 link to the Reviews or Inbox tab, 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.
2026-10-16 14:55:17 -07:00
## Markdown
2026-10-16 14:55:17 -07:00
2026-10-17 10:54:06 -07:00
`Markdown.swift` ports `markdown.ts` line for line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because byre's 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 link text in the selectable run, with the web's tooltips; while one runs both lose their link and the clicked one reads "starting…" (or "sending…" for a suggestion's discuss). The web draws them as small bordered buttons.
2026-10-16 14:55:17 -07:00
## Diagrams
2026-10-16 14:55:17 -07:00
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:
2026-10-16 14:55:17 -07:00
- 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.
2026-10-16 14:55:17 -07:00
It lays the nodes out in layers along the header's direction, orders each layer to reduce crossings, and draws with SwiftUI `Canvas`, using the palette roles the web client gives Mermaid in `mermaid.ts`. Any other diagram type, or a flowchart line it can't parse, shows the source with `diagram not rendered: <reason>` under it, the web client's own fallback for source Mermaid rejects. 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.
2026-10-16 14:55:17 -07:00
For example, this source from an inbox question:
2026-10-16 14:55:17 -07:00
```text
flowchart LR
A[ask_human] --> B{blocking?}
B -- yes --> C[end turn]
B -- no --> D[keep working]
```
2026-10-16 14:55:17 -07:00
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.
2026-10-16 14:55:17 -07:00
## Images
2026-10-16 14:55:17 -07:00
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).
2026-10-16 14:55:17 -07:00
## Notifications
2026-10-16 14:55:17 -07:00
`Notifications.swift` ports `notifications.ts` onto `UNUserNotificationCenter`: the same preferences from `GET /api/me`, the same events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, and the item id or `turn-<seq>` as the request identifier, so a repeat replaces the earlier one as the browser's `tag` does. They show as banners even while byre is the active app, as browser notifications do over the page (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, shows the window (reopening it if it was closed) and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its task until startup has a store, then focuses it. Settings shows the authorization state and a button that requests it, in place of the browser permission flow. As in the browser, nothing notifies while the app isn't running.
2026-10-16 14:55:17 -07:00
## Pane focus
2026-10-16 14:55:17 -07:00
`PaneFocus.swift` ports `trackPaneFocus`: it 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`.
2026-10-16 14:55:17 -07:00
## Drafts
2026-10-16 14:55:17 -07:00
`Drafts.swift` ports `drafts.ts` with a SwiftData `DraftRecord` in place of `localStorage` (see "Persistence"). `localStorage` is per origin, so 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 the web client's JSON object. 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. Change Server… leaves the old server's drafts in place, and switching back finds them. The structure is the web client's: 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. Pasted images aren't kept, as in the browser; after a relaunch the composer says how many need re-attaching.
2026-10-16 14:55:17 -07:00
## Keyboard and menus
2026-10-16 14:55:17 -07:00
2026-10-17 10:54:06 -07:00
The app menu has About, Change Server…, Hide, Hide Others and Quit; 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 and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an alert or modal is open (`dialogOpen`), as `bindShortcuts` ignores them while a dialog is open; the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants the web client also accepts (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.
2026-10-16 14:55:17 -07:00
## Connection
2026-10-16 14:55:17 -07:00
On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. 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 kept as the app's one `ServerRecord` (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) looks for a `ServerRecord` again and starts against it, so on a new Mac the address another Mac saved connects without typing.
## Persistence
2026-10-17 10:54:06 -07:00
`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` (the server origin), `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 newest `ServerRecord` and `DraftRecord` and the latest `VisitRecord` per task, and the next write keeps one and deletes the rest. 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. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (`applicationShouldTerminate` in `ByreApp.swift`).
2026-10-16 14:55:17 -07:00
## Gaps in SwiftUI
2026-10-16 14:55:17 -07:00
2026-10-16 23:30:57 -07:00
Places where SwiftUI alone may not match the web client, each checked side by side against its fallback; the fallback is used only where the check failed:
2026-10-16 14:55:17 -07:00
| 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 | `LazyVStack` in a `ScrollView` | `List` with its own row reuse | No fallback: on a 5258-event task, `LazyVStack` scrolls smoothly, 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. `List` passed too |
2026-10-17 09:15:50 -07:00
| 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; listed under "Differences from the web client" |
| 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 `web/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 |
2026-10-16 23:30:57 -07:00
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)`.
2026-10-16 14:55:17 -07:00
## Differences from the web client
2026-10-16 14:55:17 -07:00
Platform differences the copy can't avoid, each with what the web client does and what the app does:
2026-10-16 14:55:17 -07:00
| Where | Web client | App |
| --- | --- | --- |
| Settings, notifications line | `This browser shows notifications while a byre page is open.` | `This Mac shows notifications while byre is running.` |
2026-10-17 07:26:18 -07:00
| Settings, notifications not yet allowed | `This browser doesn't show notifications.` and a **Turn on for this browser** button | `This Mac doesn't show notifications from byre.` and a **Turn on for this Mac** button, which asks macOS for permission |
| Settings, notifications refused | `Notifications are blocked for this site in the browser's settings.` | `Notifications are blocked for byre in System Settings.` |
| Opening a task by address | `#/task/<id>` in the URL bar | None; tasks open from the switcher, links and notifications |
| Clicking a transcript image | Opens the image in a new browser tab | Opens it in an in-window overlay |
| Preview links (`expose_port`) | Open in a new browser tab | Open in the default browser |
| Window frame | Browser chrome and tabs | One macOS window with a standard title bar |
2026-10-17 10:54:06 -07:00
| Merge dialog | Just below the Merge button | Centred in the right pane |
| TODO and suggestion link actions | Small bordered **start** and **discuss** buttons | Accent link text, so the line stays selectable |
| Pasted or dropped images | PNG, JPEG, GIF and WebP; anything else is refused | The same four, and any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG, its size checked after conversion |
2026-10-17 09:15:50 -07:00
| Selecting transcript text | A drag selects across any number of messages | A drag selects within one run of text in one message, ending at a code block, table, quote, diagram or `<details>`; each message is copied on its own |
2026-10-16 14:55:17 -07:00
## Building and checking
2026-10-16 14:55:17 -07:00
`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.
2026-10-16 14:55:17 -07:00
## Not in version one
- Anything the web client doesn't do: a Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files.
- Several servers or windows.
- Automatic updates.