Files
byre/mac/Sources/Byre/Switcher.swift
T

560 lines
21 KiB
Swift
Raw Normal View History

import AppKit
import ByreAPI
import SwiftUI
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
2026-10-20 08:57:37 -07:00
let goKeys: Set<String> = ["k", "n", "p", "l", "s", "/"]
@MainActor
func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
2026-10-17 10:54:06 -07:00
overlays.image = nil
switch key {
case "k":
overlays.openSwitcher(store)
2026-10-20 08:57:37 -07:00
case "n":
overlays.switcher?.close()
if let project = store.focusedProject() ?? store.projects.first {
createTask(project.id, .init(), store, overlays)
}
case "p":
store.focusTab(.project)
case "l":
2026-10-17 10:54:06 -07:00
overlays.switcher?.close()
overlays.leftTab = .lead
overlays.leadFocus += 1
case "s":
2026-10-20 11:25:34 -07:00
guard let taskId = store.selectedTaskId else {
break
}
Task {
if let task = store.findTask(taskId), task.state == .discardSuggested {
await confirmDiscard(task, store, overlays, anchor: nil)
return
}
2026-10-20 11:25:34 -07:00
await confirmMerge(taskId, store, overlays)
}
case "/":
overlays.showAlert(ShortcutsHelp())
default:
break
}
}
2026-10-20 08:57:37 -07:00
@MainActor
func createTask(_ projectId: String, _ body: Components.Schemas.TaskCreate, _ store: Store, _ overlays: Overlays) {
Task {
do {
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(body)).created.body.json
store.composerFocus = task.id
store.focusTask(task.id, .transcript)
} catch {
overlays.showAlert(errorText(error))
}
}
}
@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"),
2026-10-20 08:57:37 -07:00
("⌘N", "New task in the current project"),
("⌘P", "Open the Project tab"),
("⌘L", "Open the Lead tab and its composer"),
2026-10-20 11:25:34 -07:00
("⌘S", "Merge the right pane's task, or discard it when the agent suggests discarding"),
2026-10-18 21:40:27 -07:00
("⌘[ ⌘]", "Bring the previous or next server's window forward"),
("⌘/", "Show these shortcuts"),
("Esc", "Close the switcher or a dialog"),
]),
("Task switcher", [
("↑ ↓", "Move the highlight"),
("Enter", "Pick the highlighted row"),
("Shift+Enter", "New line in the first message"),
2026-10-20 08:57:37 -07:00
("name: message", "Start the task in the project whose name starts with name"),
("workflow title", "Start the workflow named workflow, titled title"),
]),
("Composer and 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 first message to start one. Paste or drop images to attach them."
private let projectPrefix = Re(#"(?s)^([^:\s]+):\s*(.*)$"#)
struct SwitcherRow: Identifiable {
enum Kind {
case task(Project, ByreTask, Int)
case project(Project)
2026-10-20 08:57:37 -07:00
case newTask(Project)
case startTask(Project, String, Int)
case workflow(Project, String, WorkflowSummary)
}
let id: String
let kind: Kind
}
@MainActor
@Observable
final class SwitcherModel {
let store: Store
let overlays: Overlays
let anchor: CGRect
let imageKey = "switcher:\(UUID().uuidString)"
var text = ""
var selected = 0
var workflows: [WorkflowSummary] = []
init(store: Store, overlays: Overlays, anchor: CGRect) {
self.store = store
self.overlays = overlays
self.anchor = anchor
Task {
do {
workflows = try await store.api.client.listWorkflows().ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
}
}
private func workflowMatch(_ title: String) -> (WorkflowSummary, String)? {
for w in workflows.sorted(by: { $0.name.count > $1.name.count }) {
guard title.lowercased().hasPrefix(w.name.lowercased()) else {
continue
}
var rest = title.dropFirst(w.name.count)
guard rest.first.map({ $0 == ":" || $0.isWhitespace }) ?? false else {
continue
}
if rest.first == ":" {
rest = rest.dropFirst()
}
let t = trimmed(String(rest))
if !t.isEmpty {
return (w, t)
}
}
return nil
}
func rows() -> (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, rank: store.taskRank(row.task), visited: row.task.id == store.selectedTaskId ? Double.infinity : visits[row.task.id] ?? 0)
}
.sorted { a, b in
if a.rank != b.rank {
return a.rank < b.rank
}
if a.visited != b.visited {
return a.visited < b.visited
}
return a.i < b.i
}
let images = store.images.images(imageKey).count
var rows: [SwitcherRow] = []
if images == 0 {
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)))
}
}
2026-10-20 10:16:31 -07:00
let focused = store.focusedProject()?.id
let used = store.projects.enumerated().map { i, p in
(i: i, project: p, at: p.id == focused ? Double.infinity : (store.tasks[p.id] ?? []).map { visits[$0.id] ?? 0 }.max() ?? 0)
}
let recent = used.sorted { $0.at != $1.at ? $0.at > $1.at : $0.i < $1.i }.map(\.project)
let workflow = images == 0 ? workflowMatch(title) : nil
2026-10-20 10:16:31 -07:00
for project in targets ?? recent {
if title.isEmpty && images == 0 {
2026-10-20 08:57:37 -07:00
rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project)))
continue
}
if let (w, rest) = workflow {
rows.append(SwitcherRow(id: "workflow:\(project.id)", kind: .workflow(project, rest, w)))
}
rows.append(SwitcherRow(id: "go:\(project.id)", kind: .startTask(project, title, images)))
}
2026-10-20 08:57:37 -07:00
return (rows, "No active tasks. Type a first message to start 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)
2026-10-20 08:57:37 -07:00
case .newTask(let p):
create(p.id, .init())
case .startTask(let p, let message, _):
start(p.id, message)
case .workflow(let p, let title, let w):
2026-10-17 08:34:49 -07:00
create(p.id, .init(title: title, workflow: w.name))
}
}
func close() {
if overlays.switcher === self {
overlays.switcher = nil
_ = store.images.take(imageKey)
}
}
private func go(_ taskId: String) {
close()
if store.inbox.contains(where: { $0.taskId == taskId }) {
store.showQuestion(taskId)
} else {
2026-10-17 09:04:32 -07:00
store.composerFocus = taskId
store.focusTask(taskId, .transcript)
}
}
2026-10-17 08:34:49 -07:00
private func create(_ projectId: String, _ body: Components.Schemas.TaskCreate) {
close()
2026-10-20 08:57:37 -07:00
createTask(projectId, body, store, overlays)
}
private func start(_ projectId: String, _ message: String) {
let imgs = store.images.take(imageKey)
let typed = text
close()
Task {
do {
let ids = try await uploadImages(store.api, imgs)
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(prompt: message.isEmpty ? nil : message, images: ids, model: store.defaultModel))).created.body.json
store.composerFocus = task.id
store.focusTask(task.id, .transcript)
} catch {
overlays.openSwitcher(store)
if let s = overlays.switcher {
s.setText(typed)
store.images.restore(s.imageKey, imgs)
}
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: max(80, min(g.size.height * 0.5, g.size.height - model.anchor.maxY - 420)))
}
}
}
}
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(720, 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
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@FocusState private var focused: Bool
@State private var targeted = false
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) {
if !store.images.images(model.imageKey).isEmpty {
ScrollView {
Thumbnails(key: model.imageKey)
.padding(.trailing, 8)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxHeight: 110)
.fixedSize(horizontal: false, vertical: true)
}
editor
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))
2026-10-17 10:54:06 -07:00
.shadow(color: overlayShadow, radius: 14, y: 8)
.onAppear {
2026-10-17 08:28:08 -07:00
DispatchQueue.main.async {
2026-10-17 10:06:12 -07:00
logFocus("switcher before focus")
2026-10-17 08:28:08 -07:00
focused = true
2026-10-17 10:06:12 -07:00
DispatchQueue.main.async {
logFocus("switcher after focus focused=\(focused)")
}
2026-10-17 08:28:08 -07:00
}
}
}
private var editor: some View {
Text(model.text.isEmpty ? switcherPlaceholder : model.text + " ")
.font(Fonts.font(13))
.frame(maxWidth: .infinity, minHeight: 54, maxHeight: 240, alignment: .topLeading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.hidden()
.overlay {
TextEditor(text: Binding(get: { model.text }, set: { model.setText($0) }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($focused)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(model.imageKey, pasteboard: pb, overlays) }))
.padding(.horizontal, 5)
.padding(.vertical, 6)
}
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if model.text.isEmpty {
Text(switcherPlaceholder)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted || focused ? Palette.accent : Palette.border))
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(model.imageKey, providers: providers, overlays)
return 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]
StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.needsYou ? Palette.warn : nil)
2026-10-17 10:54:06 -07:00
.help(ts.title)
Text(taskTitle(project, task, color))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if count > 0 {
Badge(count: count).help("open questions")
}
2026-10-19 19:00:52 -07:00
if ts.kind == .idle && store.unreadSummary(task) {
Text("new summary")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.accent)
.help("A turn summary you haven't read")
2026-10-20 08:57:37 -07:00
.accessibilityLabel("New summary from \(task.name), unread")
2026-10-19 19:00:52 -07:00
} else {
Text(ts.label)
.font(Fonts.font(11))
.foregroundStyle(ts.needsYou ? Palette.warn : Palette.muted)
.help(ts.title)
}
case .project(let p):
StateDot(filled: nil)
Text(p.name)
.foregroundStyle(Palette.muted)
2026-10-17 10:54:06 -07:00
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
2026-10-20 08:57:37 -07:00
case .newTask(let p):
Text(created([("New task in ", nil), (p.name, Palette.muted)]))
.foregroundStyle(Palette.accent)
2026-10-17 10:54:06 -07:00
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
case .startTask(let p, let message, let count):
let attached = count == 0 ? "" : count == 1 ? " + 1 image" : " + \(count) images"
Text(created([("Start in ", nil), (p.name, Palette.muted), (message.isEmpty ? attached : ": \(message)\(attached)", nil)]))
2026-10-17 08:34:49 -07:00
.foregroundStyle(Palette.accent)
2026-10-17 10:54:06 -07:00
.lineLimit(1)
.truncationMode(.tail)
2026-10-17 08:34:49 -07:00
.frame(maxWidth: .infinity, alignment: .leading)
case .workflow(let p, let title, let w):
VStack(alignment: .leading, spacing: 0) {
Text(workflowTitle(p, title, w))
2026-10-17 10:54:06 -07:00
.lineLimit(1)
.truncationMode(.tail)
if !w.description.isEmpty {
Text(w.description)
.foregroundStyle(Palette.muted)
2026-10-17 10:54:06 -07:00
.lineLimit(1)
.truncationMode(.tail)
}
}
.foregroundStyle(Palette.accent)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func taskTitle(_ project: Project, _ task: ByreTask, _ color: Color) -> AttributedString {
var parts: [(String, Color?)] = [(project.name, Palette.muted), (" ", nil)]
if let parent = task.parentTaskId {
2026-10-20 08:57:37 -07:00
parts.append(("\(store.findTask(parent)?.name ?? parent) ↳ ", Palette.muted))
}
2026-10-20 08:57:37 -07:00
parts.append((task.name, 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
}
}