Move Duo Chat prompt composition into a PromptComposer
What does this MR do and why?
Prompts in Duo Agentic Chat are becoming more complex. They carry more than just plain text today: The prompt goal and Slash commands. Slash commands are complex objects that reference objects such as flows. In the future, the prompt will also include file uploads. The goal of this MR is evolving the prompt textarea to become a prompt composer. A prompt composer is a Vue component that orchestrates different ways of building a prompt: By using a slash commands menu, by typing text in a textarea, or by attaching files using a files uploader (in the future).
What business value does this refactor enable today?
This refactor supports the deliverables Add manual / button trigger to test custom flows (#605161 - closed) and Add `/goal` for long-running developer work in ... (gitlab-org#23107). These deliverables introduce slash commands whose presentation differs from the data cthey contribute to the prompt. For example, if the user executes a "Fix pipeline" slash command, the prompt textarea's content might look like this:
/flow "Fix Pipeline" submit the fix in a separate MR
But what we want to send to the workflow start request builder is:
// UserPrompt
{
"text": "/flow "Fix Pipeline" submit the fix ina separate MR",
"slashCommands": [
{
"label": "Fix Pipeline",
"value": "/flow \"Fix Pipeline\"",
"additionalMetadata": {
"flow_id": 12323
// Other important metadata
}
}
]
}Sending this object allows us to decouple presentation from data in Duo Agentic Chat prompt composer UI.
The architecture
A UserPrompt is a serializable data structure that contains all info about the prompt, for example:
interface UserPrompt {
text: string;
slashCommands: Array<SlashCommand>;
fileUploads: Array<FileUpload>; // in the future
}The UserPromptBuilder class supports the PromptComposer component by handling all the business logic of building a UserPrompt based on the user's interactions. This class has a fluid API:
UserPromptBuilder.empty().withText(text).withSlashCommand(command, position);DuoAgenticChatStateManager receives a UserPrompt that contains everything it needs to build a WorkflowStartRequest object. We will also create a more structured approach to building this object from the user prompt in the future.
flowchart TD
TA["PromptTextarea"] -->|text| B
SC["SlashCommandsMenu"] -->|command| B
FU["File uploads<br/>(future)"] -.->|attachment| B
B["UserPromptBuilder<br/>owned by PromptComposer"]
B -->|UserPrompt| SM["Chat state manager"]
SM -->|startRequest| DWS["Duo Workflow Service"]Implementation details
PromptComposeris now the sole owner of the draft and its only writer.PromptTextareais a presentational input: value in,input/submit/focus-changeevents out. It no longer holds the draft, decides send-versus-queue, splices slash commands, or restores focus.UserPromptBuilderis persistent/immutable — every mutator returns a new builder instead of mutating in place. Reassignment (this.draft = this.draft.withText(x)) keeps Vue reactivity reliable, and lets the builder be unit-tested with no Vue involved.UserPromptis a plain frozen object, not a class instance, because the prompt queue persists queued prompts tosessionStorageand a prompt needs to survive a JSON round trip.slashCommandsis re-derived fromtexton every edit rather than recorded when a command is picked from the menu: it's the catalogue commands whose token appears intext, preserving any extras already recorded for that token. Recording selections directly would drift — a backspaced command would leave a stale record, and a hand-typed command would record nothing.- Token matching for
slashCommandsmirrorsslash_commands_menu.vueexactly, same anchoring (/compactingdoesn't match/compact,https://example.com/newdoesn't match) and same case-insensitivity (/Compactdoes match), so the UI and the payload agree on whether a command was used. PromptComposerabsorbedslash_commands_menu_with_plugins.vue, which is deleted. That wrapper only resolved the slash-command catalogue and passed it down; the composer needs that list itself to reconcileslashCommands. Its in-flight stale guard carried over unchanged.buildStartRequestnow takes aUserPromptinstead of agoalstring.onSendChatPromptaccepts either apromptor a plainquestionand normalises at the top, since four callers still hand over plain text: clarification answers, chat commands opened from elsewhere in the UI, message retries, and the trial empty state's prompt buttons.- Queue entries now hold a
UserPromptinstead of a string, and thesessionStoragekey is versioned (agenticChat.queuedPrompts.v2) so a bundle of either vintage can't read a queue written in the other's shape, which would otherwise fire a turn with an empty prompt. attachmentsships as an empty seam with no upload UI and no producer in this MR, so the File Upload epic can add a producer without reshaping the payload or touching call sites again.- Almost entirely a move or an extraction, with two exceptions raised in review: pressing Enter now respects the same two conditions that disable the submit button, so a whitespace-only prompt no longer sends an empty goal and an over-length one is no longer sent for the backend to reject.
How to review this MR?
The commits are ordered so the first can be reviewed standalone before the wiring in the second.
| Commit | What it contains | What to look at |
|---|---|---|
Add UserPrompt value type to Duo Chat services |
New UserPrompt factory and UserPromptBuilder, plus unit specs, under ee/app/assets/javascripts/ai/duo_agentic_chat/services/user_prompt/. Plain JS, nothing wired in yet. |
commandsIn in user_prompt_builder.js — it's the one piece of real logic, keeping slashCommands in sync with text. Rest is a factory and thin wrappers, judge on its own. |
Move Duo Chat prompt composition into PromptComposer |
New prompt_composer.vue, prompt_textarea.vue rewritten as presentational, removal of slash_commands_menu_with_plugins.vue, and call-site updates across the chat view, state managers, prompt_queue.vue, and websocket/workflow_utils.js. |
Whether behaviour was preserved — diff this against the deleted prompt_textarea.vue rather than trusting line counts. Most of the spec churn is tests moving between prompt_composer_spec.js and the new prompt_textarea_spec.js, not rewrites. The unchanged ai_duo_panel MSW suite is the signal that the payload change is transparent end to end. |
References
No issue is linked yet.
Target architecture: https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/ai_chat/
Screenshots or screen recordings
Not applicable — this is a structural refactor with no visible UI change.
How to set up and validate locally
- Open the Duo Chat panel in the GDK.
- Type
/and pick/compactfrom the slash-commands menu. The command should splice into the text with the cursor positioned after it, exactly as before. - Type
/compactby hand without opening the menu. The resulting payload should match step 2. - Submit a prompt while a turn is running so it gets queued, then reload the page and confirm the queue still behaves correctly.
- Cancel a running turn.
- Run the automated suites:
yarn jest ee/spec/frontend/ai/duo_agentic_chatandyarn jest:msw-integration ai_duo_panel. Repeat both withVUE_VERSION=3. The MSW integration suite passes unchanged, which is the main evidence the payload change is transparent end to end — one of those specs asserts the value that reaches the websocket start frame.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.