Skip to content

Commit e1be6b4

Browse files
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

9 files changed

+355
-46
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
estimatePersistedImageAttachmentsChars,
5+
parsePersistedImageAttachments,
6+
} from "./draftImagesStorage";
7+
8+
describe("draftImagesStorage", () => {
9+
test("parsePersistedImageAttachments returns [] for non-arrays", () => {
10+
expect(parsePersistedImageAttachments(null)).toEqual([]);
11+
expect(parsePersistedImageAttachments({})).toEqual([]);
12+
expect(parsePersistedImageAttachments("nope")).toEqual([]);
13+
});
14+
15+
test("parsePersistedImageAttachments returns [] for invalid array items", () => {
16+
expect(parsePersistedImageAttachments([{}])).toEqual([]);
17+
expect(
18+
parsePersistedImageAttachments([{ id: "img", url: 123, mediaType: "image/png" }])
19+
).toEqual([]);
20+
});
21+
22+
test("parsePersistedImageAttachments returns attachments for valid items", () => {
23+
expect(
24+
parsePersistedImageAttachments([
25+
{ id: "img-1", url: "data:image/png;base64,AAA", mediaType: "image/png" },
26+
])
27+
).toEqual([{ id: "img-1", url: "data:image/png;base64,AAA", mediaType: "image/png" }]);
28+
});
29+
30+
test("estimatePersistedImageAttachmentsChars matches JSON length", () => {
31+
const images = [{ id: "img-1", url: "data:image/png;base64,AAA", mediaType: "image/png" }];
32+
expect(estimatePersistedImageAttachmentsChars(images)).toBe(JSON.stringify(images).length);
33+
});
34+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ImageAttachment } from "@/browser/components/ImageAttachments";
2+
import { readPersistedState } from "@/browser/hooks/usePersistedState";
3+
4+
function isRecord(value: unknown): value is Record<string, unknown> {
5+
return typeof value === "object" && value !== null;
6+
}
7+
8+
function isImageAttachment(value: unknown): value is ImageAttachment {
9+
if (!isRecord(value)) return false;
10+
return (
11+
typeof value.id === "string" &&
12+
typeof value.url === "string" &&
13+
typeof value.mediaType === "string"
14+
);
15+
}
16+
17+
export function parsePersistedImageAttachments(raw: unknown): ImageAttachment[] {
18+
if (!Array.isArray(raw)) {
19+
return [];
20+
}
21+
22+
const attachments: ImageAttachment[] = [];
23+
for (const item of raw) {
24+
if (!isImageAttachment(item)) {
25+
return [];
26+
}
27+
attachments.push({ id: item.id, url: item.url, mediaType: item.mediaType });
28+
}
29+
30+
return attachments;
31+
}
32+
33+
export function readPersistedImageAttachments(imagesKey: string): ImageAttachment[] {
34+
return parsePersistedImageAttachments(readPersistedState<unknown>(imagesKey, []));
35+
}
36+
37+
export function estimatePersistedImageAttachmentsChars(images: ImageAttachment[]): number {
38+
return JSON.stringify(images).length;
39+
}

src/browser/components/ChatInput/index.tsx

Lines changed: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
2424
import {
2525
getModelKey,
2626
getInputKey,
27+
getInputImagesKey,
2728
VIM_ENABLED_KEY,
2829
getProjectScopeId,
2930
getPendingScopeId,
@@ -78,9 +79,16 @@ import { useCreationWorkspace } from "./useCreationWorkspace";
7879
import { useTutorial } from "@/browser/contexts/TutorialContext";
7980
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
8081
import { VoiceInputButton } from "./VoiceInputButton";
82+
import {
83+
estimatePersistedImageAttachmentsChars,
84+
readPersistedImageAttachments,
85+
} from "./draftImagesStorage";
8186
import { RecordingOverlay } from "./RecordingOverlay";
8287
import { ReviewBlockFromData } from "../shared/ReviewBlock";
8388

89+
// localStorage quotas are environment-dependent and relatively small.
90+
// Be conservative here so we can warn the user before writes start failing.
91+
const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000;
8492
type TokenCountReader = () => number;
8593

8694
function createTokenCountResource(promise: Promise<number>): TokenCountReader {
@@ -132,13 +140,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
132140
// Storage keys differ by variant
133141
const storageKeys = (() => {
134142
if (variant === "creation") {
143+
const pendingScopeId = getPendingScopeId(props.projectPath);
135144
return {
136-
inputKey: getInputKey(getPendingScopeId(props.projectPath)),
145+
inputKey: getInputKey(pendingScopeId),
146+
imagesKey: getInputImagesKey(pendingScopeId),
137147
modelKey: getModelKey(getProjectScopeId(props.projectPath)),
138148
};
139149
}
140150
return {
141151
inputKey: getInputKey(props.workspaceId),
152+
imagesKey: getInputImagesKey(props.workspaceId),
142153
modelKey: getModelKey(props.workspaceId),
143154
};
144155
})();
@@ -150,10 +161,6 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
150161
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
151162
const [providerNames, setProviderNames] = useState<string[]>([]);
152163
const [toast, setToast] = useState<Toast | null>(null);
153-
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
154-
// Attached reviews come from parent via props (persisted in pendingReviews state)
155-
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
156-
157164
const pushToast = useCallback(
158165
(nextToast: Omit<Toast, "id">) => {
159166
setToast({ id: Date.now().toString(), ...nextToast });
@@ -163,6 +170,60 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
163170
const handleToastDismiss = useCallback(() => {
164171
setToast(null);
165172
}, []);
173+
174+
const imageDraftTooLargeToastKeyRef = useRef<string | null>(null);
175+
176+
const [imageAttachments, setImageAttachmentsState] = useState<ImageAttachment[]>(() => {
177+
return readPersistedImageAttachments(storageKeys.imagesKey);
178+
});
179+
const persistImageAttachments = useCallback(
180+
(nextImages: ImageAttachment[]) => {
181+
if (nextImages.length === 0) {
182+
imageDraftTooLargeToastKeyRef.current = null;
183+
updatePersistedState<ImageAttachment[] | undefined>(storageKeys.imagesKey, undefined);
184+
return;
185+
}
186+
187+
const estimatedChars = estimatePersistedImageAttachmentsChars(nextImages);
188+
if (estimatedChars > MAX_PERSISTED_IMAGE_DRAFT_CHARS) {
189+
// Clear persisted value to avoid restoring stale images on restart.
190+
updatePersistedState<ImageAttachment[] | undefined>(storageKeys.imagesKey, undefined);
191+
192+
if (imageDraftTooLargeToastKeyRef.current !== storageKeys.imagesKey) {
193+
imageDraftTooLargeToastKeyRef.current = storageKeys.imagesKey;
194+
pushToast({
195+
type: "error",
196+
message:
197+
"This draft image is too large to save. It will be lost when you switch workspaces or restart.",
198+
duration: 5000,
199+
});
200+
}
201+
return;
202+
}
203+
204+
imageDraftTooLargeToastKeyRef.current = null;
205+
updatePersistedState<ImageAttachment[] | undefined>(storageKeys.imagesKey, nextImages);
206+
},
207+
[storageKeys.imagesKey, pushToast]
208+
);
209+
210+
// Keep image drafts in sync when the storage scope changes (e.g. switching creation projects).
211+
useEffect(() => {
212+
imageDraftTooLargeToastKeyRef.current = null;
213+
setImageAttachmentsState(readPersistedImageAttachments(storageKeys.imagesKey));
214+
}, [storageKeys.imagesKey]);
215+
const setImageAttachments = useCallback(
216+
(value: ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[])) => {
217+
setImageAttachmentsState((prev) => {
218+
const next = value instanceof Function ? value(prev) : value;
219+
persistImageAttachments(next);
220+
return next;
221+
});
222+
},
223+
[persistImageAttachments]
224+
);
225+
// Attached reviews come from parent via props (persisted in pendingReviews state)
226+
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
166227
const inputRef = useRef<HTMLTextAreaElement>(null);
167228
const modelSelectorRef = useRef<ModelSelectorRef>(null);
168229

@@ -181,7 +242,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
181242
setInput(draft.text);
182243
setImageAttachments(draft.images);
183244
},
184-
[setInput]
245+
[setInput, setImageAttachments]
185246
);
186247
const preEditDraftRef = useRef<DraftState>({ text: "", images: [] });
187248
const { open } = useSettings();
@@ -387,14 +448,17 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
387448
);
388449

389450
// Method to restore images to input (used by queued message edit)
390-
const restoreImages = useCallback((images: ImagePart[]) => {
391-
const attachments: ImageAttachment[] = images.map((img, index) => ({
392-
id: `restored-${Date.now()}-${index}`,
393-
url: img.url,
394-
mediaType: img.mediaType,
395-
}));
396-
setImageAttachments(attachments);
397-
}, []);
451+
const restoreImages = useCallback(
452+
(images: ImagePart[]) => {
453+
const attachments: ImageAttachment[] = images.map((img, index) => ({
454+
id: `restored-${Date.now()}-${index}`,
455+
url: img.url,
456+
mediaType: img.mediaType,
457+
}));
458+
setImageAttachments(attachments);
459+
},
460+
[setImageAttachments]
461+
);
398462

399463
// Provide API to parent via callback
400464
useEffect(() => {
@@ -678,24 +742,30 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
678742
}, [variant, workspaceIdForFocus, focusMessageInput, setChatInputAutoFocusState]);
679743

680744
// Handle paste events to extract images
681-
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
682-
const items = e.clipboardData?.items;
683-
if (!items) return;
745+
const handlePaste = useCallback(
746+
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
747+
const items = e.clipboardData?.items;
748+
if (!items) return;
684749

685-
const imageFiles = extractImagesFromClipboard(items);
686-
if (imageFiles.length === 0) return;
750+
const imageFiles = extractImagesFromClipboard(items);
751+
if (imageFiles.length === 0) return;
687752

688-
e.preventDefault(); // Prevent default paste behavior for images
753+
e.preventDefault(); // Prevent default paste behavior for images
689754

690-
void processImageFiles(imageFiles).then((attachments) => {
691-
setImageAttachments((prev) => [...prev, ...attachments]);
692-
});
693-
}, []);
755+
void processImageFiles(imageFiles).then((attachments) => {
756+
setImageAttachments((prev) => [...prev, ...attachments]);
757+
});
758+
},
759+
[setImageAttachments]
760+
);
694761

