Persist composer drafts per thread and reuse hidden draft threads#128

merged14
main
codething/2f21514d
openedMar 1, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 12d ago

Summary

  • Persist composer draft state per thread in a new Zustand store, including prompt text, cursor position, selected model/effort, and image attachments.
  • Hydrate persisted image attachments back into File objects and track non-persisted attachments when local serialization fails.
  • Refactor ChatView to use shared draft store actions instead of local component state for composer content.
  • Reuse project-scoped hidden draft threads when creating a new chat, and clear stored draft-thread mappings after first sent message or deletion.
  • Update sidebar thread visibility/count logic to exclude draft-only threads (threads with no user messages).
  • Add zustand dependency and extend shared contracts usage for reasoning/model normalization.

Testing

  • Not run (PR content generation task; no repository commands executed in this step).

Note

Medium Risk Changes core chat composer and thread lifecycle by introducing persisted draft state and creating server threads on first send, which could surface edge cases in draft/server synchronization, worktree setup, and attachment persistence/cleanup.

Overview Adds a new persistent zustand composer draft store (backed by localStorage) to keep per-thread prompt text, cursor position, selected model/effort, and image attachments, including hydration/deduping of attachments and tracking attachments that failed to persist.

Refactors ChatView/BranchToolbar to operate on either a server thread or a local draft thread: draft threads can be created/reused per project, branch/env-mode changes update the draft when no server thread exists, and the first send now creates the server thread (with worktree setup + optional setup script) and clears/rolls back draft state appropriately.

Updates routing/sidebar behaviors to recognize draft-only threads, reuse project-scoped draft thread ids, and ensure drafts are cleared when threads/projects are deleted; adds unit tests for the draft store and updates dependencies/lockfile to include zustand.

Written by Cursor Bugbot for commit 9e99b8efdc43870933fe114a94a82c2663471f2e. This will update automatically on new commits. Configure here.

Note

Persist composer drafts per thread and route draft-only threads through useComposerDraftStore across BranchToolbar, Sidebar, ChatView, and _chat.$threadId to reuse hidden draft threads

Introduce a persisted useComposerDraftStore for per-thread prompt, images, model, effort, and envMode; update branch and env handling to prefer draft context when no server thread exists; allow navigation and send flow for draft-only thread ids and create/delete server threads on first send. Core changes start in composerDraftStore.ts and propagate to ChatView.tsx, Sidebar.tsx, BranchToolbar.tsx, and routes/_chat.$threadId.tsx.

πŸ“Where to Start

Start with the store and normalization logic in useComposerDraftStore in composerDraftStore.ts, then follow draft usage in ChatView in ChatView.tsx.

Macroscope summarized 9e99b8e.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codething/2f21514d

Comment @coderabbitai help to get the list of available commands and usage tips.

