diff --git a/docs/macos.md b/docs/macos.md index d07c0af..43d4133 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -41,7 +41,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | --- | --- | | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client | | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus | -| `ByreApp.swift`, `Startup.swift` | The `App`, window, menus, startup sequence, identity banner | +| `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), server windows, the connection screen, startup sequence | | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting | | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead | | `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views | @@ -64,7 +64,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An ## Store and events -`Store` holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: `handleEvent` with a `switch` on event kinds, `taskState`, `setFocus` with an optimistic apply and a latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`. +There is one `Store` per connected server. `Store` holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: `handleEvent` with a `switch` on event kinds, `taskState`, `setFocus` with an optimistic apply and a latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`. `Feed` holds one task's events: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of `GET /api/me/lead/events` and pages back with `loadOlderLead`. @@ -74,7 +74,7 @@ API calls go through the generated `Client` over `URLSessionTransport`. A middle `EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. -Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `; Change Server… leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`. +Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `; connecting to the same URL again from New Server Window restarts it, and Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. @@ -82,20 +82,22 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/ ## Window and layout -One window. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. +One window per saved server, sharing no UI: the windows are a `WindowGroup` (id `main`) whose value is the server's origin, so opening a server's window again brings forward the one already open. Each window has its own `Overlays`, so its switcher, dialogs, left-pane tab and key monitor act only on it, and its own pane-focus reporting. The window's title is the server's host, so the Window menu lists the servers, and its content sits inside a 4-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). A window with no server (New Server Window, or after Forget Server) shows the connection screen with no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. + +At launch the first window takes the oldest saved server and opens a window for each of the others. A CloudKit import that brings a server saved on another Mac starts its connection, and a window showing an empty connection screen takes it. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that the single-window builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. - **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. - **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane. -Overlays live in `Overlays` (`Dialogs.swift`), one environment object for 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. +Overlays live in `Overlays` (`Dialogs.swift`), one environment object for each 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 handles only key events for its own window and passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. -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. +Connections belong to `Servers`, not to windows: closing a server's window leaves its store and streams running (and the app running after the last window, `applicationShouldTerminateAfterLastWindowClosed` returns false), so its notifications keep coming. Clicking the Dock icon with no window showing opens every saved server's window again (`applicationShouldHandleReopen`), and File → Open Server opens one. Quit flushes every server's drafts and reports pane focus false before exiting. ## Views -- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. +- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. - **Questions.** The question a transcript bubble picked (`Store.question`), read-only once it is no longer open, or else 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 and Other…. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** 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. @@ -135,7 +137,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Notifications -`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, and the item id or `turn-<seq>` as the request identifier, so a repeat replaces the earlier one. They show as banners even while byre is the active app (`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 (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running. +`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens or brings forward its server's window and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running. ## Pane focus @@ -143,15 +145,15 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Drafts -`Drafts.swift` keeps drafts in a SwiftData `DraftRecord` (see "Persistence"), one per server and login: the record's `scope` is the server origin, normalized to lower-case scheme and host with the port always written, then the login: `<origin>.<login>`, for example `https://byre.example.ts.net:443.you@example.com`. Its `json` holds composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Change Server… leaves the old server's drafts in place, and switching back finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching. +`Drafts.swift` keeps drafts in a SwiftData `DraftRecord` (see "Persistence"), one per server and login: the record's `scope` is the server origin, normalized to lower-case scheme and host with the port always written, then the login: `<origin>.<login>`, for example `https://byre.example.ts.net:443.you@example.com`. Its `json` holds composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Forget Server leaves the server's drafts in place, and connecting to it again finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching. ## Keyboard and menus -The app menu has About, Check for Updates…, 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`); 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 (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, Check for Updates…, Forget Server (for the focused window's server), Hide, Hide Others and Quit; File has New Server Window ⌘N, which opens a window with the connection screen, and Open Server, listing every saved server by host; 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 and one entry per open window. The Go commands act on the focused window (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that window's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection -On first launch the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is 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. +On first launch, and in a window from New Server Window, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window becomes that server's; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a server that already has a window brings that window forward and closes this one, so each server has one window and one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and a window showing the connection screen with an empty field takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the window's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen in that window with the old origin in the field. ## Updates @@ -159,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 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`). +`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`). ## Gaps in SwiftUI @@ -183,5 +185,4 @@ The font files need one thing beyond loading. The latin and latin-ext files are ## Not built -- A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files. -- Several servers or windows. +- A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files. \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md index 72fd6f2..d9d8b4e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -146,9 +146,9 @@ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 an **Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/Byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, and `byre-hands`, built from `hands/`, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour. -**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). 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. +**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, one window each. Closing the windows 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 Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, 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. +**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): each saved server's origin and window colour, 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 Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, 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 Forget Server and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. ## Screen and app control @@ -183,7 +183,7 @@ A task's build of byre runs alongside the service with its own empty state, from go run ./cmd/byre serve --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000 ``` -It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The macOS app's Change Server… connects to its exposed URL. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout. +It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The macOS app's New Server Window connects to its exposed URL, beside the service's window. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout. ## Identity @@ -252,7 +252,7 @@ The task switcher (⌘K, or the project ▸ task picker centred in the right pan **Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Merge after review** (`merge_after_review`), **Working** (the task's `state` is `running`, `review_pending`, `reviewing` or `discarding`; see "Task state in the store"), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Working** and **Merging**. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch. -**Unread summaries.** A Task's `summary_seq` is the seq of its latest `turn.ended` event with `summary: true` (0 for none), read through the partial index `events_summary`, so the app knows of unread summaries without loading transcripts; a `turn.ended` with `summary: true` on the global stream raises it in the app. The switcher's and the Project tab's task rows show a **new summary** state label while `summary_seq` is above the seq the user has read (`unreadSummary` in `mac/Sources/Byre/Store.swift`), except on tasks of a workflow run, which close too soon for their summaries to be read. Picking such a row (in the switcher, only when the task has no open inbox items) points the right pane at the task's Transcript and scrolls it to that turn's final message (`Store.jump`, applied by the transcript once the event is drawn). Read state is per user on the server: `summary_reads` holds one highest-read seq per user and task. `GET /api/me/summary-reads` returns the caller's, for active tasks, as a map from task id to seq, and `PUT /api/tasks/{taskId}/summary-read` with `{seq}` raises it, emitting `summary.read` (`task_id`, `seq`) owned by the user only when it went up, so every Mac the user has byre open on clears the label. A summary counts as read only when the summary turn's end row is on screen in a transcript while the app is active (`summaryVisible` in `mac/Sources/Byre/Chat.swift`), so selecting the task on another tab leaves it unread. `sqlite3 byre.db 'SELECT * FROM summary_reads'` shows the read seqs. +**Unread summaries.** A Task's `summary_seq` is the seq of its latest `turn.ended` event with `summary: true` (0 for none), read through the partial index `events_summary`, so the app knows of unread summaries without loading transcripts; a `turn.ended` with `summary: true` on the global stream raises it in the app. The switcher's and the Project tab's task rows show a **new summary** state label while `summary_seq` is above the seq the user has read (`unreadSummary` in `mac/Sources/Byre/Store.swift`), except on tasks of a workflow run, which close too soon for their summaries to be read. Picking such a row (in the switcher, only when the task has no open inbox items) points the right pane at the task's Transcript and scrolls it to that turn's final message (`Store.jump`, applied by the transcript once the event is drawn). Read state is per user on the server: `summary_reads` holds one highest-read seq per user and task. `GET /api/me/summary-reads` returns the caller's, for active tasks, as a map from task id to seq, and `PUT /api/tasks/{taskId}/summary-read` with `{seq}` raises it, emitting `summary.read` (`task_id`, `seq`) owned by the user only when it went up, so every Mac the user has byre open on clears the label. A summary counts as read only when the summary turn's end row is on screen in a transcript while that server's window is the key window of the active app (`summaryVisible` in `mac/Sources/Byre/Chat.swift`), so selecting the task on another tab leaves it unread. `sqlite3 byre.db 'SELECT * FROM summary_reads'` shows the read seqs. **Task colours.** Each task has a colour, `tasks.color` (`color` in the API), an index into `taskPalette` (`mac/Sources/Byre/Format.swift`), the `Palette.task0` … `Palette.taskN` colours in `Palette.swift`, each with a light and a dark value, all at least 4.5:1 against the backgrounds so they work as text. `CreateTask` assigns it in the same transaction as the task's row: of the `store.TaskColors` colours, one no active task holds, and among those the one given out longest ago (the lowest `max(rowid)` of tasks with that colour, never-used first), so a new task doesn't take the colour of one just closed. Active tasks have distinct colours until more than `TaskColors` are active at once. `TaskColors` must match the number of `taskPalette` entries. The right pane sets its accent (the `paneAccent` environment value, the palette's accent when no task is selected) to the selected task's colour, which tints the picker's row, colours the task picker's text, the selected tab's underline, the composer's border and the running border and scanner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status tab, and task links, including any Markdown link to `#/task/<id>` or `#/task/<id>/<tab>`. The lead's guidance tells it to link every task it names that way, using `#/task/<id>/questions` when it points the human at a question, and clicking one points the right pane at the task, selecting the named tab if there is one. @@ -266,7 +266,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started a review of snapshot <n>."), `review.finished` ("<task> review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. - **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. -- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `questions` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a withdrawn question; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. It sends false on deactivating, Change Server…, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. +- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `questions` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a withdrawn question; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. Each server's window reports to its own server. It sends false on deactivating, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. - **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link, a notification, or the app's own switch to Questions when a question arrives) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows still applies its tab order. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it. - **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 5cb66e9..d07e0b6 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -9,7 +9,6 @@ typealias ViewState = State @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var connection: Connection? private var quitting = false func applicationDidFinishLaunching(_ notification: Notification) { @@ -21,8 +20,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if flag { + return true + } + Servers.shared.openAll() + return false + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - guard let store = connection?.store, !quitting else { + let stores = Servers.shared.connections.values.compactMap(\.store) + guard !stores.isEmpty, !quitting else { return .terminateNow } quitting = true @@ -34,7 +42,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } Task { - await store.stop() + await withTaskGroup(of: Void.self) { group in + for store in stores { + group.addTask { + await store.stop() + } + } + } reply() } Task { @@ -48,49 +62,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @main struct ByreApp: App { @NSApplicationDelegateAdaptor private var delegate: AppDelegate - @ViewState private var connection = Connection() - @ViewState private var overlays = Overlays() var body: some Scene { - Window("byre", id: "main") { - RootView() - .environment(connection) - .environment(overlays) - .font(Fonts.font(13)) - .frame(minWidth: 640, minHeight: 400) - .onAppear { - delegate.connection = connection - } + WindowGroup(id: "main", for: String.self) { $origin in + ServerWindow(origin: $origin) } .defaultSize(width: 1280, height: 820) + .restorationBehavior(.disabled) + .defaultLaunchBehavior(.presented) .commands { - CommandGroup(after: .appInfo) { - Button("Check for Updates…") { - Updater.shared.check() - } - Button("Change Server…") { - connection.changeServer() + ByreCommands() + } + } +} + +struct ByreCommands: Commands { + @FocusedValue(\.serverWindow) private var focus + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + Updater.shared.check() + } + Button("Forget Server") { + focus?.forget?() + } + .disabled(focus?.forget == nil) + } + CommandGroup(replacing: .newItem) { + Button("New Server Window") { + openWindow(id: "main") + } + .keyboardShortcut("n") + Menu("Open Server") { + ForEach(Servers.shared.origins, id: \.self) { origin in + Button(URL(string: origin)?.host() ?? origin) { + openWindow(id: "main", value: origin) + } } } - PasteboardCommands() - CommandMenu("Go") { - goButton("Switch Task", "k") - goButton("Project", "p") - goButton("Lead", "l") - goButton("Merge", "s") - Divider() - goButton("Keyboard Shortcuts", "/") - } + .disabled(Servers.shared.origins.isEmpty) + } + PasteboardCommands() + CommandMenu("Go") { + goButton("Switch Task", "k") + goButton("Project", "p") + goButton("Lead", "l") + goButton("Merge", "s") + Divider() + goButton("Keyboard Shortcuts", "/") } } private func goButton(_ label: String, _ key: String) -> some View { Button(label) { - if let store = connection.store { + if let store = focus?.store, let overlays = focus?.overlays { runGo(key, store, overlays) } } .keyboardShortcut(KeyEquivalent(Character(key))) - .disabled(connection.store == nil || overlays.dialogOpen) + .disabled(focus?.store == nil || focus?.overlays.dialogOpen == true) } } diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift index 1bb09ff..a7154ce 100644 --- a/mac/Sources/Byre/Changes.swift +++ b/mac/Sources/Byre/Changes.swift @@ -22,11 +22,10 @@ private let emptyText: [ChangeMode: String] = [ .step: "This step changed nothing.", ] -@MainActor -private enum ChangesMemory { - static var open: Set<String> = [] - static var range = ChangeMode.main - static var pinRange = ChangeMode.step +struct ChangesMemory { + var open: Set<String> = [] + var range = ChangeMode.main + var pinRange = ChangeMode.step } func untilCancelled() async { @@ -43,9 +42,9 @@ final class ChangesModel { private(set) var changes: TaskChanges? private(set) var error = "" private(set) var diffs: [String: Result<String, ApiError>] = [:] - private(set) var open = ChangesMemory.open - private(set) var range = ChangesMemory.range - private(set) var pinRange = ChangesMemory.pinRange + private(set) var open: Set<String> + private(set) var range: ChangeMode + private(set) var pinRange: ChangeMode @ObservationIgnored private var loading = false @ObservationIgnored private var again = false @ObservationIgnored private var timer: Task<Void, Never>? @@ -56,6 +55,9 @@ final class ChangesModel { init(_ store: Store, compact: Bool) { self.store = store self.compact = compact + open = store.changesMemory.open + range = store.changesMemory.range + pinRange = store.changesMemory.pinRange } var pinned: Int? { @@ -161,7 +163,7 @@ final class ChangesModel { return } range = r - ChangesMemory.range = r + store.changesMemory.range = r retarget() } @@ -170,7 +172,7 @@ final class ChangesModel { return } pinRange = r - ChangesMemory.pinRange = r + store.changesMemory.pinRange = r retarget() } @@ -180,7 +182,7 @@ final class ChangesModel { } else { open.remove(path) } - ChangesMemory.open = open + store.changesMemory.open = open } func shows(_ a: LineAnchor) -> Bool { diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 1c70791..672acdc 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -26,6 +26,7 @@ final class ChatModel { @ObservationIgnored private var focusStart = true @ObservationIgnored private var visibleSummaries: Set<Int64> = [] @ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken? + @ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken? @ObservationIgnored private var started = false init(_ kind: ChatKind, _ store: Store) { @@ -150,6 +151,9 @@ final class ChatModel { activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in self?.markShown() } + keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in + self?.markShown() + } } func stop() { @@ -161,6 +165,10 @@ final class ChatModel { NotificationCenter.default.removeObserver(activeObserver) } activeObserver = nil + if let keyObserver { + NotificationCenter.default.removeObserver(keyObserver) + } + keyObserver = nil unmergedTimer?.cancel() unmergedTimer = nil builder.stop() @@ -214,7 +222,7 @@ final class ChatModel { } private func markShown() { - guard let taskId, NSApp.isActive else { + guard let taskId, store.paneFocus.windowKey else { return } for seq in visibleSummaries { diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 7351998..32f84a0 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -57,6 +57,7 @@ final class Overlays { @ObservationIgnored var pickerFrame: CGRect = .zero @ObservationIgnored var menuHover = false var paneFrame: CGRect = .zero + @ObservationIgnored weak var window: NSWindow? @ObservationIgnored private var monitor: Any? @ObservationIgnored private var mouseMonitor: Any? @@ -134,17 +135,22 @@ final class Overlays { return } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + let win = event.window.map(ObjectIdentifier.init) let code = event.keyCode let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let chars = event.charactersIgnoringModifiers ?? "" let taken = MainActor.assumeIsolated { - self?.key(code, flags, chars, ctrl) ?? false + guard let self, let window = self.window, win == ObjectIdentifier(window) else { + return false + } + return self.key(code, flags, chars, ctrl) } return taken ? nil : event } mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in + let win = event.window.map(ObjectIdentifier.init) MainActor.assumeIsolated { - if let self, self.menu != nil, !self.menuHover { + if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover { self.menu = nil } } diff --git a/mac/Sources/Byre/Images.swift b/mac/Sources/Byre/Images.swift index b9ab4f9..bec088d 100644 --- a/mac/Sources/Byre/Images.swift +++ b/mac/Sources/Byre/Images.swift @@ -38,7 +38,6 @@ func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") -> @MainActor final class ImageCache { - static let shared = ImageCache() private var done: [ImageSource: NSImage] = [:] private var loading: [ImageSource: Task<NSImage, any Error>] = [:] @@ -99,7 +98,7 @@ struct ByreImage: View { var body: some View { Group { - if let image = image ?? ImageCache.shared.cached(source) { + if let image = image ?? store.imageCache.cached(source) { Image(nsImage: image) .resizable() .scaledToFit() @@ -122,7 +121,7 @@ struct ByreImage: View { return } do { - image = try await ImageCache.shared.load(source, store.api) + image = try await store.imageCache.load(source, store.api) } catch { log.error("load image \(src, privacy: .public): \(errorText(error), privacy: .public)") failed = true diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index 51efdbd..fd08f3c 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -4,28 +4,26 @@ import UserNotifications @MainActor final class Notifier: NSObject, UNUserNotificationCenterDelegate { static let shared = Notifier() - weak var connection: Connection? - var showWindow: (() -> Void)? - private var pending: String? + private var pending: (origin: String, taskId: String)? func install() { UNUserNotificationCenter.current().delegate = self } - private func open(_ taskId: String) { + private func open(_ origin: String, _ taskId: String) { NSApp.activate() - showWindow?() - pending = taskId + Servers.shared.openWindow?(origin) + pending = (origin, taskId) deliver() } func deliver() { - guard let taskId = pending, let store = connection?.store else { + guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else { return } pending = nil - if !taskId.isEmpty { - store.focusTask(taskId) + if !p.taskId.isEmpty { + store.focusTask(p.taskId) } } @@ -34,9 +32,11 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - let taskId = response.notification.request.content.userInfo["task_id"] as? String ?? "" + let info = response.notification.request.content.userInfo + let origin = info["origin"] as? String ?? "" + let taskId = info["task_id"] as? String ?? "" await MainActor.run { - open(taskId) + open(origin, taskId) } } } @@ -60,7 +60,7 @@ private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: Strin content.title = store.findTask(taskId).map { "byre · \($0.title)" } ?? "byre" content.body = line.count > 200 ? "\(line.prefix(199))…" : line content.sound = .default - content.userInfo = ["task_id": taskId] + content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString] let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil) enqueue { let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus @@ -92,7 +92,7 @@ func notifyError(_ store: Store, _ ev: ByreEvent) { if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) { return } - show(store, taskId, "error-\(ev.seq)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")") + show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")") } @MainActor @@ -109,7 +109,7 @@ func clearInbox(_ item: InboxItem) { func clearTurns(_ taskId: String, through seq: Int64) { enqueue { let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in - guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5)) else { + guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else { return false } return s <= seq @@ -123,5 +123,5 @@ func notifyTurn(_ store: Store, _ ev: ByreEvent) { guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else { return } - show(store, taskId, "turn-\(ev.seq)", str(ev.payload.value, "result") ?? "") + show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "") } diff --git a/mac/Sources/Byre/PaneFocus.swift b/mac/Sources/Byre/PaneFocus.swift index 1232d3f..138712a 100644 --- a/mac/Sources/Byre/PaneFocus.swift +++ b/mac/Sources/Byre/PaneFocus.swift @@ -72,6 +72,10 @@ final class PaneFocus { window = nil } + var windowKey: Bool { + (window?.isKeyWindow ?? false) && NSApp.isActive + } + func setFrame(_ frame: CGRect) { paneFrame = frame } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 2ab1c27..f7ebf50 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -70,6 +70,7 @@ struct MainView: View { }) .background { WindowReader { window in + overlays.window = window store.paneFocus.attach(window) } } diff --git a/mac/Sources/Byre/Persistence.swift b/mac/Sources/Byre/Persistence.swift index 52592ea..a65a0a2 100644 --- a/mac/Sources/Byre/Persistence.swift +++ b/mac/Sources/Byre/Persistence.swift @@ -5,9 +5,11 @@ import SwiftData final class ServerRecord { var origin = "" var saved = Date.distantPast + var color = 0 - init(origin: String) { + init(origin: String, color: Int) { self.origin = origin + self.color = color saved = Date() } } diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift index 9b9d726..4cf63c9 100644 --- a/mac/Sources/Byre/Reviews.swift +++ b/mac/Sources/Byre/Reviews.swift @@ -20,12 +20,12 @@ private enum SnapshotPatches { static var patches: [String: Task<String?, any Error>] = [:] static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? { - let key = "\(taskId)/\(n)/\(path)" + let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)" if let cached = patches[key] { return try await cached.value } let fetched = Task<String?, any Error> { - let ck = "\(taskId)/\(n)" + let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)" let pending = changes[ck] ?? Task { try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json } diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 35c4efd..d031030 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -22,6 +22,11 @@ struct SettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { + heading("Window colour") + Text("Windows for this server have a border in this colour, on every Mac you use.") + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + ServerColorPicker() heading("Notifications") DeviceNotifications() PrefsBox() @@ -53,6 +58,31 @@ struct SettingsView: View { } } +private struct ServerColorPicker: View { + @Environment(Store.self) private var store + + var body: some View { + let origin = store.api.origin.absoluteString + let current = Servers.shared.record(origin)?.color + HStack(spacing: 4) { + ForEach(taskPalette.indices, id: \.self) { i in + Button { + Servers.shared.setColor(origin, i) + } label: { + Circle() + .fill(taskPalette[i]) + .frame(width: 18, height: 18) + .padding(3) + .overlay(Circle().stroke(i == current ? Palette.text : .clear, lineWidth: 1.5)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 8) + } +} + private struct DeviceNotifications: View { @ViewState private var status: UNAuthorizationStatus? @ViewState private var asking = false diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 3ceb20c..e2cea58 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -6,46 +6,116 @@ import SwiftUI @MainActor @Observable -final class Connection { - enum Phase { - case connect - case starting - case failed(String) - case running(Store) - } +final class Servers { + static let shared = Servers() - var phase: Phase = .connect - var serverText: String - var problem: String? - var connecting = false - @ObservationIgnored private var generation = 0 + private(set) var entries: [(origin: String, record: ServerRecord)] = [] + private(set) var connections: [String: Connection] = [:] + @ObservationIgnored var shown: Set<String> = [] + @ObservationIgnored var launched = false + @ObservationIgnored var openWindow: ((String?) -> Void)? - init() { - serverText = "" - resume() + private init() { + load() Task { for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) { if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil { - store?.drafts?.reload() - resume() + for connection in connections.values { + connection.store?.drafts?.reload() + } + load() } } } } - private func resume() { - guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else { - return + var origins: [String] { + entries.map(\.origin) + } + + func record(_ origin: String) -> ServerRecord? { + entries.first { $0.origin == origin }?.record + } + + func color(_ origin: String) -> Color { + taskPalette[(record(origin)?.color ?? 0) % taskPalette.count] + } + + private func load() { + var loaded: [(origin: String, record: ServerRecord)] = [] + for record in fetchRecords(FetchDescriptor<ServerRecord>(sortBy: [SortDescriptor(\.saved)])) { + if let origin = parseOrigin(record.origin), !loaded.contains(where: { $0.origin == origin.absoluteString }) { + loaded.append((origin.absoluteString, record)) + } } - serverText = saved - phase = .starting - Task { - await start(origin) + entries = loaded + for (origin, _) in loaded where connections[origin] == nil { + connections[origin] = Connection(origin: parseOrigin(origin)!) + } + for (origin, connection) in connections where !loaded.contains(where: { $0.origin == origin }) { + connections[origin] = nil + Task { + await connection.stop() + } } } - private static func servers() -> [ServerRecord] { - fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)])) + func add(_ origin: URL) { + if let connection = connections[origin.absoluteString], case .failed = connection.phase { + connections[origin.absoluteString] = nil + } + if record(origin.absoluteString) == nil { + let used = Set(entries.map(\.record.color)) + let color = taskPalette.indices.first { !used.contains($0) } ?? entries.count % taskPalette.count + persistence.insert(ServerRecord(origin: origin.absoluteString, color: color)) + saveRecords() + } + load() + } + + func remove(_ origin: String) { + for record in fetchRecords(FetchDescriptor<ServerRecord>()) where parseOrigin(record.origin)?.absoluteString == origin { + persistence.delete(record) + } + saveRecords() + load() + } + + func setColor(_ origin: String, _ color: Int) { + for record in fetchRecords(FetchDescriptor<ServerRecord>()) where parseOrigin(record.origin)?.absoluteString == origin { + record.color = color + } + saveRecords() + } + + func openAll() { + if origins.isEmpty { + openWindow?(nil) + } + for origin in origins { + openWindow?(origin) + } + } +} + +@MainActor +@Observable +final class Connection { + enum Phase { + case starting + case failed(String) + case running(Store) + } + + let origin: URL + var phase: Phase = .starting + @ObservationIgnored private var stopped = false + + init(origin: URL) { + self.origin = origin + Task { + await start() + } } var store: Store? { @@ -55,56 +125,84 @@ final class Connection { return nil } - func connect() async { + func stop() async { + stopped = true + if let store { + await store.stop() + } + } + + private func start() async { + let store = Store(api: API(origin: origin)) + do { + let status = try await store.api.client.getStatus().ok.body.json + async let me: Void = store.loadMe() + async let projects: Void = store.loadProjects() + async let inbox: Void = store.loadInbox() + async let lead: Void = store.loadLeadSessions() + async let reads: Void = store.loadSummaryReads() + _ = try await (me, projects, inbox, lead, reads) + store.loadDrafts() + try await store.loadFocus() + guard !stopped else { + await store.stop() + return + } + Task { + await store.startLeadStream() + } + phase = .running(store) + store.startGlobalStream(after: status.lastEventSeq) + store.remote.start() + Notifier.shared.deliver() + } catch { + await store.stop() + if !stopped { + phase = .failed("byre failed to start: \(errorText(error))") + } + } + } +} + +@MainActor +@Observable +final class ConnectForm { + var serverText = "" + var problem: String? + var connecting = false + + func connect() async -> URL? { guard let origin = parseOrigin(serverText) else { problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net." - return + return nil } connecting = true problem = nil - let gen = generation do { _ = try await API(origin: origin).client.getStatus().ok } catch { - if gen == generation { - connecting = false - problem = explain(error, origin) - } - return - } - guard gen == generation else { - return + connecting = false + problem = explain(error, origin) + return nil } connecting = false - for server in Self.servers() { - persistence.delete(server) - } - persistence.insert(ServerRecord(origin: origin.absoluteString)) - saveRecords() - serverText = origin.absoluteString - phase = .starting - await start(origin) + Servers.shared.add(origin) + return origin } - func installHere() async { + func installHere() async -> URL? { connecting = true problem = nil - let gen = generation do { let origin = try await Server.turnOn() serverText = origin.absoluteString try await waitForServer(origin) } catch { - if gen == generation { - connecting = false - problem = errorText(error) - } - return + connecting = false + problem = errorText(error) + return nil } - guard gen == generation else { - return - } - await connect() + return await connect() } private func waitForServer(_ origin: URL) async throws { @@ -125,51 +223,6 @@ final class Connection { try await group.next() } } - - func changeServer() { - generation += 1 - if let store { - Task { - await store.stop() - } - } - connecting = false - problem = nil - phase = .connect - } - - private func start(_ origin: URL) async { - generation += 1 - let gen = generation - let store = Store(api: API(origin: origin)) - do { - let status = try await store.api.client.getStatus().ok.body.json - async let me: Void = store.loadMe() - async let projects: Void = store.loadProjects() - async let inbox: Void = store.loadInbox() - async let lead: Void = store.loadLeadSessions() - async let reads: Void = store.loadSummaryReads() - _ = try await (me, projects, inbox, lead, reads) - store.loadDrafts() - try await store.loadFocus() - guard gen == generation else { - await store.stop() - return - } - Task { - await store.startLeadStream() - } - phase = .running(store) - store.startGlobalStream(after: status.lastEventSeq) - store.remote.start() - Notifier.shared.deliver() - } catch { - await store.stop() - if gen == generation { - phase = .failed("byre failed to start: \(errorText(error))") - } - } - } } private let tlsErrors: Set<URLError.Code> = [ @@ -207,20 +260,21 @@ func explain(_ error: any Error, _ origin: URL) -> String { } struct ConnectView: View { - @Bindable var connection: Connection + @Bindable var form: ConnectForm + let connected: (URL) -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Server URL") - TextField("https://byre.example.ts.net", text: $connection.serverText) + TextField("https://byre.example.ts.net", text: $form.serverText) .textFieldStyle(.roundedBorder) .onSubmit(connect) HStack(spacing: 10) { Button("Connect", action: connect) Button("Run byre on this Mac", action: installHere) } - .disabled(connection.connecting) - if let problem = connection.problem { + .disabled(form.connecting) + if let problem = form.problem { Text(problem) .foregroundStyle(Palette.danger) .textSelection(.enabled) @@ -233,13 +287,17 @@ struct ConnectView: View { private func connect() { Task { - await connection.connect() + if let origin = await form.connect() { + connected(origin) + } } } private func installHere() { Task { - await connection.installHere() + if let origin = await form.installHere() { + connected(origin) + } } } } @@ -256,15 +314,43 @@ struct FatalView: View { } } -struct RootView: View { - @Environment(Connection.self) private var connection - @Environment(\.openWindow) private var openWindow +struct ServerFocus { + let store: Store? + let overlays: Overlays + let forget: (() -> Void)? +} - var body: some View { - Group { +extension FocusedValues { + @Entry var serverWindow: ServerFocus? +} + +struct ServerWindow: View { + @Binding var origin: String? + @ViewState private var overlays = Overlays() + @ViewState private var form = ConnectForm() + @Environment(\.openWindow) private var openWindow + @Environment(\.dismiss) private var dismissWindow + + private var connection: Connection? { + origin.flatMap { Servers.shared.connections[$0] } + } + + private var focus: ServerFocus { + var forgetting: (() -> Void)? + if connection != nil { + forgetting = forget + } + return ServerFocus(store: connection?.store, overlays: overlays, forget: forgetting) + } + + private var frameColor: Color { + connection.map { Servers.shared.color($0.origin.absoluteString) } ?? Palette.bg + } + + @ViewBuilder + private var content: some View { + if let connection { switch connection.phase { - case .connect: - ConnectView(connection: connection) case .starting: Color.clear case .failed(let message): @@ -273,17 +359,92 @@ struct RootView: View { MainView() .environment(store) } - } - .foregroundStyle(Palette.text) - .tint(Palette.accent) - .background(Palette.bg) - .onAppear { - let openWindow = openWindow - Notifier.shared.connection = connection - Notifier.shared.showWindow = { - openWindow(id: "main") + } else { + ConnectView(form: form) { connected in + let key = connected.absoluteString + if Servers.shared.shown.contains(key) { + openWindow(id: "main", value: key) + dismissWindow() + return + } + origin = key } - Notifier.shared.deliver() } } + + private var framed: some View { + content + .foregroundStyle(Palette.text) + .tint(Palette.accent) + .background(Palette.bg) + .padding(connection == nil ? 0 : 4) + .background(frameColor) + .environment(overlays) + .font(Fonts.font(13)) + .frame(minWidth: 640, minHeight: 400) + } + + var body: some View { + framed + .navigationTitle(connection?.origin.host() ?? "byre") + .focusedSceneValue(\.serverWindow, focus) + .onChange(of: origin, initial: true) { old, new in + moved(old, new) + } + .onChange(of: Servers.shared.origins) { old, new in + adopt(new.filter { !old.contains($0) }) + } + .onAppear(perform: appeared) + .onDisappear { + if let origin { + Servers.shared.shown.remove(origin) + } + } + } + + private func moved(_ old: String?, _ new: String?) { + if let old { + Servers.shared.shown.remove(old) + } + if let new { + Servers.shared.shown.insert(new) + } + } + + private func appeared() { + let servers = Servers.shared + let openWindow = openWindow + servers.openWindow = { origin in + if let origin { + openWindow(id: "main", value: origin) + } else { + openWindow(id: "main") + } + } + if !servers.launched { + servers.launched = true + adopt(servers.origins) + for o in servers.origins where !servers.shown.contains(o) { + openWindow(id: "main", value: o) + } + } + Notifier.shared.deliver() + } + + private func adopt(_ candidates: [String]) { + guard origin == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first(where: { !Servers.shared.shown.contains($0) }) else { + return + } + origin = next + Servers.shared.shown.insert(next) + } + + private func forget() { + guard let current = origin else { + return + } + form.serverText = current + origin = nil + Servers.shared.remove(current) + } } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 42ceabf..393c1bc 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -50,6 +50,7 @@ final class Store { var pickedMembers: [String: String] = [:] private(set) var drafts: Drafts? let images = ImageDrafts() + let imageCache = ImageCache() let events = Listeners<ByreEvent>() let paneFocus: PaneFocus let remote: Remote @@ -60,6 +61,7 @@ final class Store { @ObservationIgnored private var globalStream: EventStream? @ObservationIgnored private var leadStream: EventStream? @ObservationIgnored private var stopped = false + @ObservationIgnored var changesMemory = ChangesMemory() init(api: API) { self.api = api