Grow the task switcher's box and let it attach images
This commit is contained in:
@@ -198,7 +198,7 @@ final class Overlays {
|
||||
switcher.move(-1)
|
||||
return true
|
||||
}
|
||||
if enter {
|
||||
if enter && !flags.contains(.shift) {
|
||||
switcher.pick()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ private let shortcuts: [(String, [(String, String)])] = [
|
||||
("Task switcher", [
|
||||
("↑ ↓", "Move the highlight"),
|
||||
("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"),
|
||||
]),
|
||||
@@ -120,15 +121,15 @@ 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…"
|
||||
private let projectPrefix = Re(#"^([^:\s]+):\s*(.*)$"#)
|
||||
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)
|
||||
case newTask(Project)
|
||||
case startTask(Project, String)
|
||||
case startTask(Project, String, Int)
|
||||
case startWorkflow(Project, String)
|
||||
case workflow(Project, String, WorkflowSummary)
|
||||
}
|
||||
@@ -151,6 +152,7 @@ final class SwitcherModel {
|
||||
let store: Store
|
||||
let overlays: Overlays
|
||||
let anchor: CGRect
|
||||
let imageKey = "switcher:\(UUID().uuidString)"
|
||||
var text = ""
|
||||
var selected = 0
|
||||
var picking: Picking?
|
||||
@@ -213,9 +215,13 @@ final class SwitcherModel {
|
||||
}
|
||||
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 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)))
|
||||
}
|
||||
}
|
||||
let focused = store.focusedProject()?.id
|
||||
let used = store.projects.enumerated().map { i, p in
|
||||
@@ -223,12 +229,14 @@ final class SwitcherModel {
|
||||
}
|
||||
let recent = used.sorted { $0.at != $1.at ? $0.at > $1.at : $0.i < $1.i }.map(\.project)
|
||||
for project in targets ?? recent {
|
||||
if title.isEmpty {
|
||||
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)))
|
||||
rows.append(SwitcherRow(id: "start:\(project.id)", kind: .startWorkflow(project, title)))
|
||||
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)))
|
||||
}
|
||||
}
|
||||
return (rows, "No active tasks. Type a first message to start one.")
|
||||
}
|
||||
@@ -260,8 +268,8 @@ final class SwitcherModel {
|
||||
store.focusProject(p.id)
|
||||
case .newTask(let p):
|
||||
create(p.id, .init())
|
||||
case .startTask(let p, let message):
|
||||
create(p.id, .init(prompt: message, model: store.defaultModel))
|
||||
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):
|
||||
@@ -282,6 +290,7 @@ final class SwitcherModel {
|
||||
func close() {
|
||||
if overlays.switcher === self {
|
||||
overlays.switcher = nil
|
||||
_ = store.images.take(imageKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +309,27 @@ final class SwitcherModel {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickWorkflow(_ project: Project, _ title: String) {
|
||||
picks += 1
|
||||
let seq = picks
|
||||
@@ -327,7 +357,7 @@ struct SwitcherPlacement: View {
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
SwitcherLayout(anchor: model.anchor) {
|
||||
SwitcherBox(model: model, maxList: g.size.height * 0.5)
|
||||
SwitcherBox(model: model, maxList: max(80, min(g.size.height * 0.5, g.size.height - model.anchor.maxY - 420)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +371,7 @@ private struct SwitcherLayout: Layout {
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let width = min(560, bounds.width * 0.9)
|
||||
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))
|
||||
@@ -352,16 +382,26 @@ private struct SwitcherLayout: Layout {
|
||||
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) {
|
||||
TextField(model.placeholder, text: Binding(get: { model.text }, set: { model.setText($0) }))
|
||||
.modifier(FieldStyle(focused: focused))
|
||||
.focused($focused)
|
||||
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) {
|
||||
@@ -413,6 +453,43 @@ private struct SwitcherBox: View {
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
|
||||
private var editor: some View {
|
||||
Text(model.text.isEmpty ? model.placeholder : 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 model.picking == nil && 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)
|
||||
.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
|
||||
if model.picking != nil {
|
||||
return false
|
||||
}
|
||||
store.images.add(model.imageKey, providers: providers, overlays)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwitcherRowView: View {
|
||||
@@ -462,8 +539,9 @@ private struct SwitcherRowView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
state(store.defaultModel)
|
||||
.help("Model for the first message")
|
||||
case .startTask(let p, let message):
|
||||
Text(created([("Start in ", nil), (p.name, Palette.muted), (": \(message)", nil)]))
|
||||
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)]))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Reference in New Issue
Block a user