Offer workflow rows in ⌘K only when the text names a workflow
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user