Files

9.5 KiB

Tech Spec: Attachments on Queued Prompts

See specs/APP-4617/PRODUCT.md for user-visible behavior.

Context

Queued prompts (V2) store per-conversation rows in QueuedQueryModel (app/src/ai/blocklist/queued_query.rs). Before this change a QueuedQuery held only text + origin; staged attachments lived solely in the live input staging on BlocklistAIContextModel.pending_attachments. Two things coupled attachments to the live input rather than the queued row:

  • At enqueue time the input attachments were left in place or cleared, so a row never carried them.
  • The send path always sourced pending attachments from the context model: input_for_query built image context from vec![] and input_context_for_request (app/src/ai/blocklist/controller/input_context.rs) appended context_model.pending_files() as FilePathReferences. A fired queued row therefore picked up whatever was currently staged, and a direct send after queuing re-sent the previous attachments.

Relevant code (current branch):

  • app/src/ai/blocklist/queued_query.rsQueuedQuery (text/origin), AutofireAction, pop_for_autofire (removed the head row and returned its action)
  • app/src/ai/blocklist/context_model.rspending_attachments staging, clear_pending_attachments
  • app/src/ai/blocklist/controller.rs:3132input_for_query; send path in send_query (~758)
  • app/src/ai/blocklist/controller/input_context.rs (230-) — pending-file → FilePathReference conversion
  • app/src/ai/blocklist/controller/slash_command.rs:68SlashCommandRequest::send_request
  • app/src/terminal/input.rs:13179 submit_queued_prompt, :13277 submit_queued_prompt_for_active_pane, :5242 execute_skill_command, viewer send (~13767)
  • app/src/terminal/input/slash_commands/mod.rs (1069-)/queue handling
  • app/src/terminal/view.rs:5199 enqueue, :5227 drain_queued_prompts
  • app/src/terminal/view/pending_user_query.rs — legacy pending-query submission

Proposed changes

1. Rows own their attachments (queued_query.rs)

QueuedQuery gains attachments: Vec<PendingAttachment>, a new_with_attachments(text, origin, attachments) constructor (:59; new delegates to it), and an attachments() accessor (:100). attachments_for(conversation_id, query_id) (:374) returns a row's attachments by id without removing it; it returns &[] when the row is absent.

2. Capture-and-clear at every enqueue site

Each enqueue site drains the live input via a new BlocklistAIContextModel::take_pending_attachments(ctx) (context_model.rs:987) and stores the drained set on the row with new_with_attachments. take_pending_attachments emits the same UpdatedPendingContext event as clear_pending_attachments so the input's attachment chips disappear. Sites: terminal/view.rs:5199, the two auto-queue-toggle paths in terminal/input.rs (~13433, ~13490), and the /queue in-progress branch in slash_commands/mod.rs.

3. Auto-fire becomes peek + remove (queued_query.rs, view.rs)

pop_for_autofire (which mutated the queue) is replaced by:

  • peek_autofire(conversation_id) -> Option<AutofireAction> (:326) — read-only; returns the head row's action while leaving the row in the queue so the send path can resolve its attachments by id.
  • remove_fired_row(conversation_id, query_id, ctx) (:349) — removes the row after dispatch/restore and clears edit state if it pointed at that row.

Both AutofireAction variants now carry query_id; PopFromEditMode additionally carries attachments. drain_queued_prompts (view.rs:5227) peeks, dispatches or restores, then calls remove_fired_row. This peek-then-remove ordering is required: the row must stay addressable during the synchronous send so attachments can be read by id.

4. Send path resolves attachments by source (controller.rs, input_context.rs)

InputQuery gains queued_query_id: Option<QueuedQueryId>. In send_query, the attachment set for a UserSubmittedQueryFromInput is resolved once:

  • Some(id)QueuedQueryModel::attachments_for(conversation_id, id) (the fired row)
  • Nonecontext_model.pending_attachments() (live staging)

input_for_query (:3132) now takes prompt_attachments: Vec<PendingAttachment>, splits them into image context (sent inline) and file references, and no longer relies on input_context_for_request for pending files. The pending-file → FilePathReference conversion (with duplicate-basename suffixing) moves out of input_context.rs into a shared add_pending_file_attachments (controller.rs:3190); input_context.rs no longer sources pending files.

5. Conversation routing for fired rows (controller.rs, slash_command.rs, input.rs)