695762
// Handle removing an image attachment
696-
const handleRemoveImage = useCallback((id: string) => {
697-
setImageAttachments((prev) => prev.filter((img) => img.id !== id));
698-
}, []);
763+
const handleRemoveImage = useCallback(
764+
(id: string) => {
765+
setImageAttachments((prev) => prev.filter((img) => img.id !== id));
766+
},
767+
[setImageAttachments]
768+
);
699769

700770
// Handle drag over to allow drop
701771
const handleDragOver = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
@@ -707,16 +777,19 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
707777
}, []);
708778

709779
// Handle drop to extract images
710-
const handleDrop = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
711-
e.preventDefault();
780+
const handleDrop = useCallback(
781+
(e: React.DragEvent<HTMLTextAreaElement>) => {
782+
e.preventDefault();
712783

713-
const imageFiles = extractImagesFromDrop(e.dataTransfer);
714-
if (imageFiles.length === 0) return;
784+
const imageFiles = extractImagesFromDrop(e.dataTransfer);
785+
if (imageFiles.length === 0) return;
715786

716-
void processImageFiles(imageFiles).then((attachments) => {
717-
setImageAttachments((prev) => [...prev, ...attachments]);
718-
});
719-
}, []);
787+
void processImageFiles(imageFiles).then((attachments) => {
788+
setImageAttachments((prev) => [...prev, ...attachments]);
789+
});
790+
},
791+
[setImageAttachments]
792+
);
720793

