From 3e537c827d74f6b1c6968e5f895bfe5b16b07fa0 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 08:51:59 -0700 Subject: [PATCH] merge modal location --- docs/macos.md | 2 +- mac/Sources/Byre/Chat.swift | 8 -------- mac/Sources/Byre/Dialogs.swift | 30 ++++++++++++++++++++++++++---- mac/Sources/Byre/MergeDialog.swift | 5 ++--- mac/Sources/Byre/Panes.swift | 6 ++++++ 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/macos.md b/docs/macos.md index 4e30000..8b74725 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -87,7 +87,7 @@ One window. The panes follow the "Web UI layout" rules in `AGENTS.md`: an `HStac - **Left pane:** tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. - **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in the server's focus order, hiding Inbox, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges, as in `taskPane`. The pane's accent is the selected task's colour. -Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, positioned as in the web client (the switcher and the Merge and Discard confirmations just below their anchor), not system sheets or alerts. +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, as in the web client; the merge dialog is centered 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. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index ff24253..1342fba 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -386,14 +386,6 @@ private struct MergeButton: View { .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 - } } } diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index c219f0c..5897c8d 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -25,6 +25,7 @@ struct OverlayModal { let content: AnyView let buttons: [ModalButton] let anchor: CGRect? + let inPane: Bool let canSubmit: @MainActor () -> Bool let done: (String) -> Void } @@ -49,7 +50,7 @@ final class Overlays { var leftTab = LeftTab.lead var leadFocus = 0 @ObservationIgnored var pickerFrame: CGRect = .zero - @ObservationIgnored var mergeFrame: CGRect? + var paneFrame: CGRect = .zero @ObservationIgnored private var monitor: Any? var isOpen: Bool { @@ -64,10 +65,10 @@ final class Overlays { alerts.append(OverlayAlert(content: AnyView(content))) } - func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { + func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { modal?.done("cancel") return await withCheckedContinuation { cont in - modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, canSubmit: canSubmit) { value in + modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in cont.resume(returning: value) } } @@ -248,7 +249,7 @@ struct OverlayHost: View { .onTapGesture { overlays.answerModal("cancel") } - ModalPlacement(modal: m) { value in + ModalPlacement(modal: m, pane: overlays.paneFrame) { value in overlays.answerModal(value) } } @@ -316,8 +317,25 @@ private struct AnchoredBox: Layout { } } +private struct PaneCenteredBox: Layout { + let pane: 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(520, pane.width - 48) + let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil)) + let x = pane.midX - size.width / 2 + let y = min(max(overlayMargin, pane.midY - size.height / 2), bounds.height - overlayMargin - size.height) + subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), proposal: ProposedViewSize(width: width, height: size.height)) + } +} + private struct ModalPlacement: View { let modal: OverlayModal + let pane: CGRect let answer: (String) -> Void var body: some View { @@ -327,6 +345,10 @@ private struct ModalPlacement: View { AnchoredBox(anchor: anchor) { box } + } else if modal.inPane { + PaneCenteredBox(pane: pane) { + box + } } else { box .padding(24) diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index e37768a..cb1cb38 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -137,9 +137,8 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async 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" { + if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" { await merge(false) } return @@ -148,7 +147,7 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async 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 }) + ], inPane: true, canSubmit: { model.valid }) if choice == "now" || choice == "after" { await merge(choice == "after") } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 72df4c0..bb882a6 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -180,6 +180,7 @@ private struct LeftPane: View { private struct TaskPane: View { @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays @ViewState private var seen: Set<String> = [] var body: some View { @@ -198,6 +199,11 @@ private struct TaskPane: View { } action: { frame in store.paneFocus.setFrame(frame) } + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + overlays.paneFrame = frame + } .onAppear { seen = Set(store.inbox.map(\.id)) }