Paste and drop images into every box that talks to an agent
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user