Commit e1be6b4
authored
🤖 fix: persist chat draft images (#1187)
Persist pasted image attachments in ChatInput drafts so they survive
workspace switches and app restarts.
Key points:
- Added `inputImages:<scopeId>` localStorage key and included it in
workspace storage copy/delete logic.
- ChatInput now restores persisted image attachments on mount and
persists updates on paste/drop/remove/send.
- Creation flow clears pending image drafts after successful workspace
creation.
Tests:
- `make static-check`
---
<details>
<summary>📋 Implementation Plan</summary>
# Persist pasted image attachments in ChatInput drafts
## Why images are currently lost
- Draft **text** is persisted via `usePersistedState(getInputKey(...))`
in `src/browser/components/ChatInput/index.tsx`.
- Draft **images** are held only in component memory
(`useState<ImageAttachment[]>([])`), so they reset whenever:
- the workspace view remounts (workspace switch), or
- the renderer reloads (app restart).
## Goal
Make pasted/drag-dropped image attachments persist the same way draft
text does:
- per-workspace (and per “creation” project scope)
- survives workspace switches and app restarts
- doesn’t regress the ability to attach large images (quota issues
should not break the UI)
## Recommended approach (minimal + safe)
Persist image attachments **best-effort** to localStorage under a new
key, while keeping the authoritative in-memory state in React.
Rationale:
- Matches existing draft persistence strategy (localStorage) without
introducing new backend APIs.
- Avoids a regression where attaching a large image fails to show in the
UI if localStorage quota is exceeded (which would happen if we used
`usePersistedState` as the source of truth).
## Implementation plan
### 1) Add a workspace-scoped storage key for draft images
**Files:**
- `src/common/constants/storage.ts`
Actions:
- Add a new helper:
- `getInputImagesKey(scopeId: string): string` →
`inputImages:${scopeId}`
- (Use the same `scopeId` inputs as `getInputKey`: workspaceId for
workspace variant; `getPendingScopeId(projectPath)` for creation
variant.)
- Add `getInputImagesKey` to `PERSISTENT_WORKSPACE_KEY_FUNCTIONS` so
images are:
- copied on workspace fork (`copyWorkspaceStorage`)
- deleted on workspace removal (`deleteWorkspaceStorage`)
- migrated on workspace ID migration (`migrateWorkspaceStorage`)
### 2) Load persisted images when ChatInput mounts
**Files:**
- `src/browser/components/ChatInput/index.tsx`
Actions:
- Extend `storageKeys` to include `imagesKey`:
- workspace: `getInputImagesKey(props.workspaceId)`
- creation: `getInputImagesKey(getPendingScopeId(props.projectPath))`
- Initialize `imageAttachments` from localStorage:
- `useState(() =>
readPersistedState<ImageAttachment[]>(storageKeys.imagesKey, []))`
- Add a small runtime validator (defensive programming):
- if the persisted value isn’t an array of `{id,url,mediaType}` strings,
log and fall back to `[]` (and consider clearing the bad key).
### 3) Persist image changes back to localStorage (best-effort)
**Files:**
- `src/browser/components/ChatInput/index.tsx`
Actions:
- Keep `imageAttachments` as a normal `useState`.
- Wrap updates so they also persist:
- When setting images, call `updatePersistedState(storageKeys.imagesKey,
nextImages.length ? nextImages : undefined)`.
- Ensure all mutation paths persist:
- paste (`handlePaste`)
- drop (`handleDrop`)
- remove (`handleRemoveImage`)
- clear on send success
- restoreImages API
- edit-mode transitions via `setDraft`
Optional but recommended (quota UX):
- Add a simple size guard before persisting:
- compute `JSON.stringify(nextImages).length`
- if above a conservative threshold (e.g. 6–8MB chars), skip persistence
and show a toast like:
- “Image draft too large to save; it will be lost on restart.”
- Still keep `imageAttachments` in memory so the user can send the
message.
### 4) Clear pending image drafts on successful workspace creation
**Files:**
- `src/browser/components/ChatInput/useCreationWorkspace.ts`
Actions:
- When creation succeeds (where we already clear `pendingInputKey`),
also clear:
-
`updatePersistedState(getInputImagesKey(getPendingScopeId(projectPath)),
undefined)`
### 5) Tests
**Goal:** prevent regressions in the future.
Recommended tests:
- `src/common/constants/storage.test.ts`
- verifies `getInputImagesKey()` format
- verifies `copyWorkspaceStorage()` copies the image key (using a fake
localStorage)
- verifies `deleteWorkspaceStorage()` removes the image key
- `src/browser/components/ChatInput` test (new):
- pre-populate localStorage with `inputImages:<workspaceId>` containing
one `ImageAttachment`
- render `ChatInput` for that workspace and assert the thumbnail `<img
src=...>` is present
- update state (e.g., remove image) and assert localStorage key is
removed/updated
### 6) Manual QA checklist
- Workspace draft persistence:
1. Open workspace A → paste an image + type text.
2. Switch to workspace B → switch back to A.
3. Confirm text + image thumbnail are still present.
- Restart persistence:
1. With an image attached in workspace A draft, fully restart Mux.
2. Confirm the draft image is restored.
- Creation flow:
1. Go to creation mode → paste an image.
2. Create workspace.
3. Confirm the pending draft is cleared (image doesn’t “stick around” in
future creation attempts).
- Fork/removal:
- Fork a workspace with a draft image: image draft should copy.
- Delete a workspace: image draft key should be removed.
---
<details>
<summary>Alternative: store draft images on disk (more robust, more
work)</summary>
If localStorage size limits or sync write jank are a concern, store
draft images under `~/.mux/sessions/<workspaceId>/draft-images.json`
using `SessionFileManager` (server-side) and expose
`workspace.getDraft()` / `workspace.setDraft()` ORPC endpoints.
Pros:
- avoids localStorage quota
- avoids large synchronous localStorage writes in the renderer
Cons:
- new backend API surface
- more wiring + tests (IPC/orpc + session file persistence)
</details>
</details>
---
_Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_
---------
Signed-off-by: Thomas Kosiewski <tk@coder.com>1 parent 85522bd commit e1be6b4
File tree
9 files changed
+355
-46
lines changed- src
- browser/components
- ChatInput
- common/constants
9 files changed
+355
-46
lines changedLines changed: 34 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
78 | 79 | | |
79 | 80 | | |
80 | 81 | | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
81 | 86 | | |
82 | 87 | | |
83 | 88 | | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
84 | 92 | | |
85 | 93 | | |
86 | 94 | | |
| |||
132 | 140 | | |
133 | 141 | | |
134 | 142 | | |
| 143 | + | |
135 | 144 | | |
136 | | - | |
| 145 | + | |
| 146 | + | |
137 | 147 | | |
138 | 148 | | |
139 | 149 | | |
140 | 150 | | |
141 | 151 | | |
| 152 | + | |
142 | 153 | | |
143 | 154 | | |
144 | 155 | | |
| |||
150 | 161 | | |
151 | 162 | | |
152 | 163 | | |
153 | | - | |
154 | | - | |
155 | | - | |
156 | | - | |
157 | 164 | | |
158 | 165 | | |
159 | 166 | | |
| |||
163 | 170 | | |
164 | 171 | | |
165 | 172 | | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
166 | 227 | | |
167 | 228 | | |
168 | 229 | | |
| |||
181 | 242 | | |
182 | 243 | | |
183 | 244 | | |
184 | | - | |
| 245 | + | |
185 | 246 | | |
186 | 247 | | |
187 | 248 | | |
| |||
387 | 448 | | |
388 | 449 | | |
389 | 450 | | |
390 | | - | |
391 | | - | |
392 | | - | |
393 | | - | |
394 | | - | |
395 | | - | |
396 | | - | |
397 | | - | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
398 | 462 | | |
399 | 463 | | |
400 | 464 | | |
| |||
678 | 742 | | |
679 | 743 | | |
680 | 744 | | |
681 | | - | |
682 | | - | |
683 | | - | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
684 | 749 | | |
685 | | - | |
686 | | - | |
| 750 | + | |
| 751 | + | |
687 | 752 | | |
688 | | - | |
| 753 | + | |
689 | 754 | | |
690 | | - | |
691 | | - | |
692 | | - | |
693 | | - | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
694 | 761 | | |
695 | 762 | | |
696 | | - | |
697 | | - | |
698 | | - | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
699 | 769 | | |
700 | 770 | | |
701 | 771 | | |
| |||
707 | 777 | | |
708 | 778 | | |
709 | 779 | | |
710 | | - | |
711 | | - | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
712 | 783 | | |
713 | | - | |
714 | | - | |
| 784 | + | |
| 785 | + | |
715 | 786 | | |
716 | | - | |
717 | | - | |
718 | | - | |
719 | | - | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
720 | 793 | | |
721 | 794 | | |
722 | 795 | | |
| |||
Lines changed: 5 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
461 | 462 | | |
462 | 463 | | |
463 | 464 | | |
464 | | - | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
465 | 468 | | |
466 | 469 | | |
467 | 470 | | |
| 471 | + | |
468 | 472 | | |
469 | 473 | | |
470 | 474 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
198 | 199 | | |
199 | 200 | | |
200 | 201 | | |
201 | | - | |
202 | | - | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
203 | 205 | | |
204 | 206 | | |
205 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
0 commit comments