82 lines
9.5 KiB
Markdown
82 lines
9.5 KiB
Markdown
# 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 `FilePathReference`s. 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.rs` — `QueuedQuery` (text/origin), `AutofireAction`, `pop_for_autofire` (removed the head row and returned its action)
|
|
- `app/src/ai/blocklist/context_model.rs` — `pending_attachments` staging, `clear_pending_attachments`
|
|
- `app/src/ai/blocklist/controller.rs:3132` — `input_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:68` — `SlashCommandRequest::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)
|
|
- `None` → `context_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.rs` — `take_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.rs` — `peek_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.rs` — `input_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.rs` → `view.rs`/`input.rs` → `controller.rs`/`slash_command.rs`); the signature changes ripple across these files and must land together. Best done sequentially in one PR.
|