Add the macOS client spike and record its SwiftUI gap results
This commit is contained in:
+21
-16
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
This is a design. What exists so far is the spike under `mac/`: a harness window with one tab per row of "Gaps in SwiftUI" (composer, transcript, terminal, font, SVG), not the client. The work to build the client is under "M8 — macOS client" in `TODO.md`.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -13,12 +13,14 @@ The first version copies the web client: the same two equal panes, tabs, labels,
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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. `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`) and signs it ad hoc; notification permission needs the bundle identifier. The app keeps its server origin in the `byre.server` default (`defaults read run.fc.byre.mac byre.server` prints it).
|
||||
|
||||
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.
|
||||
|
||||
## One source for shared values
|
||||
|
||||
@@ -28,7 +30,7 @@ Values the web client defines are generated into Swift, never copied by hand, so
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
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/`.
|
||||
@@ -87,7 +89,7 @@ Closing the window leaves the app running with its store and streams, so notific
|
||||
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.
|
||||
- **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.
|
||||
@@ -138,7 +140,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
|
||||
|
||||
## 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.
|
||||
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 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
|
||||
|
||||
@@ -146,17 +148,19 @@ On first launch the window shows one field, Server URL (an `https` origin with n
|
||||
|
||||
## 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:
|
||||
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. The spike's tabs put the SwiftUI way and the fallback side by side and log each key press, paste, drop and upload they see:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 | Confirmed: a drag stops at the edge of one message. 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 |
|
||||
|
||||
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` in the spike does this. SwiftUI gets the result through `Font(CTFont)`.
|
||||
|
||||
## Differences from the web client
|
||||
|
||||
@@ -169,6 +173,7 @@ Platform differences the copy can't avoid, each with what the web client does an
|
||||
| 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 |
|
||||
| Selecting transcript text | A drag selects across any number of messages | A drag selects within one message; each message is copied on its own |
|
||||
|
||||
## Building and checking
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
|
||||
- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows").
|
||||
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `favicon.svg` (a lead dot in the accent colour with spokes to five task-coloured dots; its colours are saturated versions of the `web/style.css` accent and task hues, not copies of the palette, since the palette's pastels blur together at 16px; they switch with the light/dark scheme), `favicon.png` (the same icon in its dark colours on an opaque dark `--surface` square at 180×180, rendered from `web/favicon-tile.svg`, which is not embedded; opaque because Orion puts a transparent icon on a white tile; linked as a second icon and as the `apple-touch-icon`, which Orion prefers over the SVG for its tab icon; Orion's icon store (`~/Library/Application Support/Orion/Defaults/website_icons`, kept apart from its cache) keys icons by hostname without the port, so another server on the same hostname can supply byre's tab icon; `favicon-tile.svg` repeats the shapes and dark colours of `favicon.svg`, so change both together, then regenerate the PNG with `rsvg-convert --width 180 --height 180 --output web/favicon.png web/favicon-tile.svg`), `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
|
||||
- There is no macOS client yet; `docs/macos.md` is its design.
|
||||
- There is no macOS client yet; `docs/macos.md` is its design. `mac/` holds the spike that checks the design's SwiftUI gaps: `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into it (the same `tailscale serve` URL a browser uses).
|
||||
|
||||
## Data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user