diff --git a/TODO.md b/TODO.md index 5e6d233..fe41f7e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/api/openapi.yaml b/api/openapi.yaml index a93cee2..a27a280 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 } } diff --git a/docs/operations.md b/docs/operations.md index d52e441..e21d586 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -146,7 +146,7 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht ## Agent sessions -`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text` and `images`, the sender as actor), and the request returns the running session with 202. A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed " sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed`. A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`. +`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed " sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed`. A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`. Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB; `ImageTypes` and `MaxBlobBytes` in `internal/store/blob.go`) and returns its id; `GET /api/blobs/{id}` serves it. A message or session prompt may carry `images`, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it before the text: to Claude as a base64 `image` content block (`claude.Session.Send`), to Codex as an `image` input with a `data:` URL (`codex.Session.Send`). The `user.message` event records the ids as `images`. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image. @@ -189,9 +189,9 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. -Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner, here and everywhere else it shows (tab labels, snapshot rows' review marks, task states), is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. +Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner, here and everywhere else it shows (tab labels, snapshot rows' review marks, task states), is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. -**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. +**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows `models[0]` from `mac/Sources/Byre/Composer.swift`, the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on `models[0]` with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab, scrolled to its latest summary when that is unread (see below and "Lead agent"). diff --git a/internal/agents/queue.go b/internal/agents/queue.go index 5e935cd..c5c7867 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -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) diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 3df44e0..b69ce89 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -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, diff --git a/internal/server/server.go b/internal/server/server.go index 9e4f4cd..54c1e6b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/internal/store/taskinputs.go b/internal/store/taskinputs.go index a7e1db4..09e41c2 100644 --- a/internal/store/taskinputs.go +++ b/internal/store/taskinputs.go @@ -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 { diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 60ccf1b..8c600b9 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -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 diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index e148de4..37d189f 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -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) } diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift index 3b53348..036f0ad 100644 --- a/mac/Sources/Byre/Drafts.swift +++ b/mac/Sources/Byre/Drafts.swift @@ -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] ?? [] } diff --git a/mac/Sources/Byre/Images.swift b/mac/Sources/Byre/Images.swift index 12f6b65..220d59b 100644 --- a/mac/Sources/Byre/Images.swift +++ b/mac/Sources/Byre/Images.swift @@ -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 diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 9c2624f..809e216 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -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 }