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 {...@@ -53,6 +53,7 @@ import {
53 DEFAULT_THREAD_TERMINAL_ID,53 DEFAULT_THREAD_TERMINAL_ID,
54 MAX_THREAD_TERMINAL_COUNT,54 MAX_THREAD_TERMINAL_COUNT,
55 type ChatMessage,55 type ChatMessage,
56 type Thread,
56 type TurnDiffSummary,57 type TurnDiffSummary,
57} from "../types";58} from "../types";
58import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";59import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
...@@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings";...@@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings";
113import {114import {
114 COMPOSER_DRAFT_STORAGE_KEY,115 COMPOSER_DRAFT_STORAGE_KEY,
115 type ComposerImageAttachment,116 type ComposerImageAttachment,
117 type DraftThreadState,
116 type PersistedComposerImageAttachment,118 type PersistedComposerImageAttachment,
117 readPersistedComposerDraftAttachments,119 readPersistedComposerDraftAttachments,
118 useComposerDraftStore,120 useComposerDraftStore,
...@@ -195,6 +197,42 @@ function buildExpandedImagePreview(...@@ -195,6 +197,42 @@ function buildExpandedImagePreview(
195 };197 };
196}198}
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
198type ComposerCommandItem =236type ComposerCommandItem =
199 | {237 | {
200 id: string;238 id: string;
...@@ -428,9 +466,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -428,9 +466,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
428 (store) => store.syncPersistedAttachments,466 (store) => store.syncPersistedAttachments,
429 );467 );
430 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);468 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
431 const clearProjectDraftThreadById = useComposerDraftStore(469 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
432 (store) => store.clearProjectDraftThreadById,470 const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null);
433 );
434 const promptRef = useRef(prompt);471 const promptRef = useRef(prompt);
435 const [isDragOverComposer, setIsDragOverComposer] = useState(false);472 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
436 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);473 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
...@@ -488,7 +525,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -488,7 +525,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
488 [removeComposerDraftImage, threadId],525 [removeComposerDraftImage, threadId],
489 );526 );
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;
492 const diffSearch = useMemo(540 const diffSearch = useMemo(
493 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),541 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
494 [rawSearch],542 [rawSearch],
...@@ -876,7 +924,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -876,7 +924,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
876 },924 },
877 ) => {925 ) => {
878 const api = readNativeApi();926 const api = readNativeApi();
879 if (!api || !activeThreadId || !activeProject || !activeThread) return;927 if (!api || !activeThreadId || !activeProject || !activeThread || !isServerThread) return;
880 if (options?.rememberAsLastInvoked !== false) {928 if (options?.rememberAsLastInvoked !== false) {
881 setLastInvokedScriptByProjectId((current) => {929 setLastInvokedScriptByProjectId((current) => {
882 if (current[activeProject.id] === script.id) return current;930 if (current[activeProject.id] === script.id) return current;
...@@ -948,7 +996,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -948,7 +996,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
948 });996 });
949 }997 }
950 },998 },
951 [activeProject, activeThread, activeThreadId, dispatch, gitCwd],999 [activeProject, activeThread, activeThreadId, dispatch, gitCwd, isServerThread],
952 );1000 );
953 const persistProjectScripts = useCallback(1001 const persistProjectScripts = useCallback(
954 async (input: {1002 async (input: {
...@@ -1634,7 +1682,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1634,7 +1682,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1634 if (!trimmed && composerImages.length === 0) return;1682 if (!trimmed && composerImages.length === 0) return;
1635 if (!activeProject) return;1683 if (!activeProject) return;
1636 const threadIdForSend = activeThread.id;1684 const threadIdForSend = activeThread.id;
1637 const isFirstMessage = activeThread.messages.length === 0;1685 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
1638 const baseBranchForWorktree =1686 const baseBranchForWorktree =
1639 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath1687 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
1640 ? activeThread.branch1688 ? activeThread.branch
...@@ -1693,7 +1741,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1693,7 +1741,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1693 clearComposerDraftContent(threadIdForSend);1741 clearComposerDraftContent(threadIdForSend);
1694 setComposerHighlightedItemId(null);1742 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;
1697 try {1748 try {
1698 // On first message: lock in branch + create worktree if needed.1749 // On first message: lock in branch + create worktree if needed.
1699 if (baseBranchForWorktree) {1750 if (baseBranchForWorktree) {
...@@ -1704,23 +1755,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1704,23 +1755,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
1704 branch: baseBranchForWorktree,1755 branch: baseBranchForWorktree,
1705 newBranch,1756 newBranch,
1706 });1757 });
1707 await api.orchestration.dispatchCommand({1758 nextThreadBranch = result.worktree.branch;
1708 type: "thread.meta.update",1759 nextThreadWorktreePath = result.worktree.path;
1709 commandId: newCommandId(),1760 if (isServerThread) {
1710 threadId: threadIdForSend,1761 await api.orchestration.dispatchCommand({
1711 branch: result.worktree.branch,1762 type: "thread.meta.update",
1712 worktreePath: result.worktree.path,1763 commandId: newCommandId(),
1713 });1764 threadId: threadIdForSend,
1714 // Keep local thread state in sync immediately so terminal drawer opens1765 branch: result.worktree.branch,
1715 // with the worktree cwd/env instead of briefly using the project root.1766 worktreePath: result.worktree.path,
1716 dispatch({1767 });
1717 type: "SET_THREAD_BRANCH",1768 // Keep local thread state in sync immediately so terminal drawer opens
1718 threadId: threadIdForSend,1769 // with the worktree cwd/env instead of briefly using the project root.
1719 branch: result.worktree.branch,1770 dispatch({
1720 worktreePath: result.worktree.path,1771 type: "SET_THREAD_BRANCH",
1721 });1772 threadId: threadIdForSend,
1773 branch: result.worktree.branch,
1774 worktreePath: result.worktree.path,
1775 });
1776 }
1722 const setupScript = setupProjectScript(activeProject.scripts);1777 const setupScript = setupProjectScript(activeProject.scripts);
1723 if (setupScript) {1778 if (setupScript && isServerThread) {
1724 await runProjectScript(setupScript, {1779 await runProjectScript(setupScript, {
1725 cwd: result.worktree.path,1780 cwd: result.worktree.path,
1726 worktreePath: result.worktree.path,1781 worktreePath: result.worktree.path,
...@@ -1729,14 +1784,30 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1729,14 +1784,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
1729 }1784 }
1730 }1785 }
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
1732 // Auto-title from first message1809 // Auto-title from first message
1733 if (isFirstMessage) {1810 if (isFirstMessage && isServerThread) {
1734 const titleSeed =
1735 trimmed ||
1736 (composerImagesSnapshot.length > 0
1737 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
1738 : "New thread");
1739 const title = truncateTitle(titleSeed);
1740 await api.orchestration.dispatchCommand({1811 await api.orchestration.dispatchCommand({
1741 type: "thread.meta.update",1812 type: "thread.meta.update",
1742 commandId: newCommandId(),1813 commandId: newCommandId(),
...@@ -1747,7 +1818,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1747,7 +1818,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
17471818
1748 setSendPhase("sending-turn");1819 setSendPhase("sending-turn");
1749 const turnAttachments = await turnAttachmentsPromise;1820 const turnAttachments = await turnAttachmentsPromise;
1750 attemptedTurnStart = true;
1751 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";1821 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
1752 const sandboxMode =1822 const sandboxMode =
1753 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";1823 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
...@@ -1768,12 +1838,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1768,12 +1838,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
1768 sandboxMode,1838 sandboxMode,
1769 createdAt: messageCreatedAt,1839 createdAt: messageCreatedAt,
1770 });1840 });
1841 turnStartSucceeded = true;
1771 if (isFirstMessage) {1842 if (isFirstMessage) {
1772 clearProjectDraftThreadById(activeProject.id, threadIdForSend);1843 clearDraftThread(threadIdForSend);
1773 }1844 }
1774 } catch (err) {1845 } 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 }
1775 if (1855 if (
1776 !attemptedTurnStart &&1856 !turnStartSucceeded &&
1777 promptRef.current.length === 0 &&1857 promptRef.current.length === 0 &&
1778 composerImagesRef.current.length === 01858 composerImagesRef.current.length === 0
1779 ) {1859 ) {
...@@ -1841,7 +1921,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1841,7 +1921,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1841 const normalizedModel = resolveModelSlug(model);1921 const normalizedModel = resolveModelSlug(model);
1842 setComposerDraftModel(threadId, normalizedModel);1922 setComposerDraftModel(threadId, normalizedModel);
1843 const api = readNativeApi();1923 const api = readNativeApi();
1844 if (api && activeThread) {1924 if (api && isServerThread && activeThread) {
1845 void api.orchestration.dispatchCommand({1925 void api.orchestration.dispatchCommand({
1846 type: "thread.meta.update",1926 type: "thread.meta.update",
1847 commandId: newCommandId(),1927 commandId: newCommandId(),
...@@ -1851,7 +1931,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1851,7 +1931,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1851 }1931 }
1852 scheduleComposerFocus();1932 scheduleComposerFocus();
1853 },1933 },
1854 [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId],1934 [activeThread, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId],
1855 );1935 );
1856 const onEffortSelect = useCallback(1936 const onEffortSelect = useCallback(
1857 (effort: ReasoningEffort) => {1937 (effort: ReasoningEffort) => {
apps/web/src/components/Sidebar.tsx+43-104
...@@ -75,10 +75,6 @@ interface PrStatusIndicator {...@@ -75,10 +75,6 @@ interface PrStatusIndicator {
7575
76type ThreadPr = GitStatusResult["pr"];76type ThreadPr = GitStatusResult["pr"];
7777
78function shouldShowThreadInSidebar(thread: Thread): boolean {
79 return thread.messages.some((message) => message.role === "user");
80}
81
82function hasUnseenCompletion(thread: Thread): boolean {78function hasUnseenCompletion(thread: Thread): boolean {
83 if (!thread.latestTurn?.completedAt) return false;79 if (!thread.latestTurn?.completedAt) return false;
84 const completedAt = Date.parse(thread.latestTurn.completedAt);80 const completedAt = Date.parse(thread.latestTurn.completedAt);
...@@ -234,8 +230,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -234,8 +230,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
234export default function Sidebar() {230export default function Sidebar() {
235 const { state, dispatch } = useStore();231 const { state, dispatch } = useStore();
236 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);232 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
233 const readDraftThread = useComposerDraftStore((store) => store.readDraftThread);
237 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);234 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);
238 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);235 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
236 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
239 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);237 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);
240 const clearProjectDraftThreadById = useComposerDraftStore(238 const clearProjectDraftThreadById = useComposerDraftStore(
241 (store) => store.clearProjectDraftThreadById,239 (store) => store.clearProjectDraftThreadById,
...@@ -346,55 +344,25 @@ export default function Sidebar() {...@@ -346,55 +344,25 @@ export default function Sidebar() {
346 worktreePath?: string | null;344 worktreePath?: string | null;
347 },345 },
348 ): Promise<void> => {346 ): Promise<void> => {
349 const api = readNativeApi();
350 if (!api) return Promise.resolve();
351 const hasBranchOption = options?.branch !== undefined;347 const hasBranchOption = options?.branch !== undefined;
352 const hasWorktreePathOption = options?.worktreePath !== undefined;348 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 };
378 const storedDraftThreadId = readProjectDraftThreadId(projectId);349 const storedDraftThreadId = readProjectDraftThreadId(projectId);
379 const storedDraftThread = storedDraftThreadId350 const storedDraftThread = storedDraftThreadId ? readDraftThread(storedDraftThreadId) : null;
380 ? state.threads.find((thread) => thread.id === storedDraftThreadId)351 if (storedDraftThread && storedDraftThread.projectId === projectId && storedDraftThreadId) {
381 : undefined;
382 const reusableStoredDraftThread =
383 storedDraftThread &&
384 storedDraftThread.projectId === projectId &&
385 !shouldShowThreadInSidebar(storedDraftThread)
386 ? storedDraftThread
387 : null;
388 if (reusableStoredDraftThread) {
389 return (async () => {352 return (async () => {
390 await applyThreadOptions(reusableStoredDraftThread);353 if (hasBranchOption || hasWorktreePathOption) {
391 setProjectDraftThreadId(projectId, reusableStoredDraftThread.id);354 setDraftThreadContext(storedDraftThreadId, {
392 if (routeThreadId === reusableStoredDraftThread.id) {355 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
356 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
357 });
358 }
359 setProjectDraftThreadId(projectId, storedDraftThreadId);
360 if (routeThreadId === storedDraftThreadId) {
393 return;361 return;
394 }362 }
395 await navigate({363 await navigate({
396 to: "/$threadId",364 to: "/$threadId",
397 params: { threadId: reusableStoredDraftThread.id },365 params: { threadId: storedDraftThreadId },
398 });366 });
399 })();367 })();
400 }368 }
...@@ -402,53 +370,25 @@ export default function Sidebar() {...@@ -402,53 +370,25 @@ export default function Sidebar() {
402 clearProjectDraftThreadId(projectId);370 clearProjectDraftThreadId(projectId);
403 }371 }
404372
405 const activeThread = routeThreadId373 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
406 ? state.threads.find((thread) => thread.id === routeThreadId)374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
407 : undefined;375 if (hasBranchOption || hasWorktreePathOption) {
408 const reusableDraftThread =376 setDraftThreadContext(routeThreadId, {
409 activeThread &&377 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
410 activeThread.projectId === projectId &&378 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
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 },
424 });379 });
425 })();380 }
381 setProjectDraftThreadId(projectId, routeThreadId);
382 return Promise.resolve();
426 }383 }
427 const threadId = newThreadId();384 const threadId = newThreadId();
428 const createdAt = new Date().toISOString();385 const createdAt = new Date().toISOString();
429 const model =
430 state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
431 return (async () => {386 return (async () => {
432 await api.orchestration.dispatchCommand({387 setProjectDraftThreadId(projectId, threadId, {
433 type: "thread.create",388 createdAt,
434 commandId: newCommandId(),
435 threadId,
436 projectId,
437 title: "New thread",
438 model,
439 branch: options?.branch ?? null,389 branch: options?.branch ?? null,
440 worktreePath: options?.worktreePath ?? null,390 worktreePath: options?.worktreePath ?? null,
441 createdAt,
442 });391 });
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
453 await navigate({393 await navigate({
454 to: "/$threadId",394 to: "/$threadId",
...@@ -458,13 +398,12 @@ export default function Sidebar() {...@@ -458,13 +398,12 @@ export default function Sidebar() {
458 },398 },
459 [399 [
460 clearProjectDraftThreadId,400 clearProjectDraftThreadId,
461 dispatch,
462 navigate,401 navigate,
402 readDraftThread,
463 readProjectDraftThreadId,403 readProjectDraftThreadId,
464 routeThreadId,404 routeThreadId,
405 setDraftThreadContext,
465 setProjectDraftThreadId,406 setProjectDraftThreadId,
466 state.projects,
467 state.threads,
468 ],407 ],
469 );408 );
470409
...@@ -678,9 +617,7 @@ export default function Sidebar() {...@@ -678,9 +617,7 @@ export default function Sidebar() {
678 if (!project) return;617 if (!project) return;
679618
680 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);619 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
681 const visibleThreads = projectThreads.filter(shouldShowThreadInSidebar);620 if (projectThreads.length > 0) {
682 const draftThreads = projectThreads.filter((thread) => !shouldShowThreadInSidebar(thread));
683 if (visibleThreads.length > 0) {
684 toastManager.add({621 toastManager.add({
685 type: "warning",622 type: "warning",
686 title: "Project is not empty",623 title: "Project is not empty",
...@@ -695,13 +632,9 @@ export default function Sidebar() {...@@ -695,13 +632,9 @@ export default function Sidebar() {
695 if (!confirmed) return;632 if (!confirmed) return;
696633
697 try {634 try {
698 for (const draftThread of draftThreads) {635 const projectDraftThreadId = readProjectDraftThreadId(projectId);
699 await api.orchestration.dispatchCommand({636 if (projectDraftThreadId) {
700 type: "thread.delete",637 clearComposerDraftForThread(projectDraftThreadId);
701 commandId: newCommandId(),
702 threadId: draftThread.id,
703 });
704 clearComposerDraftForThread(draftThread.id);
705 }638 }
706 clearProjectDraftThreadId(projectId);639 clearProjectDraftThreadId(projectId);
707 await api.orchestration.dispatchCommand({640 await api.orchestration.dispatchCommand({
...@@ -719,7 +652,13 @@ export default function Sidebar() {...@@ -719,7 +652,13 @@ export default function Sidebar() {
719 });652 });
720 }653 }
721 },654 },
722 [clearComposerDraftForThread, clearProjectDraftThreadId, state.projects, state.threads],655 [
656 clearComposerDraftForThread,
657 clearProjectDraftThreadId,
658 readProjectDraftThreadId,
659 state.projects,
660 state.threads,
661 ],
723 );662 );
724663
725 useEffect(() => {664 useEffect(() => {
...@@ -727,8 +666,9 @@ export default function Sidebar() {...@@ -727,8 +666,9 @@ export default function Sidebar() {
727 const activeThread = routeThreadId666 const activeThread = routeThreadId
728 ? state.threads.find((thread) => thread.id === routeThreadId)667 ? state.threads.find((thread) => thread.id === routeThreadId)
729 : undefined;668 : undefined;
669 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
730 if (isChatNewLocalShortcut(event, keybindings)) {670 if (isChatNewLocalShortcut(event, keybindings)) {
731 const projectId = activeThread?.projectId ?? state.projects[0]?.id;671 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
732 if (!projectId) return;672 if (!projectId) return;
733 event.preventDefault();673 event.preventDefault();
734 void handleNewThread(projectId);674 void handleNewThread(projectId);
...@@ -736,12 +676,12 @@ export default function Sidebar() {...@@ -736,12 +676,12 @@ export default function Sidebar() {
736 }676 }
737677
738 if (!isChatNewShortcut(event, keybindings)) return;678 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;
740 if (!projectId) return;680 if (!projectId) return;
741 event.preventDefault();681 event.preventDefault();
742 void handleNewThread(projectId, {682 void handleNewThread(projectId, {
743 branch: activeThread?.branch ?? null,683 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
744 worktreePath: activeThread?.worktreePath ?? null,684 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
745 });685 });
746 };686 };
747687
...@@ -749,7 +689,7 @@ export default function Sidebar() {...@@ -749,7 +689,7 @@ export default function Sidebar() {
749 return () => {689 return () => {
750 window.removeEventListener("keydown", onWindowKeyDown);690 window.removeEventListener("keydown", onWindowKeyDown);
751 };691 };
752 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);692 }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]);
753693
754 const onCreateThreadClick = () => {694 const onCreateThreadClick = () => {
755 if (state.projects.length === 0) {695 if (state.projects.length === 0) {
...@@ -808,7 +748,6 @@ export default function Sidebar() {...@@ -808,7 +748,6 @@ export default function Sidebar() {
808 {state.projects.map((project) => {748 {state.projects.map((project) => {
809 const visibleThreads = state.threads749 const visibleThreads = state.threads
810 .filter((thread) => thread.projectId === project.id)750 .filter((thread) => thread.projectId === project.id)
811 .filter(shouldShowThreadInSidebar)
812 .toSorted((a, b) => {751 .toSorted((a, b) => {
813 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();752 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
814 if (byDate !== 0) return byDate;753 if (byDate !== 0) return byDate;
apps/web/src/composerDraftStore.test.ts+55-4
...@@ -33,7 +33,11 @@ describe("composerDraftStore addImages", () => {...@@ -33,7 +33,11 @@ describe("composerDraftStore addImages", () => {
33 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;33 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
3434
35 beforeEach(() => {35 beforeEach(() => {
36 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });36 useComposerDraftStore.setState({
37 draftsByThreadId: {},
38 draftThreadsByThreadId: {},
39 projectDraftThreadIdByProjectId: {},
40 });
37 originalRevokeObjectUrl = URL.revokeObjectURL;41 originalRevokeObjectUrl = URL.revokeObjectURL;
38 revokeSpy = vi.fn();42 revokeSpy = vi.fn();
39 URL.revokeObjectURL = revokeSpy;43 URL.revokeObjectURL = revokeSpy;
...@@ -118,7 +122,11 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -118,7 +122,11 @@ describe("composerDraftStore clearComposerContent", () => {
118 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;122 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
119123
120 beforeEach(() => {124 beforeEach(() => {
121 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });125 useComposerDraftStore.setState({
126 draftsByThreadId: {},
127 draftThreadsByThreadId: {},
128 projectDraftThreadIdByProjectId: {},
129 });
122 originalRevokeObjectUrl = URL.revokeObjectURL;130 originalRevokeObjectUrl = URL.revokeObjectURL;
123 revokeSpy = vi.fn();131 revokeSpy = vi.fn();
124 URL.revokeObjectURL = revokeSpy;132 URL.revokeObjectURL = revokeSpy;
...@@ -149,15 +157,30 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -149,15 +157,30 @@ describe("composerDraftStore project draft thread mapping", () => {
149 const otherThreadId = ThreadId.makeUnsafe("thread-b");157 const otherThreadId = ThreadId.makeUnsafe("thread-b");
150158
151 beforeEach(() => {159 beforeEach(() => {
152 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });160 useComposerDraftStore.setState({
161 draftsByThreadId: {},
162 draftThreadsByThreadId: {},
163 projectDraftThreadIdByProjectId: {},
164 });
153 });165 });
154166
155 it("stores and reads project draft thread ids via actions", () => {167 it("stores and reads project draft thread ids via actions", () => {
156 const store = useComposerDraftStore.getState();168 const store = useComposerDraftStore.getState();
157 expect(store.readProjectDraftThreadId(projectId)).toBeNull();169 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 });
160 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);177 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 });
161 });184 });
162185
163 it("clears only matching project draft mapping entries", () => {186 it("clears only matching project draft mapping entries", () => {
...@@ -169,6 +192,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -169,6 +192,7 @@ describe("composerDraftStore project draft thread mapping", () => {
169192
170 store.clearProjectDraftThreadById(projectId, threadId);193 store.clearProjectDraftThreadById(projectId, threadId);
171 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();194 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
195 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
172 });196 });
173197
174 it("clears project draft mapping by project id", () => {198 it("clears project draft mapping by project id", () => {
...@@ -176,5 +200,32 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -176,5 +200,32 @@ describe("composerDraftStore project draft thread mapping", () => {
176 store.setProjectDraftThreadId(projectId, threadId);200 store.setProjectDraftThreadId(projectId, threadId);
177 store.clearProjectDraftThreadId(projectId);201 store.clearProjectDraftThreadId(projectId);
178 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();202 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 });
179 });230 });
180});231});
apps/web/src/composerDraftStore.ts+250-20
...@@ -66,8 +66,16 @@ interface PersistedComposerThreadDraftState {...@@ -66,8 +66,16 @@ interface PersistedComposerThreadDraftState {
66 effort?: ReasoningEffort | null;66 effort?: ReasoningEffort | null;
67}67}
6868
69interface PersistedDraftThreadState {
70 projectId: ProjectId;
71 createdAt: string;
72 branch: string | null;
73 worktreePath: string | null;
74}
75
69interface PersistedComposerDraftStoreState {76interface PersistedComposerDraftStoreState {
70 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;77 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
78 draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>;
71 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;79 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
72}80}
7381
...@@ -81,13 +89,41 @@ export interface ComposerThreadDraftState {...@@ -81,13 +89,41 @@ export interface ComposerThreadDraftState {
81 effort: ReasoningEffort | null;89 effort: ReasoningEffort | null;
82}90}
8391
92export interface DraftThreadState {
93 projectId: ProjectId;
94 createdAt: string;
95 branch: string | null;
96 worktreePath: string | null;
97}
98
84interface ComposerDraftStoreState {99interface ComposerDraftStoreState {
85 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;100 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
101 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
86 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;102 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
103 readDraftThread: (threadId: ThreadId) => DraftThreadState | null;
104 isDraftThread: (threadId: ThreadId) => boolean;
87 readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null;105 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;
89 clearProjectDraftThreadId: (projectId: ProjectId) => void;124 clearProjectDraftThreadId: (projectId: ProjectId) => void;
90 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;125 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
126 clearDraftThread: (threadId: ThreadId) => void;
91 setPrompt: (threadId: ThreadId, prompt: string) => void;127 setPrompt: (threadId: ThreadId, prompt: string) => void;
92 setCursor: (threadId: ThreadId, cursor: number) => void;128 setCursor: (threadId: ThreadId, cursor: number) => void;
93 setModel: (threadId: ThreadId, model: string | null | undefined) => void;129 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
...@@ -187,11 +223,39 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt...@@ -187,11 +223,39 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
187223
188function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {224function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
189 if (!value || typeof value !== "object") {225 if (!value || typeof value !== "object") {
190 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };226 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
191 }227 }
192 const candidate = value as Record<string, unknown>;228 const candidate = value as Record<string, unknown>;
193 const rawDraftMap = candidate.draftsByThreadId;229 const rawDraftMap = candidate.draftsByThreadId;
230 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
194 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;231 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 }
195 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =259 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
196 {};260 {};
197 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {261 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {
...@@ -205,11 +269,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -205,11 +269,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
205 threadId.length > 0269 threadId.length > 0
206 ) {270 ) {
207 projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;271 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 }
208 }285 }
209 }286 }
210 }287 }
211 if (!rawDraftMap || typeof rawDraftMap !== "object") {288 if (!rawDraftMap || typeof rawDraftMap !== "object") {
212 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId };289 return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
213 }290 }
214 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};291 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
215 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {292 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
...@@ -250,7 +327,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -250,7 +327,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
250 ...(effort ? { effort } : {}),327 ...(effort ? { effort } : {}),
251 };328 };
252 }329 }
253 return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId };330 return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
254}331}
255332
256function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {333function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
...@@ -304,12 +381,12 @@ function hydrateImagesFromPersisted(...@@ -304,12 +381,12 @@ function hydrateImagesFromPersisted(
304381
305function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {382function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
306 if (typeof window === "undefined") {383 if (typeof window === "undefined") {
307 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };384 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
308 }385 }
309 try {386 try {
310 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);387 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
311 if (!raw) {388 if (!raw) {
312 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };389 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
313 }390 }
314 const parsed = JSON.parse(raw) as unknown;391 const parsed = JSON.parse(raw) as unknown;
315 if (parsed && typeof parsed === "object" && "state" in parsed) {392 if (parsed && typeof parsed === "object" && "state" in parsed) {
...@@ -317,7 +394,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS...@@ -317,7 +394,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS
317 }394 }
318 return normalizePersistedComposerDraftState(parsed);395 return normalizePersistedComposerDraftState(parsed);
319 } catch {396 } catch {
320 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };397 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
321 }398 }
322}399}
323400
...@@ -339,26 +416,122 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -339,26 +416,122 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
339 persist(416 persist(
340 (set, get) => ({417 (set, get) => ({
341 draftsByThreadId: {},418 draftsByThreadId: {},
419 draftThreadsByThreadId: {},
342 projectDraftThreadIdByProjectId: {},420 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 },
343 readProjectDraftThreadId: (projectId) => {433 readProjectDraftThreadId: (projectId) => {
344 if (projectId.length === 0) {434 if (projectId.length === 0) {
345 return null;435 return null;
346 }436 }
347 return get().projectDraftThreadIdByProjectId[projectId] ?? null;437 return get().projectDraftThreadIdByProjectId[projectId] ?? null;
348 },438 },
349 setProjectDraftThreadId: (projectId, threadId) => {439 setProjectDraftThreadId: (projectId, threadId, options) => {
350 if (projectId.length === 0 || threadId.length === 0) {440 if (projectId.length === 0 || threadId.length === 0) {
351 return;441 return;
352 }442 }
353 set((state) => {443 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) {
355 return state;500 return state;
356 }501 }
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 }
357 return {529 return {
358 projectDraftThreadIdByProjectId: {530 draftThreadsByThreadId: {
359 ...state.projectDraftThreadIdByProjectId,531 ...state.draftThreadsByThreadId,
360 [projectId]: threadId,532 [threadId]: nextDraftThread,
361 },533 },
534 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
362 };535 };
363 });536 });
364 },537 },
...@@ -367,12 +540,21 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -367,12 +540,21 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
367 return;540 return;
368 }541 }
369 set((state) => {542 set((state) => {
370 if (state.projectDraftThreadIdByProjectId[projectId] === undefined) {543 const threadId = state.projectDraftThreadIdByProjectId[projectId];
544 if (threadId === undefined) {
371 return state;545 return state;
372 }546 }
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 }
374 return {555 return {
375 projectDraftThreadIdByProjectId: rest,556 draftThreadsByThreadId: nextDraftThreadsByThreadId,
557 projectDraftThreadIdByProjectId: restProjectMappings,
376 };558 };
377 });559 });
378 },560 },
...@@ -384,9 +566,40 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -384,9 +566,40 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
384 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {566 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
385 return state;567 return state;
386 }568 }
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;
388 return {600 return {
389 projectDraftThreadIdByProjectId: rest,601 draftThreadsByThreadId: restDraftThreadsByThreadId,
602 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
390 };603 };
391 });604 });
392 },605 },
...@@ -688,11 +901,26 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -688,11 +901,26 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
688 }901 }
689 }902 }
690 set((state) => {903 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) {
692 return state;908 return state;
693 }909 }
694 const { [threadId]: _removed, ...rest } = state.draftsByThreadId;910 const { [threadId]: _removedComposerDraft, ...restComposerDraftsByThreadId } =
695 return { draftsByThreadId: rest };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 };
696 });924 });
697 },925 },
698 }),926 }),
...@@ -729,6 +957,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -729,6 +957,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
729 }957 }
730 return {958 return {
731 draftsByThreadId: persistedDraftsByThreadId,959 draftsByThreadId: persistedDraftsByThreadId,
960 draftThreadsByThreadId: state.draftThreadsByThreadId,
732 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,961 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
733 };962 };
734 },963 },
...@@ -743,6 +972,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -743,6 +972,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
743 return {972 return {
744 ...currentState,973 ...currentState,
745 draftsByThreadId,974 draftsByThreadId,
975 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
746 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,976 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
747 };977 };
748 },978 },
apps/web/src/routes/_chat.$threadId.tsx+8-3
...@@ -3,6 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";...@@ -3,6 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";3import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
44
5import ChatView from "../components/ChatView";5import ChatView from "../components/ChatView";
6import { useComposerDraftStore } from "../composerDraftStore";
6import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";7import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
7import { useMediaQuery } from "../hooks/useMediaQuery";8import { useMediaQuery } from "../hooks/useMediaQuery";
8import { useStore } from "../store";9import { useStore } from "../store";
...@@ -139,6 +140,10 @@ function ChatThreadRouteView() {...@@ -139,6 +140,10 @@ function ChatThreadRouteView() {
139 });140 });
140 const search = Route.useSearch();141 const search = Route.useSearch();
141 const threadExists = state.threads.some((thread) => thread.id === threadId);142 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;
142 const diffOpen = search.diff === "1";147 const diffOpen = search.diff === "1";
143 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);148 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
144 const closeDiff = useCallback(() => {149 const closeDiff = useCallback(() => {
...@@ -166,13 +171,13 @@ function ChatThreadRouteView() {...@@ -166,13 +171,13 @@ function ChatThreadRouteView() {
166 return;171 return;
167 }172 }
168173
169 if (!threadExists) {174 if (!routeThreadExists) {
170 void navigate({ to: "/", replace: true });175 void navigate({ to: "/", replace: true });
171 return;176 return;
172 }177 }
173 }, [navigate, state.threadsHydrated, threadExists, threadId]);178 }, [navigate, routeThreadExists, state.threadsHydrated, threadId]);
174179
175 if (!state.threadsHydrated || !threadExists) {180 if (!state.threadsHydrated || !routeThreadExists) {
176 return null;181 return null;
177 }182 }
178183