Offer workflow rows in ⌘K only when the text names a workflow

This commit is contained in:
Ian Gulliver
2026-10-20 14:07:18 -07:00
parent f76bddc6d6
commit a728cb2c96
4 changed files with 38 additions and 100 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ final class Overlays {
} else if image != nil {
image = nil
} else if let switcher {
switcher.escape()
switcher.close()
} else if menu != nil {
menu = nil
}
+34 -96
View File
@@ -79,7 +79,7 @@ private let shortcuts: [(String, [(String, String)])] = [
("Enter", "Pick the highlighted row"),
("Shift+Enter", "New line in the first message"),
("name: message", "Start the task in the project whose name starts with name"),
("Esc", "Back from the workflow list to the task list"),
("workflow title", "Start the workflow named workflow, titled title"),
]),
("Composer and answers", [
("Enter", "Send"),
@@ -135,7 +135,6 @@ struct SwitcherRow: Identifiable {
case project(Project)
case newTask(Project)
case startTask(Project, String, Int)
case startWorkflow(Project, String)
case workflow(Project, String, WorkflowSummary)
}
@@ -146,53 +145,48 @@ struct SwitcherRow: Identifiable {
@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
let imageKey = "switcher:\(UUID().uuidString)"
var text = ""
var selected = 0
var picking: Picking?
var focusRequest = 0
@ObservationIgnored private var picks = 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))
}
}
}
var placeholder: String {
picking.map { "Pick a workflow to start in \($0.project.name): \($0.title)" } ?? switcherPlaceholder
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) {
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()) } } ?? []
@@ -233,15 +227,16 @@ final class SwitcherModel {
(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
for project in targets ?? recent {
if title.isEmpty && images == 0 {
rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project)))
continue
}
rows.append(SwitcherRow(id: "go:\(project.id)", kind: .startTask(project, title, images)))
if images == 0 {
rows.append(SwitcherRow(id: "start:\(project.id)", kind: .startWorkflow(project, title)))
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)))
}
return (rows, "No active tasks. Type a first message to start one.")
}
@@ -275,23 +270,11 @@ final class SwitcherModel {
create(p.id, .init())
case .startTask(let p, let message, _):
start(p.id, message)
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
@@ -334,26 +317,6 @@ final class SwitcherModel {
}
}
}
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 {
@@ -454,13 +417,10 @@ private struct SwitcherBox: View {
}
}
}
.onChange(of: model.focusRequest) {
focused = true
}
}
private var editor: some View {
Text(model.text.isEmpty ? model.placeholder : model.text + " ")
Text(model.text.isEmpty ? switcherPlaceholder : model.text + " ")
.font(Fonts.font(13))
.frame(maxWidth: .infinity, minHeight: 54, maxHeight: 240, alignment: .topLeading)
.padding(.horizontal, 10)
@@ -472,14 +432,14 @@ private struct SwitcherBox: View {
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($focused)
.focusedValue(\.composer, ComposerTarget(paste: { pb in model.picking == nil && store.images.add(model.imageKey, pasteboard: pb, overlays) }))
.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(model.placeholder)
Text(switcherPlaceholder)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -488,9 +448,6 @@ private struct SwitcherBox: View {
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted || focused ? Palette.accent : Palette.border))
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
if model.picking != nil {
return false
}
store.images.add(model.imageKey, providers: providers, overlays)
return true
}
@@ -535,15 +492,12 @@ private struct SwitcherRowView: View {
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state("project")
case .newTask(let p):
Text(created([("New task in ", nil), (p.name, Palette.muted)]))
.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 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)]))
@@ -551,15 +505,6 @@ private struct SwitcherRowView: View {
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state(store.defaultModel)
.help("The agent's first message; the agent names the task")
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))
@@ -574,17 +519,10 @@ private struct SwitcherRowView: View {
}
.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 {