apps/web/src/composerDraftStore.tsline 433Outdated Β· 45ce796Open thread
……@@ -0,0 +1,687 @@
430+ const dedupedIncoming: ComposerImageAttachment[] = [];
431+ for (const image of images) {
432+ if (existingIds.has(image.id)) {
433+ revokeObjectPreviewUrl(image.previewUrl);
MmacroscopeappBoton line 433Mar 1, 2026

🟒 Low src/composerDraftStore.ts:433

If images contains duplicates sharing the same blob URL, the first accepted image's URL gets revoked when processing the second. Consider only revoking URLs for duplicates whose URL differs from any accepted image's URL.

βœ… Resolved in 751d864218c4e5976bb100e21a74cd717b879348

apps/web/src/components/ChatView.tsxline 1660Outdated Β· 45ce796Open thread
……@@ -1500,9 +1657,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1503- setPrompt("");
1504- setComposerImages([]);
1505- setComposerCursor(0);
1660+ clearComposerDraftContent(threadIdForSend);
CcursorBoton line 1660Mar 1, 2026

Revoked blob URLs break image previews on send failure

Medium Severity

clearComposerDraftContent revokes blob preview URLs for all composer images, but composerImagesSnapshot (a shallow copy taken earlier at line 1635) holds references to the same image objects with those now-invalid blob URLs. When the send fails and the error handler restores images via addComposerImagesToDraft(composerImagesSnapshot), the restored images have revoked previewUrl values, causing broken image thumbnails in the composer.

Additional Locations (2)

Fix in CursorΒ Fix in Web

apps/web/src/components/ChatView.tsxline 139Outdated Β· 45ce796Open thread
……@@ -129,6 +136,8 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
129136const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
130137const SCRIPT_TERMINAL_COLS = 120;
131138const SCRIPT_TERMINAL_ROWS = 30;
139+const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
CcursorBoton line 139Mar 1, 2026

Duplicated storage key constant across two files

Low Severity

COMPOSER_DRAFT_STORAGE_KEY is defined independently in both ChatView.tsx and composerDraftStore.ts with the same string value "t3code:composer-drafts:v1". If one is updated without the other, the debug helper readComposerDraftStorageChars in ChatView.tsx would silently read from the wrong key.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/components/Sidebar.tsxline 353Outdated Β· 45ce796Open thread
……@@ -303,6 +315,42 @@ export default function Sidebar() {
350+ if (reusableDraftThreadId) {
351+ writeProjectDraftThreadId(projectId, reusableDraftThreadId);
352+ return Promise.resolve();
353+ }
CcursorBoton line 353Mar 1, 2026

Draft thread reuse silently drops branch/worktree options

Medium Severity

When handleNewThread is called with options containing branch and worktreePath (via the "new chat" keyboard shortcut that inherits branch from the active thread), both the stored-draft reuse path and the active-draft reuse path return early without applying the branch/worktree options to the reused thread. The options parameter is only used when a brand-new thread is created.

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tslines 124–127Outdated Β· 751d864Open thread
……@@ -0,0 +1,700 @@
124+function composerImageDedupKey(image: ComposerImageAttachment): string {
125+ const lastModified = Number.isFinite(image.file.lastModified) ? image.file.lastModified : 0;
126+ return `${image.mimeType}\u0000${image.sizeBytes}\u0000${lastModified}\u0000${image.name}`;
127+}
MmacroscopeappBoton lines 124–127Mar 1, 2026

🟒 Low src/composerDraftStore.ts:124

Hydrated images get Date.now() for lastModified (from fileFromDataUrl), but the dedup key includes lastModified. This breaks deduplication for attachments restored from localStorage. Consider either persisting lastModified alongside other attachment data, or excluding it from the dedup key.

βœ… Resolved in 4ae74fc4ea8b465eea666f2a3ef09ff30b99ddae

apps/web/src/composerDraftStore.tslines 234–361Outdated Β· 751d864Open thread
MmacroscopeappBoton lines 234–361Mar 1, 2026

🟒 Low src/composerDraftStore.ts:234

Empty files (e.g., data:text/plain;base64,) have an empty string payload after split. The check !payload rejects empty strings, returning null and causing data loss. Consider using payload === undefined instead.

No longer relevant as of a5ed08c8f51ecd15e37708015adf1fd7c046cc6b

apps/web/src/composerDraftStore.tsline 587Outdated Β· 751d864Open thread
……@@ -0,0 +1,700 @@
584+ for (const image of existing.images) {
585+ revokeObjectPreviewUrl(image.previewUrl);
586+ }
587+ }
CcursorBoton line 587Mar 1, 2026

Blob URLs revoked too early break optimistic message images

Medium Severity

clearComposerContent revokes blob URLs via revokeObjectPreviewUrl before clearing images from the store. However, those same blob URLs are still referenced by the optimistic user message attachments (captured in composerImagesSnapshot and added to optimisticUserMessages moments earlier). After revocation, the <img src="blob:..."> elements in the optimistic message bubble will show broken images until the server message replaces them. The old code did not revoke blob URLs during send β€” only on thread change β€” so optimistic previews remained intact.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/components/ChatView.tsxline 1843Outdated Β· 4ae74fcOpen thread
……@@ -1665,26 +1839,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
16661840 const onModelSelect = useCallback(
16671841 (model: string) => {
1842+ const normalizedModel = resolveModelSlug(model);
1843+ setComposerDraftModel(threadId, normalizedModel);
CcursorBoton line 1843Mar 1, 2026

Model stored permanently, preventing draft auto-removal

Low Severity

onModelSelect passes its value through resolveModelSlug, which never returns null (it falls back to DEFAULT_MODEL). The store's setModel then stores this always-non-null value. Unlike setEffort β€” which normalizes DEFAULT_REASONING to null β€” there's no equivalent normalization for the default model. Since clearComposerContent preserves model and shouldRemoveDraft requires model === null, any thread where the user ever selected a model (including re-selecting the default) creates a draft entry that never auto-removes. Over many threads these orphaned draft entries accumulate in localStorage.

Additional Locations (2)

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tslines 870–871Outdated Β· 619db3dOpen thread
……@@ -0,0 +1,1002 @@
868+ if (threadId.length === 0) {
869+ return;
870+ }
871+ set((state) => {
MmacroscopeappBoton lines 870–871Mar 1, 2026

🟑 Medium src/composerDraftStore.ts:870

clearComposerContent should revoke blob URLs for images before clearing (like clearThreadDraft does on lines 899-901). Without this, blob memory leaks until page unload.

diff
1 }
2+ const existing = get().draftsByThreadId[threadId];
3+ if (existing) {
4+ for (const image of existing.images) {
5+ revokeObjectPreviewUrl(image.previewUrl);
6+ }
7+ }
8 set((state) => {
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/web/src/composerDraftStore.ts around lines 870-871:
2
3`clearComposerContent` should revoke blob URLs for images before clearing (like `clearThreadDraft` does on lines 899-901). Without this, blob memory leaks until page unload.
4
5Evidence trail:
6apps/web/src/composerDraftStore.ts lines 868-892 (clearComposerContent sets `images: []` without revoking URLs), lines 893-901 (clearThreadDraft calls `revokeObjectPreviewUrl(image.previewUrl)` for each image before clearing). Both functions operate on `state.draftsByThreadId[threadId].images` with `previewUrl` properties.

Kept clearComposerContent non-revoking intentionally because those blob URLs are also referenced by optimistic user-message previews during send. Revoking there breaks thumbnails until the server response lands. Instead, this update adds explicit blob URL cleanup when optimistic messages are removed (both on server reconciliation and send-failure rollback), so previews stay intact during send while object URLs are still released promptly after they are no longer shown.

apps/web/src/components/ChatView.tsxline 1521 (old)Open thread
……@@ -1516,23 +1738,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
15181740 });
1519- await api.orchestration.dispatchCommand({
1520- type: "thread.meta.update",
1521- commandId: newCommandId(),
CcursorBoton line 1521 (old)Mar 1, 2026

Thread errors silently lost for local draft threads

Medium Severity

setThreadError dispatches a SET_ERROR action to state.threads, but local draft threads only exist in the zustand draft store, not in state.threads. The updateThread reducer maps over state.threads and silently finds no match. Meanwhile buildLocalDraftThread hardcodes error: null, so the ThreadErrorBanner never renders errors for draft threads. This affects image validation errors (wrong file type, size limit, attachment count) and onSend failure messages β€” the user gets no feedback.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tsline 431Outdated Β· 619db3dOpen thread
……@@ -0,0 +1,1002 @@
428+ if (threadId.length === 0) {
429+ return false;
430+ }
431+ return get().draftThreadsByThreadId[threadId] !== undefined;
CcursorBoton line 431Mar 1, 2026

Store action isDraftThread is never consumed externally

Low Severity

The isDraftThread action is defined in the ComposerDraftStoreState interface and implemented in the store, but no component or module ever selects or calls it. It's dead code that adds unnecessary surface area to the store.

Additional Locations (1)

Fix in CursorΒ Fix in Web

CcursorBotreviewed Β· 1 inline threadMar 1, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • βœ… Fixed: Race condition when clearing draft before server sync
    • Added an explicit getSnapshot() + SYNC_SERVER_READ_MODEL dispatch after thread.create succeeds, ensuring the server thread is present in state.threads before clearDraftThread removes the draft, closing the window where the route guard would see routeThreadExists=false and redirect to /.

Create PR

Or push these changes by commenting:

1@cursor push cfd643fd9d
Preview (pingdotgg/t3code@cfd643fd9d)
diff
1diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
2--- a/apps/web/src/components/ChatView.tsx
3+++ b/apps/web/src/components/ChatView.tsx
4@@ -1775,6 +1775,8 @@
5 createdAt: activeThread.createdAt,
6 });
7 createdServerThreadForLocalDraft = true;
8+ const snapshot = await api.orchestration.getSnapshot();
9+ dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
10 }
11
12 // Auto-title from first message
apps/web/src/components/ChatView.tsxline 1833Outdated Β· df0039dOpen thread
……@@ -1598,9 +1827,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
1830+ turnStartSucceeded = true;
1831+ if (isFirstMessage) {
1832+ clearDraftThread(threadIdForSend);
1833+ }
CcursorBoton line 1833Mar 1, 2026

Race condition when clearing draft before server sync

High Severity

clearDraftThread removes the draft from draftThreadsByThreadId immediately after turnStartSucceeded, but the newly created server thread (from thread.create) may not yet be synced to state.threads via the async event stream. This leaves a window where both threadExists and draftThreadExists are false in the route guard, causing routeThreadExists to be false and triggering an unwanted redirect to /. The old code had an explicit getSnapshot() sync after thread.create, which was removed in this refactor.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tslines 1000–1023Outdated Β· a5ed08cOpen thread
MmacroscopeappBoton lines 1000–1023Mar 1, 2026

🟒 Low src/composerDraftStore.ts:1000

Returning the shared mutable EMPTY_THREAD_DRAFT object risks state corruption if any consumer mutates it. Consider returning createEmptyThreadDraft() instead, or freeze EMPTY_THREAD_DRAFT with Object.freeze().

suggestion
1 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? createEmptyThreadDraft());
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/web/src/composerDraftStore.ts around line 1000:
2
3Returning the shared mutable `EMPTY_THREAD_DRAFT` object risks state corruption if any consumer mutates it. Consider returning `createEmptyThreadDraft()` instead, or freeze `EMPTY_THREAD_DRAFT` with `Object.freeze()`.
4
5Evidence trail:
6apps/web/src/composerDraftStore.ts lines 118-130 (EMPTY_THREAD_DRAFT definition as unfrozen mutable object with nested arrays), lines 134-144 (createEmptyThreadDraft function that creates new instances), line 1000 (useComposerThreadDraft returning the shared EMPTY_THREAD_DRAFT as fallback)

Applied. EMPTY_THREAD_DRAFT and its nested empty arrays are now frozen with Object.freeze(...) so the shared fallback cannot be mutated accidentally, while preserving referential stability for the selector fallback.

CcursorBotreviewed Β· 1 inline threadMar 1, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • βœ… Fixed: Unused variable serializationFailedImageIds is dead code
    • Removed the unused serializationFailedImageIds array declaration and its only push call, as the variable was never read after population.

Create PR

Or push these changes by commenting:

1@cursor push 7eedfef6ff
Preview (pingdotgg/t3code@7eedfef6ff)
diff
1diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
2--- a/apps/web/src/components/ChatView.tsx
3+++ b/apps/web/src/components/ChatView.tsx
4@@ -1217,7 +1217,6 @@
5 currentPersistedAttachments.map((attachment) => [attachment.id, attachment]),
6 );
7 const stagedAttachmentById = new Map<string, PersistedComposerImageAttachment>();
8- const serializationFailedImageIds: string[] = [];
9 await Promise.all(
10 composerImages.map(async (image) => {
11 try {
12@@ -1233,8 +1232,6 @@
13 const existingPersisted = existingPersistedById.get(image.id);
14 if (existingPersisted) {
15 stagedAttachmentById.set(image.id, existingPersisted);
16- } else {
17- serializationFailedImageIds.push(image.id);
18 }
19 }
20 }),
apps/web/src/components/ChatView.tsxline 1237Outdated Β· a5ed08cOpen thread
……@@ -1081,26 +1191,86 @@ export default function ChatView({ threadId }: ChatViewProps) {
1234+ if (existingPersisted) {
1235+ stagedAttachmentById.set(image.id, existingPersisted);
1236+ } else {
1237+ serializationFailedImageIds.push(image.id);
CcursorBoton line 1237Mar 1, 2026

Unused variable serializationFailedImageIds is dead code

Low Severity

serializationFailedImageIds is declared and populated inside the image-persistence useEffect, but it is never read after the Promise.all completes. The non-persisted image tracking is handled separately by syncPersistedAttachments via a deferred localStorage check, making this array entirely dead code.

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tsline 493Outdated Β· 9853d1fOpen thread
……@@ -0,0 +1,1009 @@
490+ previousThreadIdForProject !== threadId &&
491+ !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
492+ ) {
493+ delete nextDraftThreadsByThreadId[previousThreadIdForProject];
MmacroscopeappBoton line 493Mar 2, 2026

🟑 Medium src/composerDraftStore.ts:493

When removing a draft thread mapping (line 493), the corresponding entry in draftsByThreadId is not deleted. This leaves orphaned draft content (potentially with large base64 images) in localStorage indefinitely. Consider also deleting nextDraftsByThreadId entries when removing orphaned thread mappingsβ€”this applies to similar cleanup in clearProjectDraftThreadId and clearProjectDraftThreadById.

βœ… Resolved in cb830d562fde3876cbad1061989621c4c91ee807

apps/web/src/composerDraftStore.tslines 359–360Outdated Β· 9853d1fOpen thread
……@@ -0,0 +1,1009 @@
357+ attachment: PersistedComposerImageAttachment,
358+): File | null {
359+ const [rawHeader, payload] = attachment.dataUrl.split(",", 2);
360+ const header = rawHeader ?? "";
MmacroscopeappBoton lines 359–360Mar 2, 2026

🟒 Low src/composerDraftStore.ts:359

Using split(",", 2) truncates non-base64 payloads containing literal commas (e.g., data:text/plain,item1,item2). Consider splitting only on the first comma by using indexOf instead.

βœ… Resolved in cb830d562fde3876cbad1061989621c4c91ee807

apps/web/src/routes/_chat.$threadId.tsxlines 143–145Outdated Β· 9853d1fOpen thread
……@@ -139,6 +140,10 @@ function ChatThreadRouteView() {
141142 const threadExists = state.threads.some((thread) => thread.id === threadId);
143+ const draftThreadExists = useComposerDraftStore(
144+ (store) => store.draftThreadsByThreadId[threadId] !== undefined,
145+ );
MmacroscopeappBoton lines 143–145Mar 2, 2026

🟒 Low routes/_chat.$threadId.tsx:143

Consider using Object.hasOwn() instead of !== undefined to check if the draft thread exists. Direct property access on draftThreadsByThreadId returns inherited prototype properties (e.g., toString, constructor) as truthy values, bypassing the 404 redirect for those thread IDs.

βœ… Resolved in cb830d562fde3876cbad1061989621c4c91ee807

CcursorBotreviewed Β· 1 inline threadMar 2, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • βœ… Fixed: Store strips DEFAULT_MODEL override for non-default base models
    • Removed the redundant DEFAULT_MODEL-to-null normalization in the setModel store action, since the caller (onModelSelect) already correctly normalizes relative to the thread's base model.

Create PR

Or push these changes by commenting:

1@cursor push 34854ac687
Preview (pingdotgg/t3code@34854ac687)
diff
1diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
2--- a/apps/web/src/composerDraftStore.ts
3+++ b/apps/web/src/composerDraftStore.ts
4@@ -1,5 +1,4 @@
5 import {
6- DEFAULT_MODEL,
7 DEFAULT_REASONING,
8 ProjectId,
9 REASONING_OPTIONS,
10@@ -671,11 +670,7 @@
11 if (threadId.length === 0) {
12 return;
13 }
14- const normalizedModelCandidate = normalizeModelSlug(model);
15- const normalizedModel =
16- normalizedModelCandidate && normalizedModelCandidate !== DEFAULT_MODEL
17- ? normalizedModelCandidate
18- : null;
19+ const normalizedModel = normalizeModelSlug(model);
20 set((state) => {
21 const existing = state.draftsByThreadId[threadId];
22 if (!existing && normalizedModel === null) {
apps/web/src/composerDraftStore.tsline 678Outdated Β· 9853d1fOpen thread
……@@ -0,0 +1,1009 @@
675+ const normalizedModel =
676+ normalizedModelCandidate && normalizedModelCandidate !== DEFAULT_MODEL
677+ ? normalizedModelCandidate
678+ : null;
CcursorBoton line 678Mar 2, 2026

Store strips DEFAULT_MODEL override for non-default base models

Medium Severity

The setModel store action unconditionally normalizes DEFAULT_MODEL ("gpt-5.3-codex") to null. The onModelSelect caller already passes null when the selected model equals the thread's base model. When a thread's base model differs from DEFAULT_MODEL (e.g., "gpt-5.2-codex") and the user explicitly picks DEFAULT_MODEL, the store's additional filtering discards this legitimate override. The composer falls back to the base model, ignoring the user's selection. For local draft threads with no server-side fallback, this results in the wrong model being used permanently.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/composerDraftStore.tslines 461–469Outdated Β· cb830d5Open thread
……@@ -0,0 +1,1024 @@
466+ worktreePath:
467+ options && "worktreePath" in options
468+ ? (options.worktreePath ?? null)
469+ : (existingThread?.worktreePath ?? null),
MmacroscopeappBoton lines 461–469Mar 2, 2026

🟒 Low src/composerDraftStore.ts:461

Using "branch" in options with ?? null means { branch: undefined } clears the existing value instead of preserving it. Consider using options?.branch === undefined ? existingThread?.branch ?? null : options.branch ?? null to match setDraftThreadContext behavior.

βœ… Resolved in 9e99b8efdc43870933fe114a94a82c2663471f2e

apps/web/src/composerDraftStore.tsline 124Outdated Β· 9b0cdb8Open thread
……@@ -0,0 +1,1024 @@
121+const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
122+Object.freeze(EMPTY_IMAGES);
123+Object.freeze(EMPTY_IDS);
124+Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
MmacroscopeappBoton line 124Mar 2, 2026

🟒 Low src/composerDraftStore.ts:124

The frozen EMPTY_PERSISTED_ATTACHMENTS array is typed as mutable PersistedComposerImageAttachment[], so consumers may call .push() or .sort() and get a runtime TypeError. Consider typing it as readonly or documenting that returned drafts should not be mutated in place.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file apps/web/src/composerDraftStore.ts around line 124:
2
3The frozen `EMPTY_PERSISTED_ATTACHMENTS` array is typed as mutable `PersistedComposerImageAttachment[]`, so consumers may call `.push()` or `.sort()` and get a runtime `TypeError`. Consider typing it as `readonly` or documenting that returned drafts should not be mutated in place.
4
5Evidence trail:
6apps/web/src/composerDraftStore.ts lines 124-127: Shows `EMPTY_PERSISTED_ATTACHMENTS` declared as `PersistedComposerImageAttachment[]` and then frozen with `Object.freeze()`
7apps/web/src/composerDraftStore.ts lines 49-58: Shows `ComposerThreadDraftState` interface with mutable array types (no `readonly`)
8apps/web/src/composerDraftStore.ts lines 128-133: Shows `EMPTY_THREAD_DRAFT` object using the frozen arrays
9apps/web/src/composerDraftStore.ts line 1023: Shows `useComposerThreadDraft` returning `EMPTY_THREAD_DRAFT` as fallback, exposing the frozen array with mutable type to consumers
CcursorBotreviewed Β· 2 inline threadsMar 2, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • βœ… Fixed: Error dispatch bypasses local draft thread error handling
    • Replaced direct dispatch({ type: 'SET_ERROR' }) with setThreadError() so the error is routed to localDraftErrorsByThreadId for local draft threads that don't exist in state.threads.
  • βœ… Fixed: Worktree setup script skipped for draft threads
    • Added setup script execution after thread.create for local draft threads, with a forceRun option on runProjectScript to bypass the stale isServerThread closure check.

Create PR

Or push these changes by commenting:

1@cursor push 64717bf7cb
Preview (pingdotgg/t3code@64717bf7cb)
diff
1diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
2--- a/apps/web/src/components/ChatView.tsx
3+++ b/apps/web/src/components/ChatView.tsx
4@@ -933,10 +933,11 @@
5 worktreePath?: string | null;
6 preferNewTerminal?: boolean;
7 rememberAsLastInvoked?: boolean;
8+ forceRun?: boolean;
9 },
10 ) => {
11 const api = readNativeApi();
12- if (!api || !activeThreadId || !activeProject || !activeThread || !isServerThread) return;
13+ if (!api || !activeThreadId || !activeProject || !activeThread || (!isServerThread && !options?.forceRun)) return;
14 if (options?.rememberAsLastInvoked !== false) {
15 setLastInvokedScriptByProjectId((current) => {
16 if (current[activeProject.id] === script.id) return current;
17@@ -1699,11 +1700,10 @@
18 const shouldCreateWorktree =
19 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
20 if (shouldCreateWorktree && !activeThread.branch) {
21- dispatch({
22- type: "SET_ERROR",
23- threadId: threadIdForSend,
24- error: "Select a base branch before sending in New worktree mode.",
25- });
26+ setThreadError(
27+ threadIdForSend,
28+ "Select a base branch before sending in New worktree mode.",
29+ );
30 return;
31 }
32
33@@ -1801,6 +1801,18 @@
34 createdAt: activeThread.createdAt,
35 });
36 createdServerThreadForLocalDraft = true;
37+
38+ if (baseBranchForWorktree) {
39+ const setupScript = setupProjectScript(activeProject.scripts);
40+ if (setupScript) {
41+ await runProjectScript(setupScript, {
42+ cwd: nextThreadWorktreePath!,
43+ worktreePath: nextThreadWorktreePath,
44+ rememberAsLastInvoked: false,
45+ forceRun: true,
46+ });
47+ }
48+ }
49 }
50
51 // Auto-title from first message
apps/web/src/components/ChatView.tsxline 1710 (old)Open thread
……@@ -1707,7 +1989,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17071989 setComposerCursor(next.cursor);
17081990 });
17091991 },
1710- [],
CcursorBoton line 1710 (old)Mar 2, 2026

Error dispatch bypasses local draft thread error handling

High Severity

When a local draft thread in worktree mode has no branch selected, onSend dispatches SET_ERROR directly via dispatch instead of using setThreadError. Since local draft threads don't exist in state.threads, the store's updateThread silently ignores the action, and the user sees no error feedback explaining why their message wasn't sent. The setThreadError helper at line 1514 was specifically designed to route errors to localDraftErrorsByThreadId for non-server threads.

Additional Locations (1)

Fix in CursorΒ Fix in Web

apps/web/src/components/ChatView.tsxline 1775Outdated Β· 9b0cdb8Open thread
……@@ -1516,23 +1752,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
1773+ }
15341774 const setupScript = setupProjectScript(activeProject.scripts);
1535- if (setupScript) {
1775+ if (setupScript && isServerThread) {
CcursorBoton line 1775Mar 2, 2026

Worktree setup script skipped for draft threads

Medium Severity

The isServerThread guard at line 1775 prevents the project setup script from running for local draft threads in worktree mode. After the worktree is created but before thread.turn.start, thread.create is dispatched β€” but the setup script is never retried after that. The worktree environment may be uninitialized (e.g. missing npm install) when the first turn starts.

Additional Locations (1)

Fix in CursorΒ Fix in Web

Added 14 commits

  • Preserve explicit DEFAULT_MODEL draft overrides123eb96
  • Preserve draft branch/worktree when update options are undefined39fd14b
  • Preserve draft metadata and dedupe composer image retries3a5a14c
  • Simplify composer draft persistence and remove debug instrumentation5a904fc
  • Unify project draft thread state in composer draft store696cbcc
  • Cover runtime undefined draft thread options in store test7c0b8be
  • Run setup script after promoting local draft threads822d8df
  • Refactor draft thread lookups and cache persisted draft statea74511e
  • Defer thread creation until first message sendac39b6f
  • Clean up orphaned composer drafts and harden draft checksbd43372
  • Persist per-thread composer drafts and reuse project draft threadscadef14
  • Address PR review feedback for draft model/error/persistence handlingec18318
  • Show branch toolbar for local draft threads4b4e3b1
  • Preserve draft env mode across thread and branch context updates9e99b8e
CcursorBotreviewed Β· 1 inline threadMar 2, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • βœ… Fixed: BranchToolbar errors silently dropped for draft threads
    • Added an onError callback prop to BranchToolbar and updated setThreadError to route errors through it for draft threads, matching how ChatView already handles draft thread errors via setLocalDraftErrorsByThreadId.

Create PR

Or push these changes by commenting:

1@cursor push d497cd72f7
Preview (pingdotgg/t3code@d497cd72f7)
diff
1diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
2--- a/apps/web/src/components/BranchToolbar.tsx
3+++ b/apps/web/src/components/BranchToolbar.tsx
4@@ -34,6 +34,7 @@
5 onEnvModeChange: (mode: "local" | "worktree") => void;
6 envLocked: boolean;
7 onComposerFocusRequest?: () => void;
8+ onError?: (error: string | null) => void;
9 }
10
11 export default function BranchToolbar({
12@@ -42,6 +43,7 @@
13 onEnvModeChange,
14 envLocked,
15 onComposerFocusRequest,
16+ onError,
17 }: BranchToolbarProps) {
18 const { state, dispatch } = useStore();
19 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
20@@ -147,7 +149,11 @@
21
22 const setThreadError = (error: string | null) => {
23 if (!activeThreadId) return;
24- dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });
25+ if (hasServerThread) {
26+ dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });
27+ } else {
28+ onError?.(error);
29+ }
30 };
31
32 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
33
34diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
35--- a/apps/web/src/components/ChatView.tsx
36+++ b/apps/web/src/components/ChatView.tsx
37@@ -2463,6 +2463,7 @@
38 onEnvModeChange={onEnvModeChange}
39 envLocked={envLocked}
40 onComposerFocusRequest={scheduleComposerFocus}
41+ onError={(error) => setThreadError(activeThread.id, error)}
42 />
43 )}
apps/web/src/components/BranchToolbar.tsxline 152 (old)Outdated Β· 10660f3Open thread
……@@ -149,15 +175,23 @@ export default function BranchToolbar({
149175 worktreePath,
150176 });
151177 }
152- dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
CcursorBoton line 152 (old)Mar 2, 2026

BranchToolbar errors silently dropped for draft threads

Medium Severity

The setThreadError helper in BranchToolbar directly calls dispatch({ type: "SET_ERROR", ... }) without handling local draft threads. Since draft threads don't exist in state.threads, the reducer's updateThread silently no-ops. Any branch checkout or creation errors (e.g., from checkoutMutation or createBranchAndCheckoutMutation onError callbacks) are silently lost when the active thread is a local draft, leaving the user with no error feedback. Unlike ChatView, this component was not updated to route errors to the local draft error state.

Fix in CursorΒ Fix in Web