Let a queued message be taken back into the composer

This commit is contained in:
Ian Gulliver
2026-10-17 15:45:27 -07:00
parent e67576ac53
commit 658f78934c
12 changed files with 218 additions and 37 deletions
+44 -4
View File
@@ -264,6 +264,29 @@ final class ChatModel {
}
}
func withdraw(_ p: PendingSend, _ overlays: Overlays) async {
guard let taskId, let inputId = p.inputId else {
return
}
let key = "message:\(taskId)"
do {
var imgs: [PendingImage] = []
for id in p.imageIds {
let data = try await ImageCache.fetch(.blob(id), store.api)
guard let image = NSImage(data: data) else {
throw ApiError(status: 0, message: "not an image")
}
imgs.append(PendingImage(data: data, image: image))
}
_ = try await store.api.client.withdrawMessage(path: .init(taskId: taskId, inputId: Int64(inputId))).noContent
builder.withdrawn(inputId)
store.drafts?.restoreText(key, p.text)
store.images.restore(key, imgs)
} catch {
overlays.showAlert(errorText(error))
}
}
func interrupt(_ sessionId: String, _ overlays: Overlays) {
Task {
do {
@@ -525,7 +548,7 @@ private struct TranscriptScroll: View {
RowView(row: row, model: model, nested: false)
}
ForEach(model.builder.pending) { p in
PendingView(pending: p)
PendingView(pending: p, model: model)
}
}
}
@@ -647,13 +670,30 @@ private final class ScannerView: NSView {
private struct PendingView: View {
let pending: PendingSend
let model: ChatModel
@Environment(Overlays.self) private var overlays
@ViewState private var withdrawing = false
var body: some View {
TrailingBubble {
VStack(alignment: .leading, spacing: 2) {
Text(pending.queued ? "queued…" : "sending…")
.font(Fonts.font(11, italic: true))
.foregroundStyle(Palette.muted)
HStack(spacing: 8) {
Text(pending.queued ? "queued…" : "sending…")
.font(Fonts.font(11, italic: true))
.foregroundStyle(Palette.muted)
if pending.inputId != nil {
Button("×") {
withdrawing = true
Task {
await model.withdraw(pending, overlays)
withdrawing = false
}
}
.buttonStyle(ThumbRemoveStyle())
.disabled(withdrawing)
.help("Take this message back into the composer")
}
}
if !pending.images.isEmpty {
FlowLayout(spacing: 6) {
ForEach(pending.images) { img in
+6 -1
View File
@@ -237,7 +237,7 @@ struct Thumbnails: View {
}
}
private struct ThumbRemoveStyle: ButtonStyle {
struct ThumbRemoveStyle: ButtonStyle {
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
ThumbRemove(configuration: configuration)
}
@@ -371,6 +371,11 @@ struct ComposerView: View {
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
reportDraft()
}
.onChange(of: store.drafts?.composerText(draftKey)) { _, new in
if let new, new != text {
text = new
}
}
.onChange(of: store.images.images(draftKey).count) {
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
}
+9 -4
View File
@@ -192,15 +192,20 @@ final class Drafts {
if ok {
lostImages[send.key] = nil
} else {
let text = [send.text, composerTexts[send.key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n")
if !text.isEmpty {
composerTexts[send.key] = text
}
restoreText(send.key, send.text)
}
dirty = true
flush()
}
func restoreText(_ key: String, _ text: String) {
let joined = [text, composerTexts[key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n")
if !joined.isEmpty {
composerTexts[key] = joined
}
save()
}
func checkedAnswer(_ id: String) -> [String] {
checked[id] ?? []
}
+1 -1
View File
@@ -67,7 +67,7 @@ final class ImageCache {
return img
}
private static func fetch(_ source: ImageSource, _ api: API) async throws -> Data {
static func fetch(_ source: ImageSource, _ api: API) async throws -> Data {
switch source {
case .data(let data):
return data
+12 -1
View File
@@ -135,6 +135,7 @@ struct PendingSend: Identifiable {
let local: Bool
let queued: Bool
let md: [MDBlock]
var inputId: Int? = nil
}
@MainActor
@@ -231,6 +232,10 @@ final class TranscriptBuilder {
}
}
func withdrawn(_ inputId: Int) {
pending.removeAll { $0.inputId == inputId }
}
private func confirmPending(_ text: String, queued: Bool, local: Bool) {
let i = queued ? pending.firstIndex { !$0.local && $0.text == text } : nil
if let i = i ?? (local ? pending.firstIndex { $0.local && $0.text == text } : nil) {
@@ -417,7 +422,13 @@ final class TranscriptBuilder {
confirmPending(text, queued: false, local: true)
pendingIds += 1
let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) }
pending.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text)))
pending.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), inputId: int(p, "id")))
}
continue
}
if ev.kind == "user.unqueued" {
if sink == nil, let id = int(p, "id") {
withdrawn(id)
}
continue
}