authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 01:23:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
log122c94a24acf31d72097faad3e943e939e7fff91
treefbb4dc05faabf92e9a95f76560b344945a7b0b58
parentb59c6d6ab1b71295bf8c77e611522989e1e97fc8

Defer thread creation until first message send

- Track draft thread metadata in composer draft store - Reuse and update local draft threads from Sidebar/ChatView - Create server thread only when sending first message, with rollback on failure - Add/expand draft-thread store tests for mapping and context updates

5 files changed, 472 insertions(+), 167 deletions(-)

apps/web/src/components/ChatView.tsx+116-36
......@@ -53,6 +53,7 @@ import {
5353 DEFAULT_THREAD_TERMINAL_ID,
5454 MAX_THREAD_TERMINAL_COUNT,
5555 type ChatMessage,
56 type Thread,
5657 type TurnDiffSummary,
5758} from "../types";
5859import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
......@@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings";
113114import {
114115 COMPOSER_DRAFT_STORAGE_KEY,
115116 type ComposerImageAttachment,
117 type DraftThreadState,
116118 type PersistedComposerImageAttachment,
117119 readPersistedComposerDraftAttachments,
118120 useComposerDraftStore,
......@@ -195,6 +197,42 @@ function buildExpandedImagePreview(
195197 };
196198}
197199
200function buildLocalDraftThread(
201 threadId: ThreadId,
202 draftThread: DraftThreadState,
203 fallbackModel: string,
204): Thread {
205 return {
206 id: threadId,
207 codexThreadId: null,
208 projectId: draftThread.projectId,
209 title: "New thread",
210 model: fallbackModel,
211 terminalOpen: false,
212 terminalHeight: 280,
213 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
214 runningTerminalIds: [],
215 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
216 terminalGroups: [
217 {
218 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
219 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
220 },
221 ],
222 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
223 session: null,
224 messages: [],
225 error: null,
226 createdAt: draftThread.createdAt,
227 latestTurn: null,
228 lastVisitedAt: draftThread.createdAt,
229 branch: draftThread.branch,
230 worktreePath: draftThread.worktreePath,
231 turnDiffSummaries: [],
232 activities: [],
233 };
234}
235
198236type ComposerCommandItem =
199237 | {
200238 id: string;
......@@ -428,9 +466,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
428466 (store) => store.syncPersistedAttachments,
429467 );
430468 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
431 const clearProjectDraftThreadById = useComposerDraftStore(
432 (store) => store.clearProjectDraftThreadById,
433 );
469 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
470 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);
434471 const promptRef = useRef(prompt);
435472 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
436473 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
......@@ -488,7 +525,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
488525 [removeComposerDraftImage, threadId],
489526 );
490527
491 const activeThread = state.threads.find((t) => t.id === threadId);
528 const serverThread = state.threads.find((t) => t.id === threadId);
529 const fallbackDraftProject = state.projects.find((project) => project.id === draftThread?.projectId);
530 const localDraftThread = useMemo(
531 () =>
532 draftThread
533 ? buildLocalDraftThread(threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL)
534 : undefined,
535 [draftThread, fallbackDraftProject?.model, threadId],
536 );
537 const activeThread = serverThread ?? localDraftThread;
538 const isServerThread = serverThread !== undefined;
539 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
492540 const diffSearch = useMemo(
493541 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
494542 [rawSearch],
......@@ -876,7 +924,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
876924 },
877925 ) => {
878926 const api = readNativeApi();
879 if (!api || !activeThreadId || !activeProject || !activeThread) return;
927 if (!api || !activeThreadId || !activeProject || !activeThread || !isServerThread) return;
880928 if (options?.rememberAsLastInvoked !== false) {
881929 setLastInvokedScriptByProjectId((current) => {
882930 if (current[activeProject.id] === script.id) return current;
......@@ -948,7 +996,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
948996 });
949997 }
950998 },
951 [activeProject, activeThread, activeThreadId, dispatch, gitCwd],
999 [activeProject, activeThread, activeThreadId, dispatch, gitCwd, isServerThread],
9521000 );
9531001 const persistProjectScripts = useCallback(
9541002 async (input: {
......@@ -1634,7 +1682,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16341682 if (!trimmed && composerImages.length === 0) return;
16351683 if (!activeProject) return;
16361684 const threadIdForSend = activeThread.id;
1637 const isFirstMessage = activeThread.messages.length === 0;
1685 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
16381686 const baseBranchForWorktree =
16391687 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
16401688 ? activeThread.branch
......@@ -1693,7 +1741,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
16931741 clearComposerDraftContent(threadIdForSend);
16941742 setComposerHighlightedItemId(null);
16951743
1696 let attemptedTurnStart = false;
1744 let createdServerThreadForLocalDraft = false;
1745 let turnStartSucceeded = false;
1746 let nextThreadBranch = activeThread.branch;
1747 let nextThreadWorktreePath = activeThread.worktreePath;
16971748 try {
16981749 // On first message: lock in branch + create worktree if needed.
16991750 if (baseBranchForWorktree) {
......@@ -1704,23 +1755,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
17041755 branch: baseBranchForWorktree,
17051756 newBranch,
17061757 });
1707 await api.orchestration.dispatchCommand({
1708 type: "thread.meta.update",
1709 commandId: newCommandId(),
1710 threadId: threadIdForSend,
1711 branch: result.worktree.branch,
1712 worktreePath: result.worktree.path,
1713 });
1714 // Keep local thread state in sync immediately so terminal drawer opens
1715 // with the worktree cwd/env instead of briefly using the project root.
1716 dispatch({
1717 type: "SET_THREAD_BRANCH",
1718 threadId: threadIdForSend,
1719 branch: result.worktree.branch,
1720 worktreePath: result.worktree.path,
1721 });
1758 nextThreadBranch = result.worktree.branch;
1759 nextThreadWorktreePath = result.worktree.path;
1760 if (isServerThread) {
1761 await api.orchestration.dispatchCommand({
1762 type: "thread.meta.update",
1763 commandId: newCommandId(),
1764 threadId: threadIdForSend,
1765 branch: result.worktree.branch,
1766 worktreePath: result.worktree.path,
1767 });
1768 // Keep local thread state in sync immediately so terminal drawer opens
1769 // with the worktree cwd/env instead of briefly using the project root.
1770 dispatch({
1771 type: "SET_THREAD_BRANCH",
1772 threadId: threadIdForSend,
1773 branch: result.worktree.branch,
1774 worktreePath: result.worktree.path,
1775 });
1776 }
17221777 const setupScript = setupProjectScript(activeProject.scripts);
1723 if (setupScript) {
1778 if (setupScript && isServerThread) {
17241779 await runProjectScript(setupScript, {
17251780 cwd: result.worktree.path,
17261781 worktreePath: result.worktree.path,
......@@ -1729,14 +1784,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
17291784 }
17301785 }
17311786
1787 const titleSeed =
1788 trimmed ||
1789 (composerImagesSnapshot.length > 0
1790 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1791 : "New thread");
1792 const title = truncateTitle(titleSeed);
1793
1794 if (isLocalDraftThread) {
1795 await api.orchestration.dispatchCommand({
1796 type: "thread.create",
1797 commandId: newCommandId(),
1798 threadId: threadIdForSend,
1799 projectId: activeProject.id,
1800 title,
1801 model: selectedModel ?? activeProject.model ?? DEFAULT_MODEL,
1802 branch: nextThreadBranch,
1803 worktreePath: nextThreadWorktreePath,
1804 createdAt: activeThread.createdAt,
1805 });
1806 createdServerThreadForLocalDraft = true;
1807 }
1808
17321809 // Auto-title from first message
1733 if (isFirstMessage) {
1734 const titleSeed =
1735 trimmed ||
1736 (composerImagesSnapshot.length > 0
1737 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1738 : "New thread");
1739 const title = truncateTitle(titleSeed);
1810 if (isFirstMessage && isServerThread) {
17401811 await api.orchestration.dispatchCommand({
17411812 type: "thread.meta.update",
17421813 commandId: newCommandId(),
......@@ -1747,7 +1818,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
17471818
17481819 setSendPhase("sending-turn");
17491820 const turnAttachments = await turnAttachmentsPromise;
1750 attemptedTurnStart = true;
17511821 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
17521822 const sandboxMode =
17531823 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
......@@ -1768,12 +1838,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
17681838 sandboxMode,
17691839 createdAt: messageCreatedAt,
17701840 });
1841 turnStartSucceeded = true;
17711842 if (isFirstMessage) {
1772 clearProjectDraftThreadById(activeProject.id, threadIdForSend);
1843 clearDraftThread(threadIdForSend);
17731844 }
17741845 } catch (err) {
1846 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
1847 await api.orchestration
1848 .dispatchCommand({
1849 type: "thread.delete",
1850 commandId: newCommandId(),
1851 threadId: threadIdForSend,
1852 })
1853 .catch(() => undefined);
1854 }
17751855 if (
1776 !attemptedTurnStart &&
1856 !turnStartSucceeded &&
17771857 promptRef.current.length === 0 &&
17781858 composerImagesRef.current.length === 0
17791859 ) {
......@@ -1841,7 +1921,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18411921 const normalizedModel = resolveModelSlug(model);
18421922 setComposerDraftModel(threadId, normalizedModel);
18431923 const api = readNativeApi();
1844 if (api && activeThread) {
1924 if (api && isServerThread && activeThread) {
18451925 void api.orchestration.dispatchCommand({
18461926 type: "thread.meta.update",
18471927 commandId: newCommandId(),
......@@ -1851,7 +1931,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18511931 }
18521932 scheduleComposerFocus();
18531933 },
1854 [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId],
1934 [activeThread, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId],
18551935 );
18561936 const onEffortSelect = useCallback(
18571937 (effort: ReasoningEffort) => {
apps/web/src/components/Sidebar.tsx+43-104
......@@ -75,10 +75,6 @@ interface PrStatusIndicator {
7575
7676type ThreadPr = GitStatusResult["pr"];
7777
78function shouldShowThreadInSidebar(thread: Thread): boolean {
79 return thread.messages.some((message) => message.role === "user");
80}
81
8278function hasUnseenCompletion(thread: Thread): boolean {
8379 if (!thread.latestTurn?.completedAt) return false;
8480 const completedAt = Date.parse(thread.latestTurn.completedAt);
......@@ -234,8 +230,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
234230export default function Sidebar() {
235231 const { state, dispatch } = useStore();
236232 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
233 const readDraftThread = useComposerDraftStore((store) => store.readDraftThread);
237234 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);
238235 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
236 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
239237 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);
240238 const clearProjectDraftThreadById = useComposerDraftStore(
241239 (store) => store.clearProjectDraftThreadById,
......@@ -346,55 +344,25 @@ export default function Sidebar() {
346344 worktreePath?: string | null;
347345 },
348346 ): Promise<void> => {
349 const api = readNativeApi();
350 if (!api) return Promise.resolve();
351347 const hasBranchOption = options?.branch !== undefined;
352348 const hasWorktreePathOption = options?.worktreePath !== undefined;
353 const applyThreadOptions = async (thread: Thread): Promise<void> => {
354 if (!hasBranchOption && !hasWorktreePathOption) {
355 return;
356 }
357 const nextBranch = hasBranchOption ? (options?.branch ?? null) : thread.branch;
358 const nextWorktreePath = hasWorktreePathOption
359 ? (options?.worktreePath ?? null)
360 : thread.worktreePath;
361 if (thread.branch === nextBranch && thread.worktreePath === nextWorktreePath) {
362 return;
363 }
364 await api.orchestration.dispatchCommand({
365 type: "thread.meta.update",
366 commandId: newCommandId(),
367 threadId: thread.id,
368 ...(hasBranchOption ? { branch: nextBranch } : {}),
369 ...(hasWorktreePathOption ? { worktreePath: nextWorktreePath } : {}),
370 });
371 dispatch({
372 type: "SET_THREAD_BRANCH",
373 threadId: thread.id,
374 branch: nextBranch,
375 worktreePath: nextWorktreePath,
376 });
377 };
378349 const storedDraftThreadId = readProjectDraftThreadId(projectId);
379 const storedDraftThread = storedDraftThreadId
380 ? state.threads.find((thread) => thread.id === storedDraftThreadId)
381 : undefined;
382 const reusableStoredDraftThread =
383 storedDraftThread &&
384 storedDraftThread.projectId === projectId &&
385 !shouldShowThreadInSidebar(storedDraftThread)
386 ? storedDraftThread
387 : null;
388 if (reusableStoredDraftThread) {
350 const storedDraftThread = storedDraftThreadId ? readDraftThread(storedDraftThreadId) : null;
351 if (storedDraftThread && storedDraftThread.projectId === projectId && storedDraftThreadId) {
389352 return (async () => {
390 await applyThreadOptions(reusableStoredDraftThread);
391 setProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
392 if (routeThreadId === reusableStoredDraftThread.id) {
353 if (hasBranchOption || hasWorktreePathOption) {
354 setDraftThreadContext(storedDraftThreadId, {
355 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
356 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
357 });
358 }
359 setProjectDraftThreadId(projectId, storedDraftThreadId);
360 if (routeThreadId === storedDraftThreadId) {
393361 return;
394362 }
395363 await navigate({
396364 to: "/$threadId",
397 params: { threadId: reusableStoredDraftThread.id },
365 params: { threadId: storedDraftThreadId },
398366 });
399367 })();
400368 }
......@@ -402,53 +370,25 @@ export default function Sidebar() {
402370 clearProjectDraftThreadId(projectId);
403371 }
404372
405 const activeThread = routeThreadId
406 ? state.threads.find((thread) => thread.id === routeThreadId)
407 : undefined;
408 const reusableDraftThread =
409 activeThread &&
410 activeThread.projectId === projectId &&
411 !shouldShowThreadInSidebar(activeThread)
412 ? activeThread
413 : null;
414 if (reusableDraftThread) {
415 return (async () => {
416 await applyThreadOptions(reusableDraftThread);
417 setProjectDraftThreadId(projectId, reusableDraftThread.id);
418 if (routeThreadId === reusableDraftThread.id) {
419 return;
420 }
421 await navigate({
422 to: "/$threadId",
423 params: { threadId: reusableDraftThread.id },
373 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
375 if (hasBranchOption || hasWorktreePathOption) {
376 setDraftThreadContext(routeThreadId, {
377 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
378 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
424379 });
425 })();
380 }
381 setProjectDraftThreadId(projectId, routeThreadId);
382 return Promise.resolve();
426383 }
427384 const threadId = newThreadId();
428385 const createdAt = new Date().toISOString();
429 const model =
430 state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
431386 return (async () => {
432 await api.orchestration.dispatchCommand({
433 type: "thread.create",
434 commandId: newCommandId(),
435 threadId,
436 projectId,
437 title: "New thread",
438 model,
387 setProjectDraftThreadId(projectId, threadId, {
388 createdAt,
439389 branch: options?.branch ?? null,
440390 worktreePath: options?.worktreePath ?? null,
441 createdAt,
442391 });
443 setProjectDraftThreadId(projectId, threadId);
444
445 // Ensure route guards can see the new thread before navigating.
446 try {
447 const snapshot = await api.orchestration.getSnapshot();
448 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
449 } catch {
450 // Event stream can still hydrate the thread shortly after dispatch.
451 }
452392
453393 await navigate({
454394 to: "/$threadId",
......@@ -458,13 +398,12 @@ export default function Sidebar() {
458398 },
459399 [
460400 clearProjectDraftThreadId,
461 dispatch,
462401 navigate,
402 readDraftThread,
463403 readProjectDraftThreadId,
464404 routeThreadId,
405 setDraftThreadContext,
465406 setProjectDraftThreadId,
466 state.projects,
467 state.threads,
468407 ],
469408 );
470409
......@@ -678,9 +617,7 @@ export default function Sidebar() {
678617 if (!project) return;
679618
680619 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
681 const visibleThreads = projectThreads.filter(shouldShowThreadInSidebar);
682 const draftThreads = projectThreads.filter((thread) => !shouldShowThreadInSidebar(thread));
683 if (visibleThreads.length > 0) {
620 if (projectThreads.length > 0) {
684621 toastManager.add({
685622 type: "warning",
686623 title: "Project is not empty",
......@@ -695,13 +632,9 @@ export default function Sidebar() {
695632 if (!confirmed) return;
696633
697634 try {
698 for (const draftThread of draftThreads) {
699 await api.orchestration.dispatchCommand({
700 type: "thread.delete",
701 commandId: newCommandId(),
702 threadId: draftThread.id,
703 });
704 clearComposerDraftForThread(draftThread.id);
635 const projectDraftThreadId = readProjectDraftThreadId(projectId);
636 if (projectDraftThreadId) {
637 clearComposerDraftForThread(projectDraftThreadId);
705638 }
706639 clearProjectDraftThreadId(projectId);
707640 await api.orchestration.dispatchCommand({
......@@ -719,7 +652,13 @@ export default function Sidebar() {
719652 });
720653 }
721654 },
722 [clearComposerDraftForThread, clearProjectDraftThreadId, state.projects, state.threads],
655 [
656 clearComposerDraftForThread,
657 clearProjectDraftThreadId,
658 readProjectDraftThreadId,
659 state.projects,
660 state.threads,
661 ],
723662 );
724663
725664 useEffect(() => {
......@@ -727,8 +666,9 @@ export default function Sidebar() {
727666 const activeThread = routeThreadId
728667 ? state.threads.find((thread) => thread.id === routeThreadId)
729668 : undefined;
669 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
730670 if (isChatNewLocalShortcut(event, keybindings)) {
731 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
671 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
732672 if (!projectId) return;
733673 event.preventDefault();
734674 void handleNewThread(projectId);
......@@ -736,12 +676,12 @@ export default function Sidebar() {
736676 }
737677
738678 if (!isChatNewShortcut(event, keybindings)) return;
739 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
679 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
740680 if (!projectId) return;
741681 event.preventDefault();
742682 void handleNewThread(projectId, {
743 branch: activeThread?.branch ?? null,
744 worktreePath: activeThread?.worktreePath ?? null,
683 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
684 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
745685 });
746686 };
747687
......@@ -749,7 +689,7 @@ export default function Sidebar() {
749689 return () => {
750690 window.removeEventListener("keydown", onWindowKeyDown);
751691 };
752 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
692 }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]);
753693
754694 const onCreateThreadClick = () => {
755695 if (state.projects.length === 0) {
......@@ -808,7 +748,6 @@ export default function Sidebar() {
808748 {state.projects.map((project) => {
809749 const visibleThreads = state.threads
810750 .filter((thread) => thread.projectId === project.id)
811 .filter(shouldShowThreadInSidebar)
812751 .toSorted((a, b) => {
813752 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
814753 if (byDate !== 0) return byDate;
apps/web/src/composerDraftStore.test.ts+55-4
......@@ -33,7 +33,11 @@ describe("composerDraftStore addImages", () => {
3333 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
3434
3535 beforeEach(() => {
36 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
36 useComposerDraftStore.setState({
37 draftsByThreadId: {},
38 draftThreadsByThreadId: {},
39 projectDraftThreadIdByProjectId: {},
40 });
3741 originalRevokeObjectUrl = URL.revokeObjectURL;
3842 revokeSpy = vi.fn();
3943 URL.revokeObjectURL = revokeSpy;
......@@ -118,7 +122,11 @@ describe("composerDraftStore clearComposerContent", () => {
118122 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
119123
120124 beforeEach(() => {
121 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
125 useComposerDraftStore.setState({
126 draftsByThreadId: {},
127 draftThreadsByThreadId: {},
128 projectDraftThreadIdByProjectId: {},
129 });
122130 originalRevokeObjectUrl = URL.revokeObjectURL;
123131 revokeSpy = vi.fn();
124132 URL.revokeObjectURL = revokeSpy;
......@@ -149,15 +157,30 @@ describe("composerDraftStore project draft thread mapping", () => {
149157 const otherThreadId = ThreadId.makeUnsafe("thread-b");
150158
151159 beforeEach(() => {
152 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
160 useComposerDraftStore.setState({
161 draftsByThreadId: {},
162 draftThreadsByThreadId: {},
163 projectDraftThreadIdByProjectId: {},
164 });
153165 });
154166
155167 it("stores and reads project draft thread ids via actions", () => {
156168 const store = useComposerDraftStore.getState();
157169 expect(store.readProjectDraftThreadId(projectId)).toBeNull();
170 expect(store.readDraftThread(threadId)).toBeNull();
158171
159 store.setProjectDraftThreadId(projectId, threadId);
172 store.setProjectDraftThreadId(projectId, threadId, {
173 branch: "feature/test",
174 worktreePath: "/tmp/worktree-test",
175 createdAt: "2026-01-01T00:00:00.000Z",
176 });
160177 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
178 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toEqual({
179 projectId,
180 branch: "feature/test",
181 worktreePath: "/tmp/worktree-test",
182 createdAt: "2026-01-01T00:00:00.000Z",
183 });
161184 });
162185
163186 it("clears only matching project draft mapping entries", () => {
......@@ -169,6 +192,7 @@ describe("composerDraftStore project draft thread mapping", () => {
169192
170193 store.clearProjectDraftThreadById(projectId, threadId);
171194 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
195 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
172196 });
173197
174198 it("clears project draft mapping by project id", () => {
......@@ -176,5 +200,32 @@ describe("composerDraftStore project draft thread mapping", () => {
176200 store.setProjectDraftThreadId(projectId, threadId);
177201 store.clearProjectDraftThreadId(projectId);
178202 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
203 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
204 });
205
206 it("clears draft registration independently", () => {
207 const store = useComposerDraftStore.getState();
208 store.setProjectDraftThreadId(projectId, threadId);
209 store.clearDraftThread(threadId);
210 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
211 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
212 });
213
214 it("updates branch context on an existing draft thread", () => {
215 const store = useComposerDraftStore.getState();
216 store.setProjectDraftThreadId(projectId, threadId, {
217 branch: "main",
218 worktreePath: null,
219 });
220 store.setDraftThreadContext(threadId, {
221 branch: "feature/next",
222 worktreePath: "/tmp/feature-next",
223 });
224 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
225 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toMatchObject({
226 projectId,
227 branch: "feature/next",
228 worktreePath: "/tmp/feature-next",
229 });
179230 });
180231});
apps/web/src/composerDraftStore.ts+250-20
......@@ -66,8 +66,16 @@ interface PersistedComposerThreadDraftState {
6666 effort?: ReasoningEffort | null;
6767}
6868
69interface PersistedDraftThreadState {
70 projectId: ProjectId;
71 createdAt: string;
72 branch: string | null;
73 worktreePath: string | null;
74}
75
6976interface PersistedComposerDraftStoreState {
7077 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
78 draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>;
7179 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
7280}
7381
......@@ -81,13 +89,41 @@ export interface ComposerThreadDraftState {
8189 effort: ReasoningEffort | null;
8290}
8391
92export interface DraftThreadState {
93 projectId: ProjectId;
94 createdAt: string;
95 branch: string | null;
96 worktreePath: string | null;
97}
98
8499interface ComposerDraftStoreState {
85100 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
101 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
86102 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
103 readDraftThread: (threadId: ThreadId) => DraftThreadState | null;
104 isDraftThread: (threadId: ThreadId) => boolean;
87105 readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null;
88 setProjectDraftThreadId: (projectId: ProjectId, threadId: ThreadId) => void;
106 setProjectDraftThreadId: (
107 projectId: ProjectId,
108 threadId: ThreadId,
109 options?: {
110 branch?: string | null;
111 worktreePath?: string | null;
112 createdAt?: string;
113 },
114 ) => void;
115 setDraftThreadContext: (
116 threadId: ThreadId,
117 options: {
118 branch?: string | null;
119 worktreePath?: string | null;
120 projectId?: ProjectId;
121 createdAt?: string;
122 },
123 ) => void;
89124 clearProjectDraftThreadId: (projectId: ProjectId) => void;
90125 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
126 clearDraftThread: (threadId: ThreadId) => void;
91127 setPrompt: (threadId: ThreadId, prompt: string) => void;
92128 setCursor: (threadId: ThreadId, cursor: number) => void;
93129 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
......@@ -187,11 +223,39 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
187223
188224function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
189225 if (!value || typeof value !== "object") {
190 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
226 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
191227 }
192228 const candidate = value as Record<string, unknown>;
193229 const rawDraftMap = candidate.draftsByThreadId;
230 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
194231 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
232 const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {};
233 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
234 for (const [threadId, rawDraftThread] of Object.entries(
235 rawDraftThreadsByThreadId as Record<string, unknown>,
236 )) {
237 if (typeof threadId !== "string" || threadId.length === 0) {
238 continue;
239 }
240 if (!rawDraftThread || typeof rawDraftThread !== "object") {
241 continue;
242 }
243 const candidateDraftThread = rawDraftThread as Record<string, unknown>;
244 const projectId = candidateDraftThread.projectId;
245 const createdAt = candidateDraftThread.createdAt;
246 const branch = candidateDraftThread.branch;
247 const worktreePath = candidateDraftThread.worktreePath;
248 if (typeof projectId !== "string" || projectId.length === 0) {
249 continue;
250 }
251 draftThreadsByThreadId[threadId as ThreadId] = {
252 projectId: projectId as ProjectId,
253 createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(),
254 branch: typeof branch === "string" ? branch : null,
255 worktreePath: typeof worktreePath === "string" ? worktreePath : null,
256 };
257 }
258 }
195259 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
196260 {};
197261 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {
......@@ -205,11 +269,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
205269 threadId.length > 0
206270 ) {
207271 projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;
272 if (!draftThreadsByThreadId[threadId as ThreadId]) {
273 draftThreadsByThreadId[threadId as ThreadId] = {
274 projectId: projectId as ProjectId,
275 createdAt: new Date().toISOString(),
276 branch: null,
277 worktreePath: null,
278 };
279 } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) {
280 draftThreadsByThreadId[threadId as ThreadId] = {
281 ...draftThreadsByThreadId[threadId as ThreadId]!,
282 projectId: projectId as ProjectId,
283 };
284 }
208285 }
209286 }
210287 }
211288 if (!rawDraftMap || typeof rawDraftMap !== "object") {
212 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId };
289 return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
213290 }
214291 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
215292 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
......@@ -250,7 +327,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
250327 ...(effort ? { effort } : {}),
251328 };
252329 }
253 return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId };
330 return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
254331}
255332
256333function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
......@@ -304,12 +381,12 @@ function hydrateImagesFromPersisted(
304381
305382function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
306383 if (typeof window === "undefined") {
307 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
384 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
308385 }
309386 try {
310387 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
311388 if (!raw) {
312 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
389 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
313390 }
314391 const parsed = JSON.parse(raw) as unknown;
315392 if (parsed && typeof parsed === "object" && "state" in parsed) {
......@@ -317,7 +394,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS
317394 }
318395 return normalizePersistedComposerDraftState(parsed);
319396 } catch {
320 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
397 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
321398 }
322399}
323400
......@@ -339,26 +416,122 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
339416 persist(
340417 (set, get) => ({
341418 draftsByThreadId: {},
419 draftThreadsByThreadId: {},
342420 projectDraftThreadIdByProjectId: {},
421 readDraftThread: (threadId) => {
422 if (threadId.length === 0) {
423 return null;
424 }
425 return get().draftThreadsByThreadId[threadId] ?? null;
426 },
427 isDraftThread: (threadId) => {
428 if (threadId.length === 0) {
429 return false;
430 }
431 return get().draftThreadsByThreadId[threadId] !== undefined;
432 },
343433 readProjectDraftThreadId: (projectId) => {
344434 if (projectId.length === 0) {
345435 return null;
346436 }
347437 return get().projectDraftThreadIdByProjectId[projectId] ?? null;
348438 },
349 setProjectDraftThreadId: (projectId, threadId) => {
439 setProjectDraftThreadId: (projectId, threadId, options) => {
350440 if (projectId.length === 0 || threadId.length === 0) {
351441 return;
352442 }
353443 set((state) => {
354 if (state.projectDraftThreadIdByProjectId[projectId] === threadId) {
444 const existingThread = state.draftThreadsByThreadId[threadId];
445 const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId];
446 const nextDraftThread: DraftThreadState = {
447 projectId,
448 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
449 branch:
450 options && "branch" in options
451 ? (options.branch ?? null)
452 : (existingThread?.branch ?? null),
453 worktreePath:
454 options && "worktreePath" in options
455 ? (options.worktreePath ?? null)
456 : (existingThread?.worktreePath ?? null),
457 };
458 const hasSameProjectMapping = previousThreadIdForProject === threadId;
459 const hasSameDraftThread =
460 existingThread &&
461 existingThread.projectId === nextDraftThread.projectId &&
462 existingThread.createdAt === nextDraftThread.createdAt &&
463 existingThread.branch === nextDraftThread.branch &&
464 existingThread.worktreePath === nextDraftThread.worktreePath;
465 if (hasSameProjectMapping && hasSameDraftThread) {
466 return state;
467 }
468 const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
469 ...state.projectDraftThreadIdByProjectId,
470 [projectId]: threadId,
471 };
472 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
473 ...state.draftThreadsByThreadId,
474 [threadId]: nextDraftThread,
475 };
476 if (
477 previousThreadIdForProject &&
478 previousThreadIdForProject !== threadId &&
479 !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
480 ) {
481 delete nextDraftThreadsByThreadId[previousThreadIdForProject];
482 }
483 return {
484 draftThreadsByThreadId: nextDraftThreadsByThreadId,
485 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
486 };
487 });
488 },
489 setDraftThreadContext: (threadId, options) => {
490 if (threadId.length === 0) {
491 return;
492 }
493 set((state) => {
494 const existing = state.draftThreadsByThreadId[threadId];
495 if (!existing) {
496 return state;
497 }
498 const nextProjectId = options.projectId ?? existing.projectId;
499 if (nextProjectId.length === 0) {
355500 return state;
356501 }
502 const nextDraftThread: DraftThreadState = {
503 projectId: nextProjectId,
504 createdAt:
505 options.createdAt === undefined ? existing.createdAt : (options.createdAt || existing.createdAt),
506 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
507 worktreePath:
508 options.worktreePath === undefined
509 ? existing.worktreePath
510 : (options.worktreePath ?? null),
511 };
512 const isUnchanged =
513 nextDraftThread.projectId === existing.projectId &&
514 nextDraftThread.createdAt === existing.createdAt &&
515 nextDraftThread.branch === existing.branch &&
516 nextDraftThread.worktreePath === existing.worktreePath;
517 if (isUnchanged) {
518 return state;
519 }
520 const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {
521 ...state.projectDraftThreadIdByProjectId,
522 [nextProjectId]: threadId,
523 };
524 if (existing.projectId !== nextProjectId) {
525 if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) {
526 delete nextProjectDraftThreadIdByProjectId[existing.projectId];
527 }
528 }
357529 return {
358 projectDraftThreadIdByProjectId: {
359 ...state.projectDraftThreadIdByProjectId,
360 [projectId]: threadId,
530 draftThreadsByThreadId: {
531 ...state.draftThreadsByThreadId,
532 [threadId]: nextDraftThread,
361533 },
534 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
362535 };
363536 });
364537 },
......@@ -367,12 +540,21 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
367540 return;
368541 }
369542 set((state) => {
370 if (state.projectDraftThreadIdByProjectId[projectId] === undefined) {
543 const threadId = state.projectDraftThreadIdByProjectId[projectId];
544 if (threadId === undefined) {
371545 return state;
372546 }
373 const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId;
547 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;
548 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
549 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
550 ...state.draftThreadsByThreadId,
551 };
552 if (!Object.values(restProjectMappings).includes(threadId)) {
553 delete nextDraftThreadsByThreadId[threadId];
554 }
374555 return {
375 projectDraftThreadIdByProjectId: rest,
556 draftThreadsByThreadId: nextDraftThreadsByThreadId,
557 projectDraftThreadIdByProjectId: restProjectMappings,
376558 };
377559 });
378560 },
......@@ -384,9 +566,40 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
384566 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
385567 return state;
386568 }
387 const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId;
569 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;
570 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
571 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
572 ...state.draftThreadsByThreadId,
573 };
574 if (!Object.values(restProjectMappings).includes(threadId)) {
575 delete nextDraftThreadsByThreadId[threadId];
576 }
577 return {
578 draftThreadsByThreadId: nextDraftThreadsByThreadId,
579 projectDraftThreadIdByProjectId: restProjectMappings,
580 };
581 });
582 },
583 clearDraftThread: (threadId) => {
584 if (threadId.length === 0) {
585 return;
586 }
587 set((state) => {
588 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
589 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId);
590 if (!hasDraftThread && !hasProjectMapping) {
591 return state;
592 }
593 const nextProjectDraftThreadIdByProjectId = Object.fromEntries(
594 Object.entries(state.projectDraftThreadIdByProjectId).filter(
595 ([, draftThreadId]) => draftThreadId !== threadId,
596 ),
597 ) as Record<ProjectId, ThreadId>;
598 const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
599 state.draftThreadsByThreadId;
388600 return {
389 projectDraftThreadIdByProjectId: rest,
601 draftThreadsByThreadId: restDraftThreadsByThreadId,
602 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
390603 };
391604 });
392605 },
......@@ -688,11 +901,26 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
688901 }
689902 }
690903 set((state) => {
691 if (state.draftsByThreadId[threadId] === undefined) {
904 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
905 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
906 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId);
907 if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) {
692908 return state;
693909 }
694 const { [threadId]: _removed, ...rest } = state.draftsByThreadId;
695 return { draftsByThreadId: rest };
910 const { [threadId]: _removedComposerDraft, ...restComposerDraftsByThreadId } =
911 state.draftsByThreadId;
912 const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } =
913 state.draftThreadsByThreadId;
914 const nextProjectDraftThreadIdByProjectId = Object.fromEntries(
915 Object.entries(state.projectDraftThreadIdByProjectId).filter(
916 ([, draftThreadId]) => draftThreadId !== threadId,
917 ),
918 ) as Record<ProjectId, ThreadId>;
919 return {
920 draftsByThreadId: restComposerDraftsByThreadId,
921 draftThreadsByThreadId: restDraftThreadsByThreadId,
922 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
923 };
696924 });
697925 },
698926 }),
......@@ -729,6 +957,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
729957 }
730958 return {
731959 draftsByThreadId: persistedDraftsByThreadId,
960 draftThreadsByThreadId: state.draftThreadsByThreadId,
732961 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
733962 };
734963 },
......@@ -743,6 +972,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
743972 return {
744973 ...currentState,
745974 draftsByThreadId,
975 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
746976 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
747977 };
748978 },
apps/web/src/routes/_chat.$threadId.tsx+8-3
......@@ -3,6 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
33import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
44
55import ChatView from "../components/ChatView";
6import { useComposerDraftStore } from "../composerDraftStore";
67import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
78import { useMediaQuery } from "../hooks/useMediaQuery";
89import { useStore } from "../store";
......@@ -139,6 +140,10 @@ function ChatThreadRouteView() {
139140 });
140141 const search = Route.useSearch();
141142 const threadExists = state.threads.some((thread) => thread.id === threadId);
143 const draftThreadExists = useComposerDraftStore(
144 (store) => store.draftThreadsByThreadId[threadId] !== undefined,
145 );
146 const routeThreadExists = threadExists || draftThreadExists;
142147 const diffOpen = search.diff === "1";
143148 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
144149 const closeDiff = useCallback(() => {
......@@ -166,13 +171,13 @@ function ChatThreadRouteView() {
166171 return;
167172 }
168173
169 if (!threadExists) {
174 if (!routeThreadExists) {
170175 void navigate({ to: "/", replace: true });
171176 return;
172177 }
173 }, [navigate, state.threadsHydrated, threadExists, threadId]);
178 }, [navigate, routeThreadExists, state.threadsHydrated, threadId]);
174179
175 if (!state.threadsHydrated || !threadExists) {
180 if (!state.threadsHydrated || !routeThreadExists) {
176181 return null;
177182 }
178183