From 38d56139c3d0fecd4a308e6f293720c6887cd400 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 08:24:22 -0700 Subject: [PATCH] macOS client overlays, menus and notifications --- TODO.md | 6 +- docs/macos.md | 20 +- mac/Sources/Byre/ByreApp.swift | 26 ++ mac/Sources/Byre/Chat.swift | 99 +++++- mac/Sources/Byre/Composer.swift | 5 + mac/Sources/Byre/Dialogs.swift | 234 +++++++++---- mac/Sources/Byre/Inbox.swift | 3 +- mac/Sources/Byre/MergeDialog.swift | 358 ++++++++++++++++++++ mac/Sources/Byre/Notifications.swift | 97 ++++++ mac/Sources/Byre/Panes.swift | 36 +- mac/Sources/Byre/Startup.swift | 10 + mac/Sources/Byre/Store.swift | 8 +- mac/Sources/Byre/Switcher.swift | 487 +++++++++++++++++++++++++++ mac/Sources/Byre/Terminal.swift | 5 + web/dist/switcher.js | 1 + web/dist/switcher.js.map | 2 +- web/src/switcher.ts | 1 + 17 files changed, 1297 insertions(+), 101 deletions(-) create mode 100644 mac/Sources/Byre/MergeDialog.swift create mode 100644 mac/Sources/Byre/Notifications.swift create mode 100644 mac/Sources/Byre/Switcher.swift diff --git a/TODO.md b/TODO.md index e58d367..2bd65e6 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,9 @@ # M8 — macOS client -- macOS client overlays, menus and notifications - Switcher, keyboard help, alert and confirm overlays, Merge and Discard dialogs, Go menu shortcuts with the Ctrl variants and terminal passthrough, UNUserNotificationCenter notifications. +- macOS client strict memory safety + Turn on `.strictMemorySafety()` for the `Byre` target in `mac/Package.swift` and resolve what it flags (`@unchecked Sendable` on `Re`, `MainActor.assumeIsolated` in the key and click monitors, and anything else), so `swift build` is warning-free with it on. +- macOS client signing and SwiftData with iCloud sync + Sign `byre.app` with the paid developer team (provisioning profile, iCloud and CloudKit entitlements) in `mac/bundle.sh`, and move the app's persisted state (the `byre.server` origin, drafts, last visits) from `UserDefaults` to SwiftData with CloudKit sync, so the server address follows the user to every Mac. - macOS client parity review Side by side with the web client on the same server, window size and appearance, using disposable tasks: every tab, switcher, Inbox choice types, merge/discard dialogs, light/dark, narrow windows. Document build, install and storage in `docs/operations.md`. diff --git a/docs/macos.md b/docs/macos.md index 7ed46e9..c0a7599 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,6 +1,6 @@ # byre for macOS -This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting, the identity banner, and every other tab: Changes, Files, Terminal, Inbox, Reviews, Workflow (its stage graph drawn natively), Project, Status and Settings. Overlays so far are alerts, confirmations (used by Settings to delete a lead note), the More menu and the full-size image view. Settings asks for notification permission, but nothing delivers notifications until `Notifications.swift` exists. Until the switcher exists, the task picker is a plain menu of the active tasks, and the transcript head has Compact and Sync but not yet Merge or Discard. The rest of the work is under "M8 — macOS client" in `TODO.md`. +This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting, the identity banner, every other tab (Changes, Files, Terminal, Inbox, Reviews, Workflow with its stage graph drawn natively, Project, Status and Settings), every overlay (the switcher, keyboard shortcuts, alerts, confirmations, the Merge and Discard dialogs, the More menu and the full-size image view), the Go menu with its Ctrl variants, and notifications. The rest of the work is under "M8 — macOS client" in `TODO.md`. ## Decision @@ -56,10 +56,10 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam | `workflow.ts` | `Workflow.swift` | Stage graph and attempts | | `project.ts`, `todolink.ts` | `Project.swift`, `TodoLink.swift` | Project tab and TODO links | | `status.ts`, `settings.ts` | `Status.swift`, `Settings.swift` | Left-pane tabs | -| `switcher.ts` | `Switcher.swift` | ⌘K overlay and the shortcuts help | +| `switcher.ts` | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | | `drafts.ts` | `Drafts.swift` | Composer and answer drafts | | `notifications.ts` | `Notifications.swift` | `UNUserNotificationCenter` delivery | -| `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, confirmations, the More menu, the image view), `ago`, Copy, shared button and layout styles, diff lines | +| `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, modals with `showConfirm` and `showChoice`, the switcher's slot, the More menu, the image view) and the window's key monitor, `ago`, Copy, shared button and layout styles, diff lines | | `images.ts` | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay | ## Store and events @@ -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`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold (40 s, checked every 5 s) and retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. As in the web client there are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. -Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `, as the web page does; Change Server… leaves that screen. `handleEvent` doesn't call `notifyInbox` or `notifyTurn` until `Notifications.swift` exists. +Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `, as the web page does; Change Server… leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`, as `state.ts` does. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. @@ -89,7 +89,9 @@ One window. The panes follow the "Web UI layout" rules in `AGENTS.md`: an `HStac Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, positioned as in the web client (the switcher and the Merge and Discard confirmations just below their anchor), not system sheets or alerts. -Closing the window leaves the app running with its store and streams, so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting. +Overlays live in `Overlays` (`Dialogs.swift`), one environment object for the window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. + +Closing the window leaves the app running with its store and streams (`applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting. ## Views @@ -100,8 +102,8 @@ Each view does what its TypeScript counterpart does, with the same text, refresh - **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`, following `terminal.ts`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: ` with New shell, 1011 for a start failure, reconnect after the same delay while the task is active, one socket per open tab and selected task. Colours come from the palette as in the web client. Ctrl combinations typed in it go to the shell. -- **Switcher.** The ⌘K overlay with the same ordering (tasks with inbox items first, then least recently visited), the `name: title` project prefix, New task, Start workflow… and its second step, projects, and Escape stepping back. -- **Merge and Discard.** `confirmMerge` ported whole: the editable commit title, co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, and the parent-or-default-branch choice for subtasks. +- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: the same ordering (tasks with inbox items first, then least recently visited, the selected task last), the `name: title` project prefix, New task, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Inbox when it has items, its unread summary when it has one, and its Transcript otherwise. +- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end, and labelled and disabled as in `taskHead`. `confirmMerge` (`MergeDialog.swift`) is ported whole: the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It sits just below the Merge button, or centred when ⌘S opens it with the button off screen. Discard confirms below its button, then clears the focused task. ## Markdown @@ -135,7 +137,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Notifications -`Notifications.swift` ports `notifications.ts` onto `UNUserNotificationCenter`: the same preferences from `GET /api/me`, the same events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · `, body cut at 200 characters, and the item id or `turn-` as the request identifier. Clicking one shows the window and focuses the task. Settings shows the authorization state and a button that requests it, in place of the browser permission flow. As in the browser, nothing notifies while the app isn't running. +`Notifications.swift` ports `notifications.ts` onto `UNUserNotificationCenter`: the same preferences from `GET /api/me`, the same events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · `, body the first line cut at 200 characters, and the item id or `turn-` as the request identifier, so a repeat replaces the earlier one as the browser's `tag` does. They show as banners even while byre is the active app, as browser notifications do over the page (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, shows the window (reopening it if it was closed) and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its task until startup has a store, then focuses it. Settings shows the authorization state and a button that requests it, in place of the browser permission flow. As in the browser, nothing notifies while the app isn't running. ## Pane focus @@ -147,7 +149,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Keyboard and menus -The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands do nothing while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts work the same way through a local key monitor, except while the terminal has focus, where Ctrl combinations go to the shell. Escape closes the open overlay. +The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index f2e348f..6a18738 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -8,6 +8,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var connection: Connection? private var quitting = false + func applicationDidFinishLaunching(_ notification: Notification) { + Notifier.shared.install() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let store = connection?.store, !quitting else { return .terminateNow @@ -57,6 +65,24 @@ struct ByreApp: App { } } 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 { + runGo(key, store, overlays) + } + } + .keyboardShortcut(KeyEquivalent(Character(key))) + .disabled(connection.store == nil || overlays.isOpen) + } } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index a070e5c..5c05547 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -15,7 +15,9 @@ final class ChatModel { private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)? private(set) var fetching = false private(set) var olderGeneration = 0 + private(set) var hasUnmerged = false @ObservationIgnored var stuck = true + @ObservationIgnored private var unmergedTimer: Task? @ObservationIgnored private var offs: [() -> Void] = [] @ObservationIgnored private var focusStart = true @ObservationIgnored private var visibleSummaries: Set = [] @@ -102,6 +104,9 @@ final class ChatModel { guard let feed else { return } + Task { + await loadUnmerged() + } appended(feed.events) offs.append(feed.appended.add { [weak self] events in self?.appended(events) @@ -125,15 +130,38 @@ final class ChatModel { NotificationCenter.default.removeObserver(activeObserver) } activeObserver = nil + unmergedTimer?.cancel() + unmergedTimer = nil builder.stop() store.drafts?.flush() } private func appended(_ events: [ByreEvent]) { + if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) { + unmergedTimer?.cancel() + unmergedTimer = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(1500)) + if !Task.isCancelled { + await self?.loadUnmerged() + } + } + } _ = builder.append(events) jump() } + private func loadUnmerged() async { + guard let taskId else { + return + } + do { + let u = try await store.api.client.getUnmerged(path: .init(taskId: taskId)).ok.body.json + hasUnmerged = u.commits > 0 || u.files > 0 + } catch { + log.error("load unmerged changes: \(errorText(error), privacy: .public)") + } + } + func takeFocusStart() -> Bool { let take = focusStart && session == nil focusStart = false @@ -275,9 +303,13 @@ private struct ChatHead: View { } } else { let sess = store.currentSession(task.id) + let busy = sess?.state == .running header { CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] }) - SyncButton(task: task, busy: sess?.state == .running) + SyncButton(task: task, busy: busy) + Spacer(minLength: 12) + MergeButton(task: task, busy: busy, unmerged: model.hasUnmerged) + DiscardButton(task: task, busy: busy) } } } @@ -287,8 +319,8 @@ private struct ChatHead: View { private func header(@ViewBuilder _ content: () -> Content) -> some View { HStack(spacing: 8) { content() - Spacer(minLength: 0) } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 8) .overlay(alignment: .bottom) { @@ -326,6 +358,69 @@ private struct SyncButton: View { } } +private struct MergeButton: View { + let task: ByreTask + let busy: Bool + let unmerged: Bool + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + + var body: some View { + let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch" + let held = task.mergeQueuedAt != nil + let intoParent = task.mergingIntoParent + let help = intoParent + ? "Waiting for the parent task's agent to merge this subtask" + : held + ? "Waiting for the merge in progress in this project to finish" + : task.mergeAfterReview + ? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)" + : task.parentTaskId != nil + ? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)" + : "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)" + Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") { + Task { + await confirmMerge(task.id, store, overlays) + } + } + .buttonStyle(WebButtonStyle()) + .disabled(busy || held || intoParent || !unmerged) + .help(help) + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + overlays.mergeFrame = frame + } + .onDisappear { + overlays.mergeFrame = nil + } + } +} + +private struct DiscardButton: View { + let task: ByreTask + let busy: Bool + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var frame: CGRect = .zero + + var body: some View { + Button("Discard") { + Task { + await confirmDiscard(task, store, overlays, anchor: frame) + } + } + .buttonStyle(WebButtonStyle(danger: true)) + .disabled(busy) + .help("The agent writes the task summary, then byre deletes the worktree and branch without merging") + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { f in + frame = f + } + } +} + struct CompactButton: View { let sessionId: String? let state: AgentSession.StatePayload? diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index d61e718..2898b48 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -347,6 +347,11 @@ struct ComposerView: View { .onChange(of: modelKey) { pick = nil } + .onChange(of: overlays.leadFocus) { + if chat.kind == .lead { + focused = true + } + } .onChange(of: focused) { if !focused { store.drafts?.flush() diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 25c3cec..c219f0c 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -13,86 +13,163 @@ struct OverlayMenu { let entries: [MenuEntry] } -struct OverlayConfirm { - let paragraphs: [String] - let action: String - let danger: Bool +struct ModalButton { + let label: String + let value: String + var primary = false + var danger = false + var help = "" +} + +struct OverlayModal { + let content: AnyView + let buttons: [ModalButton] let anchor: CGRect? - let done: (Bool) -> Void + let canSubmit: @MainActor () -> Bool + let done: (String) -> Void +} + +struct OverlayAlert: Identifiable { + let id = UUID() + let content: AnyView +} + +enum LeftTab { + case lead, status, settings } @MainActor @Observable final class Overlays { - var alerts: [String] = [] + var alerts: [OverlayAlert] = [] var image: NSImage? var menu: OverlayMenu? - var confirm: OverlayConfirm? + var modal: OverlayModal? + var switcher: SwitcherModel? + var leftTab = LeftTab.lead + var leadFocus = 0 + @ObservationIgnored var pickerFrame: CGRect = .zero + @ObservationIgnored var mergeFrame: CGRect? @ObservationIgnored private var monitor: Any? var isOpen: Bool { - !alerts.isEmpty || image != nil || menu != nil || confirm != nil + !alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil } func showAlert(_ text: String) { - alerts.append(text) + alerts.append(OverlayAlert(content: AnyView(ModalText(text: text)))) } - func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool { - confirm?.done(false) + func showAlert(_ content: some View) { + alerts.append(OverlayAlert(content: AnyView(content))) + } + + func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { + modal?.done("cancel") return await withCheckedContinuation { cont in - confirm = OverlayConfirm(paragraphs: paragraphs, action: action, danger: danger, anchor: anchor) { ok in - cont.resume(returning: ok) + modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, canSubmit: canSubmit) { value in + cont.resume(returning: value) } } } - func answerConfirm(_ ok: Bool) { - let c = confirm - confirm = nil - c?.done(ok) + func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool { + let content = VStack(alignment: .leading, spacing: 12) { + ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, p in + ModalText(text: p) + } + } + return await showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: action, value: "ok", primary: true, danger: danger)], anchor: anchor) == "ok" + } + + func showChoice(_ content: some View, _ choices: [ModalButton], anchor: CGRect? = nil) async -> String { + await showModal(content, [ModalButton(label: "Cancel", value: "cancel")] + choices, anchor: anchor) + } + + func answerModal(_ value: String) { + let m = modal + modal = nil + m?.done(value) + } + + func openSwitcher(_ store: Store) { + if switcher != nil { + return + } + switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame) } func closeTop() { - if menu != nil { - menu = nil - } else if confirm != nil { - answerConfirm(false) + if !alerts.isEmpty { + alerts.removeFirst() + } else if modal != nil { + answerModal("cancel") } else if image != nil { image = nil - } else if !alerts.isEmpty { - alerts.removeFirst() + } else if let switcher { + switcher.escape() + } else if menu != nil { + menu = nil } } - func install() { + func install(_ ctrl: @escaping @MainActor (String) -> Bool) { if monitor != nil { return } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let code = event.keyCode + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let chars = event.charactersIgnoringModifiers ?? "" let taken = MainActor.assumeIsolated { - self?.key(code) ?? false + self?.key(code, flags, chars, ctrl) ?? false } return taken ? nil : event } } - private func key(_ code: UInt16) -> Bool { + private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool { if !isOpen { + if flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) { + return ctrl(chars) + } return false } if code == 53 { closeTop() return true } - if menu == nil && confirm != nil && (code == 36 || code == 76) { - answerConfirm(true) - return true + let enter = code == 36 || code == 76 + let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false + if !alerts.isEmpty { + if enter { + alerts.removeFirst() + return true + } + return false } - if menu == nil && image == nil && !alerts.isEmpty && (code == 36 || code == 76) { - alerts.removeFirst() - return true + if let modal { + if enter && !marked { + if modal.canSubmit(), let last = modal.buttons.last { + answerModal(last.value) + } + return true + } + return false + } + if image == nil, !marked, let switcher { + if code == 125 { + switcher.move(1) + return true + } + if code == 126 { + switcher.move(-1) + return true + } + if enter { + switcher.pick() + return true + } } return false } @@ -105,6 +182,17 @@ final class Overlays { } } +struct ModalText: View { + let text: String + + var body: some View { + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct OverlayHost: View { @Environment(Overlays.self) private var overlays @@ -131,6 +219,14 @@ struct OverlayHost: View { .fixedSize() .offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4) } + if let switcher = overlays.switcher { + Color.black.opacity(0.35) + .contentShape(Rectangle()) + .onTapGesture { + switcher.close() + } + SwitcherPlacement(model: switcher) + } if let image = overlays.image { Color.black.opacity(0.6) .onTapGesture { @@ -146,23 +242,25 @@ struct OverlayHost: View { overlays.image = nil } } - if let c = overlays.confirm { + if let m = overlays.modal { Color.black.opacity(0.35) .contentShape(Rectangle()) .onTapGesture { - overlays.answerConfirm(false) + overlays.answerModal("cancel") } - ConfirmPlacement(confirm: c) { ok in - overlays.answerConfirm(ok) + ModalPlacement(modal: m) { value in + overlays.answerModal(value) } } - if let text = overlays.alerts.first { + if let alert = overlays.alerts.first { Color.black.opacity(0.35) .contentShape(Rectangle()) - AlertBox(text: text) { - overlays.alerts.removeFirst() + GeometryReader { g in + AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) { + overlays.alerts.removeFirst() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -191,7 +289,7 @@ private struct MenuRow: View { } } -private let overlayMargin: CGFloat = 8 +let overlayMargin: CGFloat = 8 private struct AnchoredBox: Layout { let anchor: CGRect @@ -218,14 +316,14 @@ private struct AnchoredBox: Layout { } } -private struct ConfirmPlacement: View { - let confirm: OverlayConfirm - let answer: (Bool) -> Void +private struct ModalPlacement: View { + let modal: OverlayModal + let answer: (String) -> Void var body: some View { GeometryReader { g in - let box = ConfirmBox(confirm: confirm, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer) - if let anchor = confirm.anchor { + let box = ModalBox(modal: modal, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer) + if let anchor = modal.anchor { AnchoredBox(anchor: anchor) { box } @@ -238,36 +336,31 @@ private struct ConfirmPlacement: View { } } -private struct ConfirmBox: View { - let confirm: OverlayConfirm +private struct ModalBox: View { + let modal: OverlayModal let maxText: CGFloat - let answer: (Bool) -> Void + let answer: (String) -> Void var body: some View { + let ok = modal.canSubmit() VStack(alignment: .leading, spacing: 12) { ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(confirm.paragraphs.enumerated()), id: \.offset) { _, p in - Text(p) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + modal.content + .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: maxText) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Spacer() - Button("Cancel") { - answer(false) + ForEach(Array(modal.buttons.enumerated()), id: \.offset) { _, b in + Button(b.label) { + answer(b.value) + } + .buttonStyle(WebButtonStyle(primary: b.primary, danger: b.danger)) + .disabled(b.value != "cancel" && !ok) + .help(b.help) } - .buttonStyle(WebButtonStyle()) - Button(confirm.action) { - answer(true) - } - .buttonStyle(WebButtonStyle(primary: true, danger: confirm.danger)) } .padding(.top, 4) } @@ -280,14 +373,19 @@ private struct ConfirmBox: View { } private struct AlertBox: View { - let text: String + let alert: OverlayAlert + let maxText: CGFloat let ok: () -> Void var body: some View { VStack(alignment: .leading, spacing: 16) { - Text(text) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) + ScrollView { + alert.content + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: maxText) + .fixedSize(horizontal: false, vertical: true) HStack { Spacer() Button("OK", action: ok) diff --git a/mac/Sources/Byre/Inbox.swift b/mac/Sources/Byre/Inbox.swift index 833fdcb..182ffee 100644 --- a/mac/Sources/Byre/Inbox.swift +++ b/mac/Sources/Byre/Inbox.swift @@ -195,7 +195,7 @@ private struct InboxCard: View { @FocusState private var focus: String? var body: some View { - Group { + VStack(spacing: 0) { if let model { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 10) { @@ -215,6 +215,7 @@ private struct InboxCard: View { } } } + .frame(maxWidth: .infinity, alignment: .leading) .onAppear { if model == nil { model = CardModel(item, store, overlays) diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift new file mode 100644 index 0000000..e37768a --- /dev/null +++ b/mac/Sources/Byre/MergeDialog.swift @@ -0,0 +1,358 @@ +import SwiftUI + +typealias Participant = Components.Schemas.Participant + +@MainActor +private var mergeDrafts: [String: String] = [:] + +private let maxTitle = 72 + +typealias Leave = @MainActor (@escaping @MainActor () -> Void) -> Void + +struct MergeWarning { + let text: String + var open: (@MainActor () -> Void)? + var detail = "" +} + +@MainActor +@Observable +final class MergeModel { + let taskId: String + let target: String + let parentTitle: String? + let coAuthors: [Participant] + let original: String + var title: String + var editing = false + var toParent: Bool + var reviewItems: [MergeWarning] = [] + @ObservationIgnored var running = false + @ObservationIgnored var leave: Leave = { _ in } + @ObservationIgnored private var loads = 0 + + init(taskId: String, target: String, parentTitle: String?, coAuthors: [Participant], title: String) { + self.taskId = taskId + self.target = target + self.parentTitle = parentTitle + self.coAuthors = coAuthors + original = title + self.title = title + toParent = parentTitle != nil + } + + var valid: Bool { + toParent || !trimmed(title).isEmpty + } + + var sentTitle: String { + editing ? title : original + } + + func load(_ store: Store) async { + loads += 1 + let started = loads + var items: [MergeWarning] + var isRunning = false + do { + (items, isRunning) = try await reviewWarnings(taskId, store, leave) + } catch { + items = [MergeWarning(text: "Review status unavailable", detail: errorText(error))] + } + if started == loads { + reviewItems = items + running = isRunning + } + } + + func stop() { + loads += 1 + } +} + +@MainActor +func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async { + guard let task = store.findTask(taskId), task.status == .active else { + return + } + if store.currentSession(taskId)?.state == .running { + overlays.showAlert("Agent is mid-turn; merge when it's idle") + return + } + if task.mergeQueuedAt != nil { + overlays.showAlert("Merge already held behind another merge in this project") + return + } + if task.mergingIntoParent { + overlays.showAlert("Already waiting for the parent task's agent to merge this subtask") + return + } + let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch" + let parent = task.parentTaskId.flatMap { store.findTask($0) } + let coAuthors: [Participant] + do { + let client = store.api.client + async let unmerged = client.getUnmerged(path: .init(taskId: taskId)).ok.body.json + async let people = client.getCoAuthors(path: .init(taskId: taskId)).ok.body.json + let (u, p) = try await (unmerged, people) + if u.commits == 0 && u.files == 0 { + overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)") + return + } + coAuthors = p + } catch { + overlays.showAlert(errorText(error)) + return + } + let title = mergeDrafts[taskId] ?? task.mergeTitle + mergeDrafts[taskId] = nil + let model = MergeModel(taskId: taskId, target: target, parentTitle: parent?.status == .active ? parent?.title : nil, coAuthors: coAuthors, title: title) + model.leave = { [weak model] go in + if let model { + mergeDrafts[taskId] = model.sentTitle + } + overlays.answerModal("cancel") + go() + } + let off = store.events.add { ev in + if ev.taskId == taskId && (ev.kind == "snapshot.created" || isReviewEvent(ev)) { + Task { + await model.load(store) + } + } + } + defer { + model.stop() + off() + } + await model.load(store) + let content = MergeContent(model: model) + let merge = { (afterReview: Bool) in + let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent + ? .init(target: .parent, afterReview: afterReview ? true : nil) + : .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil) + do { + _ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted + } catch { + overlays.showAlert(errorText(error)) + } + } + let anchor = overlays.mergeFrame + if !model.running { + if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], anchor: anchor, canSubmit: { model.valid }) == "ok" { + await merge(false) + } + return + } + let choice = await overlays.showModal(content, [ + ModalButton(label: "Cancel", value: "cancel"), + ModalButton(label: "Merge now", value: "now"), + ModalButton(label: "After review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"), + ], anchor: anchor, canSubmit: { model.valid }) + if choice == "now" || choice == "after" { + await merge(choice == "after") + } +} + +@MainActor +private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping Leave) async throws -> ([MergeWarning], Bool) { + let client = store.api.client + async let snapsCall = client.listSnapshots(path: .init(taskId: taskId)).ok.body.json + async let reviewsCall = client.listReviews(path: .init(taskId: taskId)).ok.body.json + let (snaps, reviews) = try await (snapsCall, reviewsCall) + guard let latest = snaps.last else { + return ([MergeWarning(text: "These changes haven't been reviewed")], false) + } + var items: [MergeWarning] = [] + let open: @MainActor () -> Void = { + leave { + store.focusTask(taskId, .reviews) + store.pickReview(latest.n) + } + } + let r = reviews.first { $0.snapshotN == latest.n } + if let r { + let found = unignored(r) + switch r.state { + case .running: + items.append(MergeWarning(text: "Review of the latest snapshot is still running", open: open)) + case .failed: + items.append(MergeWarning(text: "Review of the latest snapshot failed", open: open)) + default: + if !found.isEmpty { + items.append(MergeWarning(text: "Review of the latest snapshot found \(severityCounts(found))", open: open)) + } + } + } else { + items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open)) + } + let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json + if !changes.files.isEmpty { + items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed")) + } + return (items, r?.state == .running) +} + +private func unignored(_ r: Review) -> [JSONDict] { + r.state == .done ? findings(r).enumerated().filter { r.findingStates.additionalProperties[String($0.offset)] != .ignored }.map(\.element) : [] +} + +private func coAuthorsLabel(_ coAuthors: [Participant]) -> String { + let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty }.count + return "\(coAuthors.count) co-author\(coAuthors.count == 1 ? "" : "s")\(missing > 0 ? " (\(missing) without git identity)" : "")" +} + +private func mergeTrailers(_ coAuthors: [Participant]) -> String { + var lines: [String] = [] + let credited = coAuthors.filter { !$0.gitName.isEmpty && !$0.gitEmail.isEmpty } + let missing = coAuthors.filter { $0.gitName.isEmpty || $0.gitEmail.isEmpty } + if !credited.isEmpty { + lines.append("Co-authored-by:") + lines += credited.map { " \($0.gitName) <\($0.gitEmail)>" } + } + if !missing.isEmpty { + if !lines.isEmpty { + lines.append("") + } + lines.append("No git identity, so no trailer:") + lines += missing.map { " \($0.displayName) (\($0.login))" } + } + return lines.joined(separator: "\n") +} + +private struct MergeContent: View { + @Bindable var model: MergeModel + @Environment(Store.self) private var store + @FocusState private var focused: Bool + + var body: some View { + let questions = store.inbox.filter { $0.taskId == model.taskId && $0.kind == .question }.count + var warnings = model.reviewItems + if questions > 0 { + let taskId = model.taskId + let leave = model.leave + warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: { + leave { + store.focusTask(taskId, .inbox) + } + })) + } + return VStack(alignment: .leading, spacing: 0) { + if let parentTitle = model.parentTitle { + VStack(alignment: .leading, spacing: 8) { + option(true, "Into \(parentTitle)", "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has.") + option(false, "Into \(model.target)", "Squashed into one commit on the default branch and pushed, like any task.") + } + .padding(.bottom, 16) + } + if !model.toParent { + Text("Merge into \(model.target) as:") + .foregroundStyle(Palette.muted) + .padding(.bottom, 4) + titleRow + .padding(.bottom, 16) + } + if !warnings.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(warnings.enumerated()), id: \.offset) { _, w in + warningRow(w) + } + } + .foregroundStyle(Palette.warn) + .padding(.bottom, 16) + } + if !model.toParent && !model.coAuthors.isEmpty { + DetailsBox(boxed: false) { + Text(coAuthorsLabel(model.coAuthors)) + .foregroundStyle(Palette.muted) + } content: { + ModalText(text: mergeTrailers(model.coAuthors)) + .padding(.top, 8) + } + .padding(.bottom, 16) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var titleRow: some View { + if model.editing { + TextField("", text: Binding(get: { model.title }, set: { model.title = String($0.prefix(maxTitle)) })) + .modifier(FieldStyle(focused: focused)) + .focused($focused) + .accessibilityLabel("Commit title") + .onAppear { + focused = true + } + } else { + HStack(spacing: 8) { + Text(model.title) + .font(Fonts.font(13, weight: 700)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Button("✎") { + model.editing = true + } + .buttonStyle(.plain) + .foregroundStyle(Palette.muted) + .padding(.horizontal, 6) + .help("Edit the commit title") + .accessibilityLabel("Edit the commit title") + } + } + } + + private func option(_ value: Bool, _ label: String, _ hint: String) -> some View { + Button { + model.toParent = value + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: model.toParent == value ? "largecircle.fill.circle" : "circle") + .foregroundStyle(model.toParent == value ? Palette.accent : Palette.muted) + VStack(alignment: .leading, spacing: 0) { + Text(label) + Text(hint) + .font(Fonts.font(12)) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func warningRow(_ w: MergeWarning) -> some View { + if let open = w.open { + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text("⚠ ") + Button(action: open) { + Text(w.text) + .underline() + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .buttonStyle(.plain) + } + } else { + Text("⚠ \(w.text)") + .fixedSize(horizontal: false, vertical: true) + .help(w.detail) + } + } +} + +@MainActor +func confirmDiscard(_ task: ByreTask, _ store: Store, _ overlays: Overlays, anchor: CGRect?) async { + if !(await overlays.showConfirm(["Discard \"\(task.title)\"?"], action: "Discard", danger: true, anchor: anchor)) { + return + } + do { + _ = try await store.api.client.discardTask(path: .init(taskId: task.id)).accepted + try await store.setFocus(taskId: .some(nil)) + } catch { + overlays.showAlert(errorText(error)) + } +} diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift new file mode 100644 index 0000000..b215db6 --- /dev/null +++ b/mac/Sources/Byre/Notifications.swift @@ -0,0 +1,97 @@ +import AppKit +import UserNotifications + +@MainActor +final class Notifier: NSObject, UNUserNotificationCenterDelegate { + static let shared = Notifier() + weak var connection: Connection? + var showWindow: (() -> Void)? + private var pending: String? + + func install() { + UNUserNotificationCenter.current().delegate = self + } + + private func open(_ taskId: String) { + NSApp.activate() + showWindow?() + pending = taskId + deliver() + } + + func deliver() { + guard let taskId = pending, let store = connection?.store else { + return + } + pending = nil + if !taskId.isEmpty { + store.focusTask(taskId) + } + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + let taskId = response.notification.request.content.userInfo["task_id"] as? String ?? "" + await MainActor.run { + open(taskId) + } + } +} + +private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool { + switch item.kind { + case .question: p.questions + case .sessionError: p.errors + case .rateLimited: p.rateLimits + } +} + +private func itemText(_ item: InboxItem) -> String { + let p = item.payload.value + switch item.kind { + case .question: return str(p, "question") ?? "" + case .sessionError: return "Error: \(str(p, "error") ?? "")" + case .rateLimited: return "Rate limited" + } +} + +@MainActor +private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: String) { + let line = trimmed(text).split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" + let content = UNMutableNotificationContent() + 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] + let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil) + Task { + let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + if status != .authorized && status != .provisional { + return + } + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + log.error("show notification: \(errorText(error), privacy: .public)") + } + } +} + +@MainActor +func notifyInbox(_ store: Store, _ item: InboxItem) { + guard let me = store.me, wants(me.notificationPrefs, item) else { + return + } + show(store, item.taskId, item.id, itemText(item)) +} + +@MainActor +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") ?? "") +} diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 98c9984..91da0bc 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -76,7 +76,11 @@ struct MainView: View { } } .onAppear { - overlays.install() + let store = store + let overlays = overlays + overlays.install { key in + ctrlGo(key, store, overlays) + } } .onDisappear { overlays.uninstall() @@ -122,25 +126,22 @@ struct PaneTabButton: View { } } -private enum LeftTab { - case lead, status, settings -} - private struct LeftPane: View { @Environment(Store.self) private var store - @ViewState private var tab = LeftTab.lead + @Environment(Overlays.self) private var overlays var body: some View { + let tab = overlays.leftTab VStack(spacing: 0) { HStack(spacing: 4) { PaneTabButton(label: "Lead", active: tab == .lead) { - tab = .lead + overlays.leftTab = .lead } PaneTabButton(label: "Status", active: tab == .status) { - tab = .status + overlays.leftTab = .status } PaneTabButton(label: "Settings", active: tab == .settings) { - tab = .settings + overlays.leftTab = .settings } Spacer(minLength: 0) HStack(spacing: 10) { @@ -237,6 +238,7 @@ private struct TaskPane: View { private struct TaskBar: View { @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays @Environment(\.paneAccent) private var accent @ViewState private var previews: [Components.Schemas.Preview] = [] @ViewState private var target = 0 @@ -248,25 +250,25 @@ private struct TaskBar: View { let label = task.map { "\(project?.name ?? "") ▸ \($0.title)" } ?? project?.name ?? "No task" HStack(spacing: 0) { Color.clear.frame(maxWidth: .infinity, maxHeight: 1) - Menu { - ForEach(store.activeTasks(), id: \.task.id) { row in - Button("\(row.project.name): \(row.task.title)") { - store.focusTask(row.task.id) - } - } + Button { + overlays.openSwitcher(store) } label: { Text("\(label) ▾") .font(Fonts.font(13, weight: 700)) .foregroundStyle(accent) .lineLimit(1) .truncationMode(.tail) + .contentShape(Rectangle()) } - .menuStyle(.button) .buttonStyle(.plain) - .menuIndicator(.hidden) .padding(.horizontal, 10) .padding(.vertical, 6) .help("Switch task (⌘K)") + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + overlays.pickerFrame = frame + } .layoutPriority(1) HStack(spacing: 12) { Spacer(minLength: 0) diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index e101539..f5fcfbc 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -100,6 +100,7 @@ final class Connection { } phase = .running(store) store.startGlobalStream(after: status.lastEventSeq) + Notifier.shared.deliver() } catch { await store.stop() if gen == generation { @@ -186,6 +187,7 @@ struct FatalView: View { struct RootView: View { @Environment(Connection.self) private var connection + @Environment(\.openWindow) private var openWindow var body: some View { Group { @@ -204,5 +206,13 @@ struct RootView: View { .foregroundStyle(Palette.text) .tint(Palette.accent) .background(Palette.bg) + .onAppear { + let openWindow = openWindow + Notifier.shared.connection = connection + Notifier.shared.showWindow = { + openWindow(id: "main") + } + Notifier.shared.deliver() + } } } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index df3804f..02232fa 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -498,11 +498,17 @@ final class Store { try await self.loadSessions(taskId) } } - case "inbox.created", "inbox.updated": + case "inbox.created": + notifyInbox(self, try! decodePayload(ev.payload, as: InboxItem.self)) + spawn("load inbox") { + try await self.loadInbox() + } + case "inbox.updated": spawn("load inbox") { try await self.loadInbox() } case "turn.ended": + notifyTurn(self, ev) if ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId { updateTask(taskId) { task in if ev.seq > task.summarySeq { diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift new file mode 100644 index 0000000..eaed18b --- /dev/null +++ b/mac/Sources/Byre/Switcher.swift @@ -0,0 +1,487 @@ +import AppKit +import SwiftUI + +typealias WorkflowSummary = Components.Schemas.WorkflowSummary + +let goKeys: Set = ["k", "p", "l", "s", "/"] + +@MainActor +func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { + switch key { + case "k": + overlays.openSwitcher(store) + case "p": + store.focusTab(.project) + case "l": + overlays.leftTab = .lead + overlays.leadFocus += 1 + case "s": + if let taskId = store.selectedTaskId { + Task { + await confirmMerge(taskId, store, overlays) + } + } + case "/": + overlays.showAlert(ShortcutsHelp()) + default: + break + } +} + +@MainActor +func ctrlGo(_ key: String, _ store: Store, _ overlays: Overlays) -> Bool { + if terminalFocused() { + return false + } + runGo(key, store, overlays) + return true +} + +private let shortcuts: [(String, [(String, String)])] = [ + ("Anywhere", [ + ("⌘K", "Switch task, or create one"), + ("⌘P", "Open the Project tab"), + ("⌘L", "Open the Lead tab and its composer"), + ("⌘S", "Merge the right pane's task"), + ("⌘/", "Show these shortcuts"), + ("Esc", "Close the switcher or a dialog"), + ]), + ("Task switcher", [ + ("↑ ↓", "Move the highlight"), + ("Enter", "Pick the highlighted row"), + ("name: title", "Create the task in the project whose name starts with name"), + ("Esc", "Back from the workflow list to the task list"), + ]), + ("Composer and inbox answers", [ + ("Enter", "Send"), + ("Shift+Enter", "New line"), + ("Enter / Space", "Pick the focused choice"), + ]), + ("Dialogs", [("Enter", "Confirm")]), + ("Terminal", [("Ctrl+…", "Goes to the shell, not to byre")]), +] + +private struct ShortcutsHelp: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Keyboard shortcuts") + .font(Fonts.font(14, weight: 700)) + .padding(.bottom, 12) + ForEach(shortcuts, id: \.0) { group, rows in + Text(group) + .font(Fonts.font(12, weight: 700)) + .foregroundStyle(Palette.muted) + .padding(.bottom, 6) + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { + ForEach(rows, id: \.0) { keys, what in + GridRow { + Text(keys) + .padding(.horizontal, 6) + .background(Palette.bg, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border)) + Text(what) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.bottom, 14) + } + Text("Ctrl works in place of ⌘.") + .foregroundStyle(Palette.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private func includes(_ s: String, _ q: String) -> Bool { + q.isEmpty || s.contains(q) +} + +private let switcherPlaceholder = "Go to a task, or type a title to create one…" +private let projectPrefix = Re(#"^([^:\s]+):\s*(.*)$"#) + +struct SwitcherRow: Identifiable { + enum Kind { + case task(Project, ByreTask, Int) + case project(Project) + case newTask(Project, String) + case startWorkflow(Project, String) + case workflow(Project, String, WorkflowSummary) + } + + let id: String + let kind: Kind +} + +@MainActor +@Observable +final class SwitcherModel { + struct Picking { + let seq: Int + let project: Project + let title: String + let typed: String + var workflows: [WorkflowSummary]? + } + + let store: Store + let overlays: Overlays + let anchor: CGRect + var text = "" + var selected = 0 + var picking: Picking? + var focusRequest = 0 + @ObservationIgnored private var picks = 0 + + init(store: Store, overlays: Overlays, anchor: CGRect) { + self.store = store + self.overlays = overlays + self.anchor = anchor + } + + var placeholder: String { + picking.map { "Pick a workflow to start in \($0.project.name): \($0.title)" } ?? switcherPlaceholder + } + + func rows() -> (rows: [SwitcherRow], empty: String) { + if let picking { + return workflowRows(picking) + } + return taskRows() + } + + private func workflowRows(_ p: Picking) -> (rows: [SwitcherRow], empty: String) { + let q = trimmed(text).lowercased() + let rows = (p.workflows ?? []) + .filter { includes("\($0.name) \($0.description)".lowercased(), q) } + .map { SwitcherRow(id: "workflow:\($0.name)", kind: .workflow(p.project, p.title, $0)) } + guard let workflows = p.workflows else { + return (rows, "Loading workflows…") + } + return (rows, workflows.isEmpty ? "No saved workflows." : "No workflow matches.") + } + + private func taskRows() -> (rows: [SwitcherRow], empty: String) { + let typed = trimmed(text) + let prefix = projectPrefix.exec(typed) + let named = prefix.map { m in store.projects.filter { $0.name.lowercased().hasPrefix(m[1]!.lowercased()) } } ?? [] + let targets = named.isEmpty ? nil : named + let title = targets != nil ? prefix![2]! : typed + let q = title.lowercased() + let visits = store.lastVisits() + let matches = store.activeTasks() + .filter { row in + if let targets { + return targets.contains { $0.id == row.project.id } && includes(row.task.title.lowercased(), q) + } + return includes("\(row.project.name) \(row.task.title)".lowercased(), q) + } + .enumerated() + .map { i, row in + (i: i, project: row.project, task: row.task, count: store.inbox.filter { $0.taskId == row.task.id }.count, visited: row.task.id == store.selectedTaskId ? Double.infinity : visits[row.task.id] ?? 0) + } + .sorted { a, b in + if (a.count > 0) != (b.count > 0) { + return a.count > 0 + } + if a.visited != b.visited { + return a.visited < b.visited + } + return a.i < b.i + } + var rows = matches.map { SwitcherRow(id: "task:\($0.task.id)", kind: .task($0.project, $0.task, $0.count)) } + for p in (targets ?? store.projects) where includes("\(p.name) project".lowercased(), q) { + rows.append(SwitcherRow(id: "project:\(p.id)", kind: .project(p))) + } + let fallback = store.focusedProject() ?? store.projects.first + for project in title.isEmpty ? [] : targets ?? (fallback.map { [$0] } ?? []) { + rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project, title))) + rows.append(SwitcherRow(id: "start:\(project.id)", kind: .startWorkflow(project, title))) + } + return (rows, "No active tasks. Type a title to create one.") + } + + func setText(_ value: String) { + text = value + selected = 0 + } + + func move(_ by: Int) { + let count = rows().rows.count + selected = max(0, min(selected + by, count - 1)) + } + + func pick() { + let all = rows().rows + guard !all.isEmpty else { + return + } + pick(all[min(selected, all.count - 1)]) + } + + func pick(_ row: SwitcherRow) { + switch row.kind { + case .task(_, let task, _): + go(task.id) + case .project(let p): + close() + store.focusProject(p.id) + case .newTask(let p, let title): + create(p.id, title, nil) + case .startWorkflow(let p, let title): + pickWorkflow(p, title) + case .workflow(let p, let title, let w): + create(p.id, title, w.name) + } + } + + func escape() { + guard let picking else { + close() + return + } + text = picking.typed + self.picking = nil + selected = 0 + } + + func close() { + if overlays.switcher === self { + overlays.switcher = nil + } + } + + private func go(_ taskId: String) { + close() + if store.inbox.contains(where: { $0.taskId == taskId }) { + store.focusTask(taskId, .inbox) + } else if let task = store.findTask(taskId), store.unreadSummary(task) { + store.openSummary(task) + } else { + store.focusTask(taskId, .transcript) + } + } + + private func create(_ projectId: String, _ title: String, _ workflow: String?) { + close() + Task { + do { + let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, workflow: workflow))).created.body.json + go(task.id) + } catch { + overlays.showAlert(errorText(error)) + } + } + } + + private func pickWorkflow(_ project: Project, _ title: String) { + picks += 1 + let seq = picks + picking = Picking(seq: seq, project: project, title: title, typed: text) + text = "" + selected = 0 + focusRequest += 1 + Task { + do { + let list = try await store.api.client.listWorkflows().ok.body.json + if picking?.seq == seq { + picking?.workflows = list + } + } catch { + close() + overlays.showAlert(errorText(error)) + } + } + } +} + +struct SwitcherPlacement: View { + let model: SwitcherModel + + var body: some View { + GeometryReader { g in + SwitcherLayout(anchor: model.anchor) { + SwitcherBox(model: model, maxList: g.size.height * 0.5) + } + } + } +} + +private struct SwitcherLayout: Layout { + let anchor: CGRect + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let width = min(560, bounds.width * 0.9) + let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) + let left = anchor.midX - width / 2 + let x = max(overlayMargin, min(left, bounds.width - width - overlayMargin)) + subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + anchor.maxY + 4), proposal: ProposedViewSize(width: width, height: size.height)) + } +} + +private struct SwitcherBox: View { + let model: SwitcherModel + let maxList: CGFloat + @FocusState private var focused: Bool + + var body: some View { + let (rows, empty) = model.rows() + let selected = min(model.selected, max(rows.count - 1, 0)) + let selectedId = rows.isEmpty ? nil : rows[selected].id + VStack(alignment: .leading, spacing: 8) { + TextField(model.placeholder, text: Binding(get: { model.text }, set: { model.setText($0) })) + .modifier(FieldStyle(focused: focused)) + .focused($focused) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if rows.isEmpty { + Text(empty) + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in + SwitcherRowView(row: row) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(i == selected ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture { + model.pick(row) + } + .id(row.id) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: maxList) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: "\(selectedId ?? "")\n\(model.text)") { + if let selectedId { + proxy.scrollTo(selectedId) + } + } + } + } + .padding(10) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 8) + .onAppear { + focused = true + } + .onChange(of: model.focusRequest) { + focused = true + } + } +} + +private struct SwitcherRowView: View { + let row: SwitcherRow + @Environment(Store.self) private var store + + var body: some View { + HStack(spacing: 8) { + switch row.kind { + case .task(let project, let task, let count): + let ts = store.taskState(task) + let color = taskPalette[task.color] + StateMark(state: ts, color: color) + Text(taskTitle(project, task, color)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + if count > 0 { + Badge(count: count).help("inbox items") + } + if store.unreadSummary(task) { + Text("new summary") + .font(Fonts.font(11, weight: 700)) + .foregroundStyle(Palette.accent) + .help("A turn summary you haven't read") + .accessibilityLabel("New summary from \(task.title), unread") + } + Text(ts.label) + .font(Fonts.font(11)) + .foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted) + .help(ts.title) + case .project(let p): + StateDot(filled: nil) + Text(p.name) + .foregroundStyle(Palette.muted) + .frame(maxWidth: .infinity, alignment: .leading) + state("project") + case .newTask(let p, let title): + Text(created([("New task in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) + .foregroundStyle(Palette.accent) + .frame(maxWidth: .infinity, alignment: .leading) + state(models[0]) + .help("Model for the first message") + case .startWorkflow(let p, let title): + Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) + .foregroundStyle(Palette.accent) + .frame(maxWidth: .infinity, alignment: .leading) + state("workflow") + case .workflow(let p, let title, let w): + VStack(alignment: .leading, spacing: 0) { + Text(workflowTitle(p, title, w)) + if !w.description.isEmpty { + Text(w.description) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + .foregroundStyle(Palette.accent) + .frame(maxWidth: .infinity, alignment: .leading) + state("workflow") + } + } + } + + private func state(_ text: String) -> some View { + Text(text) + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + } + + private func taskTitle(_ project: Project, _ task: ByreTask, _ color: Color) -> AttributedString { + var parts: [(String, Color?)] = [(project.name, Palette.muted), (" ", nil)] + if let parent = task.parentTaskId { + parts.append(("\(store.findTask(parent)?.title ?? parent) ↳ ", Palette.muted)) + } + parts.append((task.title, color)) + return created(parts) + } + + private func workflowTitle(_ p: Project, _ title: String, _ w: WorkflowSummary) -> AttributedString { + var out = AttributedString("Start ") + var name = AttributedString(w.name) + name.font = Fonts.font(13, weight: 700) + out += name + out += AttributedString(" in ") + var project = AttributedString(p.name) + project.foregroundColor = Palette.muted + out += project + out += AttributedString(": \(title)") + return out + } + + private func created(_ parts: [(String, Color?)]) -> AttributedString { + var out = AttributedString() + for (text, color) in parts { + var a = AttributedString(text) + if let color { + a.foregroundColor = color + } + out += a + } + return out + } +} diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index dbd7d88..d020e71 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -6,6 +6,11 @@ let exitedCode = 4000 let internalErrorCode = 1011 private let retryAfter = Duration.seconds(2) +@MainActor +func terminalFocused() -> Bool { + NSApp.keyWindow?.firstResponder is TerminalView +} + struct TerminalTab: View { @Environment(Store.self) private var store @ViewState private var status = "" diff --git a/web/dist/switcher.js b/web/dist/switcher.js index c25cf81..5265da7 100644 --- a/web/dist/switcher.js +++ b/web/dist/switcher.js @@ -182,6 +182,7 @@ export function openSwitcher() { }; }); replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, empty)); + rows[selected]?.el.scrollIntoView({ block: "nearest" }); }; const renderTasks = () => { const typed = input.value.trim(); diff --git a/web/dist/switcher.js.map b/web/dist/switcher.js.map index d63a390..cce291c 100644 --- a/web/dist/switcher.js.map +++ b/web/dist/switcher.js.map @@ -1 +1 @@ -{"version":3,"file":"switcher.js","sourceRoot":"","sources":["../src/switcher.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"switcher.js","sourceRoot":"","sources":["../src/switcher.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/web/src/switcher.ts b/web/src/switcher.ts index 639dec0..e5bf7c5 100644 --- a/web/src/switcher.ts +++ b/web/src/switcher.ts @@ -207,6 +207,7 @@ export function openSwitcher(): void { }; }); replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, empty)); + rows[selected]?.el.scrollIntoView({ block: "nearest" }); }; const renderTasks = () => {