Make Duo Chat draft messages persist across reloads

What does this MR do and why?

Persists unsent Duo Chat message drafts to localStorage, scoped per user and per thread, so they survive page reloads and full-page navigation. This is particularly needed because navigating within GitLab inconsistently reloads the page (and alongside it Duo Chat), meaning users cannot reliably predict when clicking a link will lose their draft, and because we present this as if it will operate like a SPA it's unexpected to lose anything on navigation anyway.

Drafts are tied to chats, or a single "new" chat draft.

Implementation details

Storage key scheme

Keys follow one of two templates:

duo_chat_draft_<userId>_<threadId>            # agentic chat
duo_chat_draft_<userId>_classic_<threadId>    # classic chat

Both <userId> and <threadId> are reduced to their trailing segment, so a global ID like gid://gitlab/User/1 contributes just 1. Examples:

  • New agentic chat: duo_chat_draft_1_new
  • Agentic workflow 99: duo_chat_draft_1_99
  • New classic chat: duo_chat_draft_1_classic_new
  • Classic thread 123: duo_chat_draft_1_classic_123

When there is no userId, no key is built at all, so the draft is dropped rather than stored under a key that a different user on the same browser could read.

Changes

  • New file ee/app/assets/javascripts/ai/utils/chat_draft_storage.js holds all of it: buildDraftStorageKey() builds the key, and createDraftStorage() returns a small store with read, save, clear, and destroy. Writes are debounced 300ms. The waiting draft is held in the closure rather than in the debounced call's arguments, because lodash keeps only the last call's arguments and a thread switch inside the window would otherwise discard the earlier draft. A pagehide listener flushes a waiting write before the page goes away.
  • PromptComposer (agentic chat) takes userId and threadId props, restores the draft in created(), persists on a watcher over the prompt text, swaps drafts on a watcher over the computed storage key, clears the draft on both send and cancel, and tears the store down in beforeDestroy().
  • DuoAgenticChatView forwards userId and threadId through to PromptComposer.
  • DuoAgenticChatStateManager now requires userId, matching classic chat, and passes it down along with the parsed workflow ID as threadId.
  • DuoChatView (classic chat) takes userId, and does the same restore, persist, swap, clear, and teardown. It scopes by this.$route.params.id.
  • DuoChatStateManager forwards its existing userId prop to DuoChatView.

Implementation notes

  • Uses getStorageValue, saveStorageValue, and removeStorageValue from ~/lib/utils/local_storage.
  • Sending or cancelling deletes the storage key outright, it does not blank it.
  • pagehide rather than beforeunload, because it also fires when the page enters the back/forward cache.
  • Switching threads is handled by a watcher on the computed storage key, so it works whether or not the router remounts the component.

References

#619581

Screenshots or screen recordings

Before After
unpersisted.gif persist.gif

How to set up and validate locally

  1. Open Duo Chat (agentic or classic mode)
  2. Type a message in the prompt textarea but do not send it
  3. Reload the page
  4. The draft message (draft A) should be restored in the textarea
  5. Open a history chat or start a chat with a sent message, then draft a message (draft B)
  6. Switch to a new chat, draft A should appear
  7. Switch back to the history chat, draft B should appear

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 Nick Leonard

Merge request reports

Loading
Loading