522 lines
19 KiB
Swift
522 lines
19 KiB
Swift
import AppKit
|
|
import ByreAPI
|
|
import SwiftUI
|
|
|
|
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
|
|
|
|
let goKeys: Set<String> = ["k", "p", "l", "s", "/"]
|
|
|
|
@MainActor
|
|
func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
|
|
overlays.image = nil
|
|
switch key {
|
|
case "k":
|
|
overlays.openSwitcher(store)
|
|
case "p":
|
|
store.focusTab(.project)
|
|
case "l":
|
|
overlays.switcher?.close()
|
|
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"),
|
|
("⌘[ ⌘]", "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"),
|
|
("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 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 startTask(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: "go:\(project.id)", kind: .startTask(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, .init(title: title))
|
|
case .startTask(let p, let title):
|
|
create(p.id, .init(title: title, prompt: title, model: store.defaultModel))
|
|
case .startWorkflow(let p, let title):
|
|
pickWorkflow(p, title)
|
|
case .workflow(let p, let title, let w):
|
|
create(p.id, .init(title: title, workflow: 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, .questions)
|
|
} else if let task = store.findTask(taskId), store.unreadSummary(task) {
|
|
store.composerFocus = taskId
|
|
store.openSummary(task)
|
|
} else {
|
|
store.composerFocus = taskId
|
|
store.focusTask(taskId, .transcript)
|
|
}
|
|
}
|
|
|
|
private func create(_ projectId: String, _ body: Components.Schemas.TaskCreate) {
|
|
close()
|
|
Task {
|
|
do {
|
|
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(body)).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: overlayShadow, radius: 14, y: 8)
|
|
.onAppear {
|
|
DispatchQueue.main.async {
|
|
logFocus("switcher before focus")
|
|
focused = true
|
|
DispatchQueue.main.async {
|
|
logFocus("switcher after focus focused=\(focused)")
|
|
}
|
|
}
|
|
}
|
|
.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]
|
|
StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.kind == .answer ? Palette.warn : nil)
|
|
.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")
|
|
}
|
|
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 : Palette.muted)
|
|
.help(ts.title)
|
|
case .project(let p):
|
|
StateDot(filled: nil)
|
|
Text(p.name)
|
|
.foregroundStyle(Palette.muted)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.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)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
state(store.defaultModel)
|
|
.help("Model for the first message")
|
|
case .startTask(let p, let title):
|
|
Text(created([("Start in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
|
|
.foregroundStyle(Palette.accent)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
state(store.defaultModel)
|
|
.help("The title is the agent's first message")
|
|
case .startWorkflow(let p, let title):
|
|
Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
|
|
.foregroundStyle(Palette.accent)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.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))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if !w.description.isEmpty {
|
|
Text(w.description)
|
|
.foregroundStyle(Palette.muted)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|