macOS client parity review
This commit is contained in:
+9
-6
@@ -58,7 +58,7 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -82,7 +82,7 @@ Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects,
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Window and layout
|
||||
|
||||
@@ -107,11 +107,11 @@ Each view does what its TypeScript counterpart does, with the same text, refresh
|
||||
- **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, 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.
|
||||
- **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 sits just below the Merge button, or centred when ⌘S opens it with the button off screen. Discard confirms below its button, then clears the focused task.
|
||||
- **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.
|
||||
|
||||
## 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/`, `#/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; `#/task/<id>` focuses the task; `#/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; anything else is plain text.
|
||||
`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.
|
||||
|
||||
## Diagrams
|
||||
|
||||
@@ -153,7 +153,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, 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 overlay is open, as `bindShortcuts` ignores them while a dialog is open. 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.
|
||||
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.
|
||||
|
||||
## Connection
|
||||
|
||||
@@ -161,7 +161,7 @@ On first launch the window shows one field, Server URL (an `https` origin with n
|
||||
|
||||
## 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` (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 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`).
|
||||
`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`).
|
||||
|
||||
## Gaps in SwiftUI
|
||||
|
||||
@@ -192,6 +192,9 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## Building and checking
|
||||
|
||||
+9
-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`.
|
||||
- `mac/` is the macOS client in progress; `docs/macos.md` is its design and says what exists. `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into its connection screen (the same `tailscale serve` URL a browser uses). Its API client is generated from `api/openapi.yaml` at build time, and `go generate ./mac` regenerates the committed `mac/Sources/Byre/Palette.swift` from the custom properties in `web/style.css`, so run it after changing the palette. The app keeps its state in SwiftData synced through the iCloud private database of container `iCloud.run.fc.byre.mac` (the CloudKit console shows its records): the server origin, last task visits, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence"). Pasted images stay in memory with the connection and are dropped on Change Server… and quit. It reports `PUT /api/me/pane-focus` as the web client does, and sends `focused: false` on Change Server…, on closing the window and on quit.
|
||||
- `mac/` is the macOS client; `docs/macos.md` is its design and says what exists, and "macOS app" below covers building, installing and its storage. It connects to the byre `https` origin typed into its connection screen (the same `tailscale serve` URL a browser uses). It reports `PUT /api/me/pane-focus` as the web client does, and sends `focused: false` on Change Server…, on closing the window and on quit.
|
||||
|
||||
## Data
|
||||
|
||||
@@ -111,6 +111,14 @@ After rebuilding: `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/
|
||||
|
||||
Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.
|
||||
|
||||
## macOS app
|
||||
|
||||
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` and assembles `mac/.build/byre.app`: the `Byre` executable, `mac/Info.plist`, the `web/fonts/` woff2 files, an icon rendered from `web/favicon-tile.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build fetches the Swift packages pinned in `mac/Package.resolved` and compiles the generated API client; later builds recompile only the `Byre` target. The API client is generated from `api/openapi.yaml` at build time, and `go generate ./mac` regenerates the committed `mac/Sources/Byre/Palette.swift` from the custom properties in `web/style.css`, so run it after changing the palette.
|
||||
|
||||
**Installing.** The app runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. Nothing copies it to `/Applications`. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
|
||||
|
||||
**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): the server origin, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Development environment; the CloudKit console (icloud.developer.apple.com, the container, Development, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Change Server… and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
|
||||
|
||||
## Previews
|
||||
|
||||
An agent shows the human a server it runs with the `expose_port` MCP tool (`internal/agents/preview.go`): `name` labels it, `port` is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's `claude` process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when `port` is outside the task's block or nothing accepts a connection on `127.0.0.1:<port>`. Otherwise byre runs `tailscale serve --bg --https=<port> http://127.0.0.1:<port>` (`internal/tailscale`, which runs the macOS CLI at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`, since byre's environment has no `tailscale` on its `PATH`, with `TAILSCALE_BE_CLI=1`, without which the app executable launched from the service exits 0 without running the subcommand), then reads `tailscale serve status --json` and fails the call if no `Web` host for that port is listed, so the server is at `https://<byre's ts.net name>:<port>/` with Tailscale's identity headers, and records a row in the `previews` table (task, name, port, time) with a `preview.exposed` event, which the transcript shows as a note linking the URL. Each port gets its own HTTPS port rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, and a port it no longer uses is turned off with `tailscale serve --https=<port> off`. A server must listen on `127.0.0.1`: while `tailscale serve` holds a port, binding `0.0.0.0` on it fails with "address already in use". When the task is merged or discarded, byre turns off each of its ports (`Manager.Unexpose`); the rows stay. `GET /api/tasks/{taskId}/previews` lists a task's rows by name, and `tailscale serve status` shows what is served.
|
||||
|
||||
Reference in New Issue
Block a user