Paste and drop images into every box that talks to an agent

This commit is contained in:
Ian Gulliver
2026-10-20 17:32:32 -07:00
parent 49f57b73fc
commit 15482473f7
9 changed files with 133 additions and 83 deletions
+31 -7
View File
@@ -261,7 +261,7 @@ private struct ChangesHead: View {
}
Spacer(minLength: 0)
if let taskId = store.selectedTaskId, let drafts = store.drafts {
let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count
let n = drafts.lineComments(taskId).filter(lineCommentReady(store.images, taskId)).count
if n > 0 {
Button("Send \(n) \(n == 1 ? "comment" : "comments")") {
sendComments(taskId, drafts)
@@ -277,12 +277,18 @@ private struct ChangesHead: View {
}
private func sendComments(_ taskId: String, _ drafts: Drafts) {
let sent = drafts.takeLineComments(taskId)
let sent = drafts.takeLineComments(taskId, ready: lineCommentReady(store.images, taskId))
let keys = sent.map { lineCommentImageKey(taskId, $0.anchor) }
let imgs = keys.map { store.images.take($0) }
Task {
do {
try await store.messageTask(taskId, commentsMessage(sent))
let ids = try await uploadImages(store.api, imgs.flatMap { $0 })
try await store.messageTask(taskId, commentsMessage(sent, imgs.map(\.count)), images: ids)
} catch {
drafts.restoreLineComments(taskId, sent)
for (key, i) in zip(keys, imgs) {
store.images.restore(key, i)
}
overlays.showAlert(errorText(error))
}
}
@@ -530,9 +536,21 @@ private func patchRows(_ lines: ArraySlice<String>, _ path: String) -> [DiffRow]
}
}
func commentsMessage(_ comments: [LineComment]) -> String {
comments.map { c in
"`\(c.anchor.path):\(c.anchor.line)`\(c.anchor.old ? " (removed line)" : "")\n> \(c.anchor.quote)\n\n\(trimmed(c.text))"
func lineCommentImageKey(_ taskId: String, _ anchor: LineAnchor) -> String {
"comment:\(taskId):\(anchor.path):\(anchor.line):\(anchor.old):\(anchor.quote)"
}
@MainActor
func lineCommentReady(_ images: ImageDrafts, _ taskId: String) -> (LineComment) -> Bool {
{ c in !trimmed(c.text).isEmpty || images.has(lineCommentImageKey(taskId, c.anchor)) }
}
func commentsMessage(_ comments: [LineComment], _ imageCounts: [Int]) -> String {
var first = 1
return zip(comments, imageCounts).map { c, n in
let attached = n == 0 ? "" : n == 1 ? "(image \(first) attached)" : "(images \(first)–\(first + n - 1) attached)"
first += n
return ["`\(c.anchor.path):\(c.anchor.line)`\(c.anchor.old ? " (removed line)" : "")\n> \(c.anchor.quote)", trimmed(c.text), attached].filter { !$0.isEmpty }.joined(separator: "\n\n")
}.joined(separator: "\n\n")
}
@@ -576,7 +594,7 @@ struct DiffLines: View {
let comment = drafts.lineComment(taskId, anchor)
DiffLineRow(row: row, mark: comment != nil, hoverable: true)
.simultaneousGesture(TapGesture().onEnded {
drafts.toggleLineComment(taskId, anchor)
drafts.toggleLineComment(taskId, anchor, ready: lineCommentReady(store.images, taskId))
})
if comment != nil {
LineCommentBox(drafts: drafts, taskId: taskId, anchor: anchor)
@@ -642,11 +660,15 @@ private struct LineCommentBox: View {
let drafts: Drafts
let taskId: String
let anchor: LineAnchor
@Environment(Store.self) private var store
@FocusState private var focused: Bool
var body: some View {
let text = Binding(get: { drafts.lineComment(taskId, anchor)?.text ?? "" }, set: { drafts.setLineComment(taskId, anchor, $0) })
let key = lineCommentImageKey(taskId, anchor)
VStack(alignment: .trailing, spacing: 4) {
Thumbnails(key: key)
.frame(maxWidth: .infinity, alignment: .leading)
TextEditor(text: text)
.font(Fonts.font(12))
.scrollContentBackground(.hidden)
@@ -666,7 +688,9 @@ private struct LineCommentBox: View {
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border))
.imageInput(key)
Button("remove") {
_ = store.images.take(key)
drafts.dropLineComment(taskId, anchor)
}
.buttonStyle(SmallLinkButtonStyle())
+47 -32
View File
@@ -1481,42 +1481,53 @@ private struct ReadyBubble: View {
.overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border))
}
private func imageKey(_ task: ByreTask) -> String {
"ready:\(task.id):\(when.timeIntervalSince1970)"
}
@ViewBuilder
private func otherEntry(_ task: ByreTask) -> some View {
if let text = other {
TextEditor(text: Binding(get: { text }, set: { other = $0 }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(dismissing)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked || trimmed(text).isEmpty {
return .ignored
let key = imageKey(task)
let hasImages = store.images.has(key)
if other != nil || hasImages {
let text = other ?? ""
VStack(alignment: .leading, spacing: 6) {
Thumbnails(key: key)
TextEditor(text: Binding(get: { text }, set: { other = $0 }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(dismissing)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked || (trimmed(text).isEmpty && !hasImages) {
return .ignored
}
dismiss(task, then: text)
return .handled
}
dismiss(task, then: text)
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Something else to tell the agent; sending it dismisses this")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Something else to tell the agent; sending it dismisses this")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.onChange(of: editing) {
if !editing && trimmed(other ?? "").isEmpty {
other = nil
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.imageInput(key)
.onChange(of: editing) {
if !editing && trimmed(other ?? "").isEmpty && !store.images.has(key) {
other = nil
}
}
}
}
} else {
Text("Other…")
.foregroundStyle(Palette.muted)
@@ -1537,14 +1548,18 @@ private struct ReadyBubble: View {
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
let key = imageKey(task)
let imgs = store.images.take(key)
Task {
do {
let ids = text == nil ? nil : try await uploadImages(store.api, imgs)
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text)
try await store.messageTask(task.id, text, images: ids)
other = nil
}
} catch {
store.images.restore(key, imgs)
overlays.showAlert(errorText(error))
}
dismissing = false
+39 -13
View File
@@ -93,6 +93,10 @@ final class ImageDrafts {
byKey[key] ?? []
}
func has(_ key: String) -> Bool {
byKey[key] != nil
}
func remove(_ key: String, _ id: UUID) {
let rest = images(key).filter { $0.id != id }
byKey[key] = rest.isEmpty ? nil : rest
@@ -218,6 +222,38 @@ struct PasteboardCommands: Commands {
}
}
private struct ImageInput: ViewModifier {
let key: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var targeted = false
func body(content: Content) -> some View {
content
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
.overlay {
if targeted {
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent)
}
}
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(key, providers: providers, overlays)
return true
}
}
}
}
extension View {
func imageInput(_ key: String) -> some View {
modifier(ImageInput(key: key))
}
}
struct Thumbnails: View {
let key: String
@Environment(Store.self) private var store
@@ -282,7 +318,6 @@ struct ComposerView: View {
@Environment(\.inRightPane) private var inRightPane
@ViewState private var text = ""
@ViewState private var pick: String?
@ViewState private var targeted = false
@ViewState private var shownOverride: Bool?
@FocusState private var focused: Bool
@@ -467,7 +502,6 @@ struct ComposerView: View {
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($focused)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(draftKey, pasteboard: pb, overlays) }))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked {
@@ -489,16 +523,8 @@ struct ComposerView: View {
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted ? Palette.accent : inRightPane ? accent : focused ? Palette.accent : Palette.border, lineWidth: inRightPane && focused ? 2 : 1))
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(draftKey, providers: providers, overlays)
return true
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(inRightPane ? accent : focused ? Palette.accent : Palette.border, lineWidth: inRightPane && focused ? 2 : 1))
.imageInput(draftKey)
}
private func reportDraft() {
@@ -510,7 +536,7 @@ struct ComposerView: View {
private func submit(_ m: ModelState) {
let value = text
let images = store.images
if trimmed(value).isEmpty && images.images(draftKey).isEmpty {
if trimmed(value).isEmpty && !images.has(draftKey) {
return
}
guard let drafts = store.drafts else {
+5 -5
View File
@@ -260,10 +260,10 @@ final class Drafts {
comments[taskId]?.first { $0.anchor == anchor }
}
func toggleLineComment(_ taskId: String, _ anchor: LineAnchor) {
func toggleLineComment(_ taskId: String, _ anchor: LineAnchor, ready: (LineComment) -> Bool) {
var list = comments[taskId] ?? []
if let i = list.firstIndex(where: { $0.anchor == anchor }) {
if !trimmed(list[i].text).isEmpty {
if ready(list[i]) {
return
}
list.remove(at: i)
@@ -290,10 +290,10 @@ final class Drafts {
save()
}
func takeLineComments(_ taskId: String) -> [LineComment] {
func takeLineComments(_ taskId: String, ready isReady: (LineComment) -> Bool) -> [LineComment] {
let all = comments[taskId] ?? []
let ready = all.filter { !trimmed($0.text).isEmpty }
let rest = all.filter { trimmed($0.text).isEmpty }
let ready = all.filter(isReady)
let rest = all.filter { !isReady($0) }
comments[taskId] = rest.isEmpty ? nil : rest
dirty = true
flush()
+3 -13
View File
@@ -112,7 +112,7 @@ private final class CardModel {
}
var hasImages: Bool {
!store.images.images(key).isEmpty
store.images.has(key)
}
var typed: String {
@@ -494,7 +494,6 @@ private struct TextEntry: View {
@Environment(\.paneAccent) private var accent
@FocusState private var editing: Bool
@ViewState private var hover = false
@ViewState private var targeted = false
var body: some View {
let id = model.item.id
@@ -514,7 +513,6 @@ private struct TextEntry: View {
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(model.busy)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked {
@@ -536,16 +534,8 @@ private struct TextEntry: View {
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted || editing ? Palette.accent : Palette.border))
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(key, providers: providers, overlays)
return true
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.imageInput(key)
}
.onChange(of: editing) {
if !editing {
+3 -3
View File
@@ -347,12 +347,12 @@ final class Store {
sessions[taskId] = list
}
func messageTask(_ taskId: String, _ text: String) async throws {
func messageTask(_ taskId: String, _ text: String, images ids: [String]? = nil) async throws {
let sessions = try await api.client.listSessions(path: .init(taskId: taskId)).ok.body.json
if let live = sessions.last(where: { $0.state != .completed }) {
_ = try await api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text))).accepted
_ = try await api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text, images: ids))).accepted
} else {
_ = try await api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text))).created
_ = try await api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text, images: ids))).created
}
}
+2 -7
View File
@@ -353,7 +353,6 @@ private struct SwitcherBox: View {
@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()
@@ -432,7 +431,6 @@ private struct SwitcherBox: View {
.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)
}
@@ -446,11 +444,8 @@ private struct SwitcherBox: View {
.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
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border))
.imageInput(model.imageKey)
}
}