macOS client overlays, menus and notifications

This commit is contained in:
Ian Gulliver
2026-10-17 08:24:22 -07:00
parent b43cffab6d
commit 38d56139c3
17 changed files with 1297 additions and 101 deletions
+487
View File
@@ -0,0 +1,487 @@
import AppKit
import SwiftUI
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
let goKeys: Set<String> = ["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
}
}