Files
byre/docs/macos.md
T

20 KiB
Raw Blame History

byre for macOS

This is a design. Nothing in it is implemented: there is no macOS client. The work to build it is under "M8 — macOS client" in TODO.md.

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.

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.

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.

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.
  • 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.

The host has the Command Line Tools and no Xcode (xcode-select --print-path prints which), so the project is a Swift package under mac/, built with swift build, not an Xcode project. A script in mac/ assembles byre.app (executable, Info.plist with a fixed bundle identifier, the font and the icon) and signs it ad hoc; notification permission needs the bundle identifier.

One source for shared values

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:

Value Source Swift
API types and client api/openapi.yaml Generated at build time by the swift-openapi-generator plugin from a symlink to the spec
Palette, light and dark Custom properties at the top of web/style.css (:root and its prefers-color-scheme: dark block) go generate ./mac writes Palette.swift: one dynamic Color per property, resolving light or dark with the window's appearance
Font web/fonts/ The build script copies the files into the bundle and the app registers them with Core Text at launch
App icon web/favicon-tile.svg The build script renders it to an .icns with rsvg-convert and iconutil

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/.

Shape of the code

The Swift files follow the TypeScript ones, so a change in one points at the same place in the other:

web/src/ mac/Sources/Byre/ What it is
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
project.ts, todolink.ts Project.swift, TodoLink.swift Project tab and TODO links
status.ts, settings.ts Status.swift, Settings.swift Left-pane tabs
switcher.ts Switcher.swift ⌘K overlay and the shortcuts help
drafts.ts Drafts.swift Composer and answer drafts
notifications.ts Notifications.swift UNUserNotificationCenter delivery
dom.ts Dialogs.swift, Format.swift Alert and confirm overlays, ago, byte sizes, Copy

Store and events

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.

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.

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. 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 and retry delay, when the network comes back (NWPathMonitor), and when the app becomes active with a stale stream. As in the web client there are two streams, global (after the bootstrap last_event_seq) and lead (?lead=true, after=0), 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.

There is no URL hash. The web client mirrors focus into #/task/<id>; the app keeps focus in Store only, and in-app task links call focusTask directly. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do.

Window and layout

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.

  • 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.

Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, positioned as in the web client (the switcher and the Merge and Discard confirmations just below their anchor), not system sheets or alerts.

Closing the window leaves the app running with its store and streams, so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting.

Views

Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states.

  • 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, 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, with pending sends shown 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. 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.
  • Switcher. The ⌘K overlay with the same ordering (tasks with inbox items first, then least recently visited), the name: title project prefix, New task, Start workflow… and its second step, projects, and Escape stepping back.
  • Merge and Discard. confirmMerge ported whole: the editable commit title, co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, and the parent-or-default-branch choice for subtasks.

Markdown

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/ and #/todo/ 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; #/task/<id> focuses the task; #/todo/<project>/<title> becomes the TODO link with its start and discuss actions or the task's state; anything else is plain text.

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, 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.

For example, this source from an inbox question:

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: " on failure. Clicking an image opens it full size in an in-window overlay (a native image view, not a browser).

Notifications

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 cut at 200 characters, and the item id or turn-<seq> as the request identifier. Clicking one shows the window and focuses the task. 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.

Pane focus

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.

Drafts

Drafts.swift ports drafts.ts with UserDefaults in place of localStorage. localStorage is per origin, so the key adds the server origin, normalized to lower-case scheme and host with the port always written, in front of the web client's key: byre.drafts.<origin>.<login>, for example byre.drafts.https://byre.example.ts.net:443.you@example.com. 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.

Keyboard and menus

The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items; 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 do nothing while an overlay is open, as bindShortcuts ignores them while a dialog is open. The Ctrl variants the web client also accepts work the same way through a local key monitor, except while the terminal has focus, where Ctrl combinations go to the shell. Escape closes the open overlay.

Connection

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). Certificate validation is never relaxed. The origin is kept in UserDefaults. Change Server… flushes drafts, reports pane focus false, drops the store and streams, and starts over against the new origin.

Gaps in SwiftUI

Places where SwiftUI alone may not match the web client. Each is checked in the spike before the rest is built, and the fallback is used only if the check fails:

Need SwiftUI Fallback
Enter sends, Shift-Enter newline, nothing during IME composition TextEditor with onKeyPress NSTextView in an NSViewRepresentable, checking hasMarkedText()
Image paste and drop into the composer onPasteCommand and onDrop The same NSTextView, overriding paste(_:) and drag handling
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
Selecting text across several messages textSelection(.enabled) selects within one Text only None found in SwiftUI; listed as a difference below if the spike confirms it
The terminal No terminal view in SwiftUI SwiftTerm's AppKit TerminalView in an NSViewRepresentable
Font from web/fonts/ Core Text registration Convert the files to TTF in the build script if Core Text refuses them
SVG images from the worktree image endpoint NSImage(data:) Show the "image not available" text

Differences from the web client

Platform differences the copy can't avoid, each with what the web client does and what the app does:

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.
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

Building and checking

swift build --package-path mac 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 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.