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
-3
View File
@@ -2,9 +2,6 @@
# M1 — One agent end to end
- Cancel a queued message
A × on the transcript's "queued…" bubble withdraws the `task_inputs` row with a new `DELETE`, refused once the message has been handed over, and puts its text and images back in the composer. Claude Code, Zed and Devin all let a queued follow-up be taken back.
# M2 — Seeing the work
- Comment on diff lines in the Changes view
+14
View File
@@ -762,6 +762,20 @@ paths:
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/queue/{inputId}:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
- { name: inputId, in: path, required: true, schema: { type: integer, format: int64 } }
delete:
operationId: withdrawMessage
description: >
Takes back a queued message (the id from its user.queued event) before the agent gets it, and records a
user.unqueued event. Refused once the message has been handed over.
responses:
"204": { description: The message was withdrawn. }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/sessions/{sessionId}:
parameters:
- { name: sessionId, in: path, required: true, schema: { type: string } }
+3 -3
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -75,7 +75,42 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
return sess, nil
}
func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, actor string) error {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
return err
}
sessions, err := m.store.ListSessions(ctx, taskID)
if err != nil {
return err
}
if len(sessions) == 0 {
return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID)
}
sess := sessions[len(sessions)-1]
unlock := m.lockDelivery(queueLockKey(taskID))
defer unlock()
err = m.store.WithdrawTaskInput(ctx, taskID, inputID, task.ProjectId, sess.Id, actor)
if errors.Is(err, store.ErrNotFound) {
return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID)
}
if err != nil {
return err
}
slog.Info("queued message withdrawn", "session", sess.Id, "task", taskID, "input", inputID, "actor", actor)
return nil
}
func queueLockKey(taskID string) string {
return "task:" + taskID
}
func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (bool, error) {
unlock := m.lockDelivery(queueLockKey(sess.TaskId))
defer unlock()
inputs, err := m.store.TaskInputs(ctx, sess.TaskId)
if err != nil || len(inputs) == 0 {
return false, err
@@ -131,6 +166,9 @@ func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskIn
return nil
}
unlock := m.lockDelivery(queueLockKey(l.taskID))
defer unlock()
inputs, err := m.store.TaskInputs(ctx, l.taskID)
if err != nil {
slog.Error("list queued messages", "session", l.id, "err", err)
+60 -19
View File
@@ -1624,6 +1624,9 @@ type ServerInterface interface {
// (GET /api/tasks/{taskId}/previews)
ListPreviews(w http.ResponseWriter, r *http.Request, taskId string)
// (DELETE /api/tasks/{taskId}/queue/{inputId})
WithdrawMessage(w http.ResponseWriter, r *http.Request, taskId string, inputId int64)
// (GET /api/tasks/{taskId}/reviews)
ListReviews(w http.ResponseWriter, r *http.Request, taskId string)
@@ -3008,6 +3011,41 @@ func (siw *ServerInterfaceWrapper) ListPreviews(w http.ResponseWriter, r *http.R
handler.ServeHTTP(w, r)
}
// WithdrawMessage operation middleware
func (siw *ServerInterfaceWrapper) WithdrawMessage(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
// ------------- Path parameter "inputId" -------------
var inputId int64
err = runtime.BindStyledParameterWithOptions("simple", "inputId", r.PathValue("inputId"), &inputId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "inputId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.WithdrawMessage(w, r, taskId, inputId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListReviews operation middleware
func (siw *ServerInterfaceWrapper) ListReviews(w http.ResponseWriter, r *http.Request) {
@@ -3562,6 +3600,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/tasks/{taskId}/queue/{inputId}", wrapper.WithdrawMessage)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
@@ -3798,25 +3837,27 @@ var swaggerSpec = []string{
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+9
View File
@@ -779,6 +779,15 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) WithdrawMessage(w http.ResponseWriter, r *http.Request, taskID string, inputID int64) {
err := s.agents.Withdraw(r.Context(), taskID, inputID, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionID string) {
sess, err := s.agents.Compact(r.Context(), sessionID)
if err != nil {
+22 -1
View File
@@ -26,15 +26,36 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi
payload["images"] = in.Images
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
in.TaskID, in.Actor, in.Text, string(images), now())
if err != nil {
return err
}
payload["id"], err = res.LastInsertId()
if err != nil {
return err
}
return emit(sessionEvent("user.queued", projectID, in.TaskID, "", sessionID, in.Actor, payload))
})
}
func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, sessionID, actor string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ?`, id, taskID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return emit(sessionEvent("user.unqueued", projectID, taskID, "", sessionID, actor, map[string]any{"id": id}))
})
}
func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, actor, text, images, created_at FROM task_inputs WHERE task_id = ? ORDER BY id`, taskID)
if err != nil {
+41 -1
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) {
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
}