A fired row routes into the conversation it was queued on rather than re-deriving from the current UI selection. send_queued_slash_command_request and send_queued_user_query_in_conversation thread queued_query_id plus a conversation_id override; SlashCommandRequest::send_request (slash_command.rs:68) replaces its is_queued_prompt: bool with queued_query_id: Option<QueuedQueryId> + conversation_id_override: Option<AIConversationId> and derives is_queued_prompt from the id. Queued skill invocations resolve prompt_attachments from the row (or vec![] if the conversation is unknown) and feed them through add_pending_file_attachments into InvokeSkillUserQuery.

6. Preserve next-prompt staging (controller.rs, slash_command.rs)

The context reset after a send is skipped when is_queued_prompt is true (the fired row's attachments came from the row, so the live pending_attachments belong to the user's next prompt). The same guard applies to queued skill invocations so they don't clear a new draft's staged attachments; direct skills still reset.

7. Split immediate vs. queued submission (input.rs, pending_user_query.rs, slash_commands/mod.rs)

  • submit_queued_prompt (input.rs:13179) now takes conversation_id + query_id and submits the fired row into that conversation.
  • New submit_user_query_now (input.rs:13248) is the immediate (non-queued) path that resets live staging; used by the /queue not-in-progress fallback and the legacy pending-user-query paths in pending_user_query.rs.
  • submit_queued_prompt_for_active_pane (input.rs:13277) takes conversation_id + query_id and branches: cloud follow-up (drop attachments, log a warning), shared-session viewer (upload via the shared path below), local agent (submit_queued_prompt).
  • execute_skill_command (input.rs:5242) replaces is_queued_prompt: bool with queued_query_id + conversation_id_override.

8. Shared viewer upload path (input.rs)

A new upload_and_send_viewer_prompt is extracted from the immediate viewer-submit path and shared with the queued viewer drain, so both go through the identical upload-then-send (Event::SendAgentPrompt) flow. The queued viewer drain reads the firing row's images/files from attachments_for and passes them in.

9. Re-stage on restore (view.rs)

drain_queued_prompts' PopFromEditMode branch and the manual edit/restore path call context_model.append_pending_attachments(row attachments) after restoring the row's text, so the chips reappear and a manual re-submit keeps them.

Testing and validation

Unit tests added alongside the changed modules; each maps to PRODUCT.md invariants:

  • context_model_tests.rstake_pending_attachments drains and returns all staged attachments and clears the input (inv. 1); enqueue moves staged attachments onto the row and leaves the input empty (inv. 1, 7).
  • queued_query_tests.rspeek_autofire leaves the row until remove_fired_row drops it (inv. 3); PopFromEditMode carries committed text + attachments and peek is non-mutating (inv. 6).
  • controller_tests.rsinput_for_query builds image/file context purely from the provided attachment set, ignoring live staging, including duplicate-basename suffixing (inv. 3, 5).
  • queued_prompts_tests.rs — multi-cycle queue keeps each row's attachments independent and draining one leaves the other intact (inv. 2); shared drain_one helper mirrors peek + remove_fired_row.

Manual verification:

  • Stage an image + file, queue while the agent is busy → chips clear from input; on fire the prompt arrives with the image inline and the file referenced (inv. 1, 3).
  • Queue two prompts with different attachments → each fires with only its own (inv. 2).
  • Stage attachments after queuing → they ride the next manual prompt, not the fired row (inv. 5).
  • Edit-mode auto-fire pop and manual restore → attachments re-appear in the input (inv. 6).
  • Cloud follow-up fire → text sent, attachments dropped, warning logged (inv. 10).
  • Shared-session viewer fire → attachments uploaded and sent (inv. 11).

Risks and mitigations

  • Double-fire / leaked rows: peek no longer removes the row, so remove_fired_row must run after every dispatch and every restore. drain_queued_prompts removes in both Submit and PopFromEditMode arms immediately after the synchronous dispatch.
  • Attachment lifetime: attachments are cloned when resolved by id during send and dropped when the row is removed; there is no shared ownership between the row and the live input, which is what keeps inv. 2 and inv. 5 independent.

Parallelization

Not beneficial. The change is a single tightly-coupled thread through the queued-prompt enqueue, drain, and send paths (queued_query.rsview.rs/input.rscontroller.rs/slash_command.rs); the signature changes ripple across these files and must land together. Best done sequentially in one PR.