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

  • PromptComposer is now the sole owner of the draft and its only writer. PromptTextarea is a presentational input: value in, input / submit / focus-change events out. It no longer holds the draft, decides send-versus-queue, splices slash commands, or restores focus.
  • UserPromptBuilder is 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.
  • UserPrompt is a plain frozen object, not a class instance, because the prompt queue persists queued prompts to sessionStorage and a prompt needs to survive a JSON round trip.
  • slashCommands is re-derived from text on every edit rather than recorded when a command is picked from the menu: it's the catalogue commands whose token appears in text, 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 slashCommands mirrors slash_commands_menu.vue exactly, same anchoring (/compacting doesn't match /compact, https://example.com/new doesn't match) and same case-insensitivity (/Compact does match), so the UI and the payload agree on whether a command was used.
  • PromptComposer absorbed slash_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 reconcile slashCommands. Its in-flight stale guard carried over unchanged.
  • buildStartRequest now takes a UserPrompt instead of a goal string. onSendChatPrompt accepts either a prompt or a plain question and 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 UserPrompt instead of a string, and the sessionStorage key 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.
  • attachments ships 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

  1. Open the Duo Chat panel in the GDK.
  2. Type / and pick /compact from the slash-commands menu. The command should splice into the text with the cursor positioned after it, exactly as before.
  3. Type /compact by hand without opening the menu. The resulting payload should match step 2.
  4. Submit a prompt while a turn is running so it gets queued, then reload the page and confirm the queue still behaves correctly.
  5. Cancel a running turn.
  6. Run the automated suites: yarn jest ee/spec/frontend/ai/duo_agentic_chat and yarn jest:msw-integration ai_duo_panel. Repeat both with VUE_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.

Edited by Enrique Alcántara

Merge request reports

Loading
Loading