From 545a9193e2add9995bb03ccec39538e5315349b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 22:01:07 -0800 Subject: [PATCH] Persist per-thread composer drafts and reuse project draft threads - add a zustand-backed composer draft store with localStorage persistence for prompt, cursor, model/effort, and image attachments - wire ChatView to draft state, restore/clear drafts correctly, and warn when an attachment cannot be persisted - track per-project draft thread IDs so Sidebar reuses hidden draft threads and excludes empty drafts from thread lists --- apps/web/package.json | 3 +- apps/web/src/components/ChatView.tsx | 275 +++++++++-- apps/web/src/components/Sidebar.tsx | 78 ++- apps/web/src/composerDraftStore.ts | 687 +++++++++++++++++++++++++++ apps/web/src/projectDraftThreads.ts | 91 ++++ bun.lock | 3 + packages/contracts/src/model.ts | 3 + 7 files changed, 1084 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/composerDraftStore.ts create mode 100644 apps/web/src/projectDraftThreads.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0d0da7060a7f793fb9fc36ec905cf648979f566c..153f821648742d12a02eafba6411f0fd798300d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,7 +27,8 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "zustand": "^5.0.11" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f90c63b397895aaa52af2f78ff0810b6dd3f31fd..840a810cad46660f0aced8b50f2f91bec07bb3b1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,7 +53,6 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatMessage, - type ChatImageAttachment, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; @@ -111,6 +110,14 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, useAppSettings } from "../appSettings"; +import { + type ComposerImageAttachment, + type PersistedComposerImageAttachment, + readPersistedComposerDraftAttachments, + useComposerDraftStore, + useComposerThreadDraft, +} from "../composerDraftStore"; +import { clearProjectDraftThreadById } from "../projectDraftThreads"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -130,6 +137,8 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; const WORKTREE_BRANCH_PREFIX = "t3code"; +const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; +const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV; function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); @@ -156,11 +165,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } -interface ComposerImageAttachment extends Omit { - previewUrl: string; - file: File; -} - interface ExpandedImageItem { src: string; name: string; @@ -239,6 +243,25 @@ function buildTemporaryWorktreeBranchName(): string { return `${WORKTREE_BRANCH_PREFIX}/${token}`; } +function readComposerDraftStorageChars(): number | null { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + return raw?.length ?? 0; + } catch { + return null; + } +} + +function debugDraftPersist(message: string, payload: Record): void { + if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) { + return; + } + console.debug(`[draft-persist] ${message}`, payload); +} + const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; @@ -372,23 +395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); - const [prompt, setPrompt] = useState(""); + const composerDraft = useComposerThreadDraft(threadId); + const prompt = composerDraft.prompt; + const composerImages = composerDraft.images; + const composerCursor = composerDraft.cursor; + const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const setComposerDraftCursor = useComposerDraftStore((store) => store.setCursor); + const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); + const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); + const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const clearComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.clearPersistedAttachments, + ); + const syncComposerDraftPersistedAttachments = useComposerDraftStore( + (store) => store.syncPersistedAttachments, + ); + const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const promptRef = useRef(prompt); - const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerCursor, setComposerCursor] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< Record @@ -402,6 +440,37 @@ export default function ChatView({ threadId }: ChatViewProps) { const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const setPrompt = useCallback( + (nextPrompt: string) => { + setComposerDraftPrompt(threadId, nextPrompt); + }, + [setComposerDraftPrompt, threadId], + ); + const setComposerCursor = useCallback( + (nextCursor: number) => { + setComposerDraftCursor(threadId, nextCursor); + }, + [setComposerDraftCursor, threadId], + ); + const addComposerImage = useCallback( + (image: ComposerImageAttachment) => { + addComposerDraftImage(threadId, image); + }, + [addComposerDraftImage, threadId], + ); + const addComposerImagesToDraft = useCallback( + (images: ComposerImageAttachment[]) => { + addComposerDraftImages(threadId, images); + }, + [addComposerDraftImages, threadId], + ); + const removeComposerImageFromDraft = useCallback( + (imageId: string) => { + removeComposerDraftImage(threadId, imageId); + }, + [removeComposerDraftImage, threadId], + ); + const activeThread = state.threads.find((t) => t.id === threadId); const diffSearch = useMemo( () => parseDiffRouteSearch(rawSearch as Record), @@ -435,8 +504,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const selectedModel = resolveModelSlug( - activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, + composerDraft.model ?? activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); + const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; const modelOptions = useMemo( () => getAppModelOptions(settings.customCodexModels, selectedModel), [selectedModel, settings.customCodexModels], @@ -645,6 +715,10 @@ export default function ChatView({ threadId }: ChatViewProps) { null, [composerHighlightedItemId, composerMenuItems], ); + const nonPersistedComposerImageIdSet = useMemo( + () => new Set(nonPersistedComposerImageIds), + [nonPersistedComposerImageIds], + ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProject?.cwd) return {}; @@ -693,12 +767,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const hasReachedTerminalLimit = (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; - const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { - for (const image of images) { - if (!image.previewUrl) continue; - URL.revokeObjectURL(image.previewUrl); - } - }, []); const focusComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -1088,26 +1156,115 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread?.id, activeThread?.messages]); useEffect(() => { - setComposerImages((existing) => { - revokePreviewUrls(existing); - return []; - }); + promptRef.current = prompt; + }, [prompt]); + + useEffect(() => { setOptimisticUserMessages([]); - setPrompt(""); - promptRef.current = ""; setSendPhase("idle"); - setComposerCursor(0); setComposerHighlightedItemId(null); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [activeThread?.id, revokePreviewUrls]); + }, [threadId]); useEffect(() => { + let cancelled = false; + void (async () => { + if (composerImages.length === 0) { + clearComposerDraftPersistedAttachments(threadId); + return; + } + try { + const currentPersistedAttachments = + useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + const existingPersistedById = new Map( + currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), + ); + const stagedAttachmentById = new Map(); + const serializationFailedImageIds: string[] = []; + await Promise.all( + composerImages.map(async (image) => { + try { + const dataUrl = await readFileAsDataUrl(image.file); + stagedAttachmentById.set(image.id, { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }); + } catch { + const existingPersisted = existingPersistedById.get(image.id); + if (existingPersisted) { + stagedAttachmentById.set(image.id, existingPersisted); + } else { + serializationFailedImageIds.push(image.id); + } + } + }), + ); + const serialized = Array.from(stagedAttachmentById.values()); + if (cancelled) { + return; + } + // Stage attachments in persisted draft state first so persist middleware can write them. + const stagedAttachmentIds = serialized.map((attachment) => attachment.id); + debugDraftPersist("stage", { + threadId, + attachmentCount: serialized.length, + serializationFailedCount: serializationFailedImageIds.length, + serializationFailedImageIds, + attachmentMeta: serialized.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + sizeBytes: attachment.sizeBytes, + dataUrlChars: attachment.dataUrl.length, + })), + storageCharsBefore: readComposerDraftStorageChars(), + }); + syncComposerDraftPersistedAttachments( + threadId, + serialized, + ); + debugDraftPersist("reconcile-request", { + threadId, + stagedCount: stagedAttachmentIds.length, + serializationFailedCount: serializationFailedImageIds.length, + storageCharsAfter: readComposerDraftStorageChars(), + }); + } catch (error) { + const currentImageIds = new Set(composerImages.map((image) => image.id)); + const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId); + const fallbackPersistedIds = fallbackPersistedAttachments + .map((attachment) => attachment.id) + .filter((id) => currentImageIds.has(id)); + const fallbackPersistedIdSet = new Set(fallbackPersistedIds); + const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => + fallbackPersistedIdSet.has(attachment.id), + ); + debugDraftPersist("error", { + threadId, + error: error instanceof Error ? error.message : String(error), + imageIds: composerImages.map((image) => image.id), + fallbackPersistedCount: fallbackPersistedIds.length, + storageChars: readComposerDraftStorageChars(), + }); + if (cancelled) { + return; + } + syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + } + })(); return () => { - revokePreviewUrls(composerImagesRef.current); + cancelled = true; }; - }, [revokePreviewUrls]); + }, [ + clearComposerDraftPersistedAttachments, + composerImages, + syncComposerDraftPersistedAttachments, + threadId, + ]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -1310,7 +1467,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; - const nextImages = [...composerImagesRef.current]; + const nextImages: ComposerImageAttachment[] = []; + let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { @@ -1321,7 +1479,7 @@ export default function ChatView({ threadId }: ChatViewProps) { error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; continue; } - if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; } @@ -1336,20 +1494,19 @@ export default function ChatView({ threadId }: ChatViewProps) { previewUrl, file, }); + nextImageCount += 1; } - setComposerImages(nextImages); + if (nextImages.length === 1 && nextImages[0]) { + addComposerImage(nextImages[0]); + } else if (nextImages.length > 1) { + addComposerImagesToDraft(nextImages); + } setThreadError(activeThreadId, error); }; const removeComposerImage = (imageId: string) => { - setComposerImages((existing) => { - const match = existing.find((image) => image.id === imageId); - if (match?.previewUrl) { - URL.revokeObjectURL(match.previewUrl); - } - return existing.filter((image) => image.id !== imageId); - }); + removeComposerImageFromDraft(imageId); }; const onComposerPaste = (event: React.ClipboardEvent) => { @@ -1516,9 +1673,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; - setPrompt(""); - setComposerImages([]); - setComposerCursor(0); + clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); let attemptedTurnStart = false; @@ -1596,6 +1751,9 @@ export default function ChatView({ threadId }: ChatViewProps) { sandboxMode, createdAt: messageCreatedAt, }); + if (isFirstMessage) { + clearProjectDraftThreadById(activeProject.id, threadIdForSend); + } } catch (err) { if ( !attemptedTurnStart && @@ -1607,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); promptRef.current = trimmed; setPrompt(trimmed); - setComposerImages(composerImagesSnapshot); + addComposerImagesToDraft(composerImagesSnapshot); setComposerCursor(trimmed.length); } setThreadError( @@ -1663,26 +1821,27 @@ export default function ChatView({ threadId }: ChatViewProps) { const onModelSelect = useCallback( (model: string) => { + const normalizedModel = resolveModelSlug(model); + setComposerDraftModel(threadId, normalizedModel); const api = readNativeApi(); - if (!activeThread) return; - if (api) { + if (api && activeThread) { void api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: activeThread.id, - model: resolveModelSlug(model), + model: normalizedModel, }); } scheduleComposerFocus(); }, - [activeThread, scheduleComposerFocus], + [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { - setSelectedEffort(effort); + setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, - [scheduleComposerFocus], + [scheduleComposerFocus, setComposerDraftEffort, threadId], ); const onEnvModeChange = useCallback( (mode: "local" | "worktree") => { @@ -1705,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(next.cursor); }); }, - [], + [setComposerCursor, setPrompt], ); const onSelectComposerItem = useCallback( @@ -1748,7 +1907,7 @@ export default function ChatView({ threadId }: ChatViewProps) { promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); - }, []); + }, [setComposerCursor, setPrompt]); const onKeyDown = (e: React.KeyboardEvent) => { if (composerMenuOpen && composerMenuItems.length > 0) { @@ -1960,6 +2119,24 @@ export default function ChatView({ threadId }: ChatViewProps) { {image.name} )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on navigation. + + + )}