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 chatBoth <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.jsholds all of it:buildDraftStorageKey()builds the key, andcreateDraftStorage()returns a small store withread,save,clear, anddestroy. 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. Apagehidelistener flushes a waiting write before the page goes away. PromptComposer(agentic chat) takesuserIdandthreadIdprops, restores the draft increated(), 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 inbeforeDestroy().DuoAgenticChatViewforwardsuserIdandthreadIdthrough toPromptComposer.DuoAgenticChatStateManagernow requiresuserId, matching classic chat, and passes it down along with the parsed workflow ID asthreadId.DuoChatView(classic chat) takesuserId, and does the same restore, persist, swap, clear, and teardown. It scopes bythis.$route.params.id.DuoChatStateManagerforwards its existinguserIdprop toDuoChatView.
Implementation notes
- Uses
getStorageValue,saveStorageValue, andremoveStorageValuefrom~/lib/utils/local_storage. - Sending or cancelling deletes the storage key outright, it does not blank it.
pagehiderather thanbeforeunload, 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
Screenshots or screen recordings
| Before | After |
|---|---|
![]() |
![]() |
How to set up and validate locally
- Open Duo Chat (agentic or classic mode)
- Type a message in the prompt textarea but do not send it
- Reload the page
- The draft message (draft A) should be restored in the textarea
- Open a history chat or start a chat with a sent message, then draft a message (draft B)
- Switch to a new chat, draft A should appear
- 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.