721794
// Handle command selection
722795
const handleCommandSelect = useCallback(

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { APIClient } from "@/browser/contexts/API";
22
import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
33
import {
44
getInputKey,
5+
getInputImagesKey,
56
getModelKey,
67
getModeKey,
78
getPendingScopeId,
@@ -461,10 +462,13 @@ describe("useCreationWorkspace", () => {
461462
expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]);
462463

463464
const modeKey = getModeKey(TEST_WORKSPACE_ID);
464-
const pendingInputKey = getInputKey(getPendingScopeId(TEST_PROJECT_PATH));
465+
const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH);
466+
const pendingInputKey = getInputKey(pendingScopeId);
467+
const pendingImagesKey = getInputImagesKey(pendingScopeId);
465468
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
466469
// Note: thinking level is no longer synced per-workspace, it's stored per-model globally
467470
expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]);
471+
expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]);
468472
});
469473

470474
test("handleSend surfaces backend errors and resets state", async () => {

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer
88
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
99
import {
1010
getInputKey,
11+
getInputImagesKey,
1112
getModelKey,
1213
getModeKey,
1314
getPendingScopeId,
@@ -198,8 +199,9 @@ export function useCreationWorkspace({
198199
// Sync preferences immediately (before switching)
199200
syncCreationPreferences(projectPath, metadata.id);
200201
if (projectPath) {
201-
const pendingInputKey = getInputKey(getPendingScopeId(projectPath));
202-
updatePersistedState(pendingInputKey, "");
202+
const pendingScopeId = getPendingScopeId(projectPath);
203+
updatePersistedState(getInputKey(pendingScopeId), "");
204+
updatePersistedState(getInputImagesKey(pendingScopeId), undefined);
203205
}
204206

205207
// Switch to the workspace IMMEDIATELY after creation to exit splash faster.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React from "react";
2+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3+
import { GlobalWindow } from "happy-dom";
4+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
5+
6+
import { ChatInputToast, type Toast } from "./ChatInputToast";
7+
8+
describe("ChatInputToast", () => {
9+
beforeEach(() => {
10+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
11+
globalThis.document = globalThis.window.document;
12+
});
13+
14+
afterEach(() => {
15+
cleanup();
16+
globalThis.window = undefined as unknown as Window & typeof globalThis;
17+
globalThis.document = undefined as unknown as Document;
18+
});
19+
20+
test("resets leaving state when a new toast is shown", async () => {
21+
const toast1: Toast = { id: "toast-1", type: "error", message: "first" };
22+
const toast2: Toast = { id: "toast-2", type: "error", message: "second" };
23+
24+
function Harness() {
25+
const [toast, setToast] = React.useState<Toast | null>(toast1);
26+
return (
27+
<div>
28+
<ChatInputToast toast={toast} onDismiss={() => undefined} />
29+
<button onClick={() => setToast(toast2)}>Next toast</button>
30+
</div>
31+
);
32+
}
33+
34+
const { getByLabelText, getByRole, getByText } = render(<Harness />);
35+
36+
fireEvent.click(getByLabelText("Dismiss"));
37+
38+
await waitFor(() => {
39+
expect(getByRole("alert").className).toContain("toastFadeOut");
40+
});
41+
42+
fireEvent.click(getByText("Next toast"));
43+
44+
await waitFor(() => {
45+
const className = getByRole("alert").className;
46+
expect(className).toContain("toastSlideIn");
47+
expect(className).not.toContain("toastFadeOut");
48+
});
49+
});
50+
});

0 commit comments

Comments
 (0)