| author | |
| committer | |
| log | 122c94a24acf31d72097faad3e943e939e7fff91 |
| tree | fbb4dc05faabf92e9a95f76560b344945a7b0b58 |
| parent | b59c6d6ab1b71295bf8c77e611522989e1e97fc8 |
- 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 updates5 files changed, 472 insertions(+), 167 deletions(-)
apps/web/src/components/ChatView.tsx+116-36| ... | ... | @@ -53,6 +53,7 @@ import { |
| 53 | 53 | DEFAULT_THREAD_TERMINAL_ID, |
| 54 | 54 | MAX_THREAD_TERMINAL_COUNT, |
| 55 | 55 | type ChatMessage, |
| 56 | type Thread, | |
| 56 | 57 | type TurnDiffSummary, |
| 57 | 58 | } from "../types"; |
| 58 | 59 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| ... | ... | @@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 113 | 114 | import { |
| 114 | 115 | COMPOSER_DRAFT_STORAGE_KEY, |
| 115 | 116 | type ComposerImageAttachment, |
| 117 | type DraftThreadState, | |
| 116 | 118 | type PersistedComposerImageAttachment, |
| 117 | 119 | readPersistedComposerDraftAttachments, |
| 118 | 120 | useComposerDraftStore, |
| ... | ... | @@ -195,6 +197,42 @@ function buildExpandedImagePreview( |
| 195 | 197 | }; |
| 196 | 198 | } |
| 197 | 199 | |
| 200 | function 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 | ||
| 198 | 236 | type ComposerCommandItem = |
| 199 | 237 | | { |
| 200 | 238 | id: string; |
| ... | ... | @@ -428,9 +466,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 428 | 466 | (store) => store.syncPersistedAttachments, |
| 429 | 467 | ); |
| 430 | 468 | 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); | |
| 434 | 471 | const promptRef = useRef(prompt); |
| 435 | 472 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 436 | 473 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -488,7 +525,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 488 | 525 | [removeComposerDraftImage, threadId], |
| 489 | 526 | ); |
| 490 | 527 | |
| 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 | 540 | const diffSearch = useMemo( |
| 493 | 541 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), |
| 494 | 542 | [rawSearch], |
| ... | ... | @@ -876,7 +924,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 876 | 924 | }, |
| 877 | 925 | ) => { |
| 878 | 926 | const api = readNativeApi(); |
| 879 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | |
| 927 | if (!api || !activeThreadId || !activeProject || !activeThread || !isServerThread) return; | |
| 880 | 928 | if (options?.rememberAsLastInvoked !== false) { |
| 881 | 929 | setLastInvokedScriptByProjectId((current) => { |
| 882 | 930 | if (current[activeProject.id] === script.id) return current; |
| ... | ... | @@ -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 | 1001 | const persistProjectScripts = useCallback( |
| 954 | 1002 | async (input: { |
| ... | ... | @@ -1634,7 +1682,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1634 | 1682 | if (!trimmed && composerImages.length === 0) return; |
| 1635 | 1683 | if (!activeProject) return; |
| 1636 | 1684 | const threadIdForSend = activeThread.id; |
| 1637 | const isFirstMessage = activeThread.messages.length === 0; | |
| 1685 | const isFirstMessage = !isServerThread || activeThread.messages.length === 0; | |
| 1638 | 1686 | const baseBranchForWorktree = |
| 1639 | 1687 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath |
| 1640 | 1688 | ? activeThread.branch |
| ... | ... | @@ -1693,7 +1741,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1693 | 1741 | clearComposerDraftContent(threadIdForSend); |
| 1694 | 1742 | setComposerHighlightedItemId(null); |
| 1695 | 1743 | |
| 1696 | let attemptedTurnStart = false; | |
| 1744 | let createdServerThreadForLocalDraft = false; | |
| 1745 | let turnStartSucceeded = false; | |
| 1746 | let nextThreadBranch = activeThread.branch; | |
| 1747 | let nextThreadWorktreePath = activeThread.worktreePath; | |
| 1697 | 1748 | try { |
| 1698 | 1749 | // On first message: lock in branch + create worktree if needed. |
| 1699 | 1750 | if (baseBranchForWorktree) { |
| ... | ... | @@ -1704,23 +1755,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1704 | 1755 | branch: baseBranchForWorktree, |
| 1705 | 1756 | newBranch, |
| 1706 | 1757 | }); |
| 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 | } | |
| 1722 | 1777 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1723 | if (setupScript) { | |
| 1778 | if (setupScript && isServerThread) { | |
| 1724 | 1779 | await runProjectScript(setupScript, { |
| 1725 | 1780 | cwd: result.worktree.path, |
| 1726 | 1781 | worktreePath: result.worktree.path, |
| ... | ... | @@ -1729,14 +1784,30 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1729 | 1784 | } |
| 1730 | 1785 | } |
| 1731 | 1786 | |
| 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 | 1809 | // 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) { | |
| 1740 | 1811 | await api.orchestration.dispatchCommand({ |
| 1741 | 1812 | type: "thread.meta.update", |
| 1742 | 1813 | commandId: newCommandId(), |
| ... | ... | @@ -1747,7 +1818,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1747 | 1818 | |
| 1748 | 1819 | setSendPhase("sending-turn"); |
| 1749 | 1820 | const turnAttachments = await turnAttachmentsPromise; |
| 1750 | attemptedTurnStart = true; | |
| 1751 | 1821 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; |
| 1752 | 1822 | const sandboxMode = |
| 1753 | 1823 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; |
| ... | ... | @@ -1768,12 +1838,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1768 | 1838 | sandboxMode, |
| 1769 | 1839 | createdAt: messageCreatedAt, |
| 1770 | 1840 | }); |
| 1841 | turnStartSucceeded = true; | |
| 1771 | 1842 | if (isFirstMessage) { |
| 1772 | clearProjectDraftThreadById(activeProject.id, threadIdForSend); | |
| 1843 | clearDraftThread(threadIdForSend); | |
| 1773 | 1844 | } |
| 1774 | 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 | 1855 | if ( |
| 1776 | !attemptedTurnStart && | |
| 1856 | !turnStartSucceeded && | |
| 1777 | 1857 | promptRef.current.length === 0 && |
| 1778 | 1858 | composerImagesRef.current.length === 0 |
| 1779 | 1859 | ) { |
| ... | ... | @@ -1841,7 +1921,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1841 | 1921 | const normalizedModel = resolveModelSlug(model); |
| 1842 | 1922 | setComposerDraftModel(threadId, normalizedModel); |
| 1843 | 1923 | const api = readNativeApi(); |
| 1844 | if (api && activeThread) { | |
| 1924 | if (api && isServerThread && activeThread) { | |
| 1845 | 1925 | void api.orchestration.dispatchCommand({ |
| 1846 | 1926 | type: "thread.meta.update", |
| 1847 | 1927 | commandId: newCommandId(), |
| ... | ... | @@ -1851,7 +1931,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1851 | 1931 | } |
| 1852 | 1932 | scheduleComposerFocus(); |
| 1853 | 1933 | }, |
| 1854 | [activeThread, scheduleComposerFocus, setComposerDraftModel, threadId], | |
| 1934 | [activeThread, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId], | |
| 1855 | 1935 | ); |
| 1856 | 1936 | const onEffortSelect = useCallback( |
| 1857 | 1937 | (effort: ReasoningEffort) => { |
apps/web/src/components/Sidebar.tsx+43-104| ... | ... | @@ -75,10 +75,6 @@ interface PrStatusIndicator { |
| 75 | 75 | |
| 76 | 76 | type ThreadPr = GitStatusResult["pr"]; |
| 77 | 77 | |
| 78 | function shouldShowThreadInSidebar(thread: Thread): boolean { | |
| 79 | return thread.messages.some((message) => message.role === "user"); | |
| 80 | } | |
| 81 | ||
| 82 | 78 | function hasUnseenCompletion(thread: Thread): boolean { |
| 83 | 79 | if (!thread.latestTurn?.completedAt) return false; |
| 84 | 80 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | ... | @@ -234,8 +230,10 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 234 | 230 | export default function Sidebar() { |
| 235 | 231 | const { state, dispatch } = useStore(); |
| 236 | 232 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 233 | const readDraftThread = useComposerDraftStore((store) => store.readDraftThread); | |
| 237 | 234 | const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId); |
| 238 | 235 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); |
| 236 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | |
| 239 | 237 | const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId); |
| 240 | 238 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 241 | 239 | (store) => store.clearProjectDraftThreadById, |
| ... | ... | @@ -346,55 +344,25 @@ export default function Sidebar() { |
| 346 | 344 | worktreePath?: string | null; |
| 347 | 345 | }, |
| 348 | 346 | ): Promise<void> => { |
| 349 | const api = readNativeApi(); | |
| 350 | if (!api) return Promise.resolve(); | |
| 351 | 347 | const hasBranchOption = options?.branch !== undefined; |
| 352 | 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 | 349 | 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) { | |
| 389 | 352 | 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) { | |
| 393 | 361 | return; |
| 394 | 362 | } |
| 395 | 363 | await navigate({ |
| 396 | 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 | 370 | clearProjectDraftThreadId(projectId); |
| 403 | 371 | } |
| 404 | 372 | |
| 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 } : {}), | |
| 424 | 379 | }); |
| 425 | })(); | |
| 380 | } | |
| 381 | setProjectDraftThreadId(projectId, routeThreadId); | |
| 382 | return Promise.resolve(); | |
| 426 | 383 | } |
| 427 | 384 | const threadId = newThreadId(); |
| 428 | 385 | const createdAt = new Date().toISOString(); |
| 429 | const model = | |
| 430 | state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; | |
| 431 | 386 | 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, | |
| 439 | 389 | branch: options?.branch ?? null, |
| 440 | 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 | } | |
| 452 | 392 | |
| 453 | 393 | await navigate({ |
| 454 | 394 | to: "/$threadId", |
| ... | ... | @@ -458,13 +398,12 @@ export default function Sidebar() { |
| 458 | 398 | }, |
| 459 | 399 | [ |
| 460 | 400 | clearProjectDraftThreadId, |
| 461 | dispatch, | |
| 462 | 401 | navigate, |
| 402 | readDraftThread, | |
| 463 | 403 | readProjectDraftThreadId, |
| 464 | 404 | routeThreadId, |
| 405 | setDraftThreadContext, | |
| 465 | 406 | setProjectDraftThreadId, |
| 466 | state.projects, | |
| 467 | state.threads, | |
| 468 | 407 | ], |
| 469 | 408 | ); |
| 470 | 409 | |
| ... | ... | @@ -678,9 +617,7 @@ export default function Sidebar() { |
| 678 | 617 | if (!project) return; |
| 679 | 618 | |
| 680 | 619 | 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) { | |
| 684 | 621 | toastManager.add({ |
| 685 | 622 | type: "warning", |
| 686 | 623 | title: "Project is not empty", |
| ... | ... | @@ -695,13 +632,9 @@ export default function Sidebar() { |
| 695 | 632 | if (!confirmed) return; |
| 696 | 633 | |
| 697 | 634 | 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); | |
| 705 | 638 | } |
| 706 | 639 | clearProjectDraftThreadId(projectId); |
| 707 | 640 | await api.orchestration.dispatchCommand({ |
| ... | ... | @@ -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 | ); |
| 724 | 663 | |
| 725 | 664 | useEffect(() => { |
| ... | ... | @@ -727,8 +666,9 @@ export default function Sidebar() { |
| 727 | 666 | const activeThread = routeThreadId |
| 728 | 667 | ? state.threads.find((thread) => thread.id === routeThreadId) |
| 729 | 668 | : undefined; |
| 669 | const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null; | |
| 730 | 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 | 672 | if (!projectId) return; |
| 733 | 673 | event.preventDefault(); |
| 734 | 674 | void handleNewThread(projectId); |
| ... | ... | @@ -736,12 +676,12 @@ export default function Sidebar() { |
| 736 | 676 | } |
| 737 | 677 | |
| 738 | 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 | 680 | if (!projectId) return; |
| 741 | 681 | event.preventDefault(); |
| 742 | 682 | 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, | |
| 745 | 685 | }); |
| 746 | 686 | }; |
| 747 | 687 | |
| ... | ... | @@ -749,7 +689,7 @@ export default function Sidebar() { |
| 749 | 689 | return () => { |
| 750 | 690 | window.removeEventListener("keydown", onWindowKeyDown); |
| 751 | 691 | }; |
| 752 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | |
| 692 | }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]); | |
| 753 | 693 | |
| 754 | 694 | const onCreateThreadClick = () => { |
| 755 | 695 | if (state.projects.length === 0) { |
| ... | ... | @@ -808,7 +748,6 @@ export default function Sidebar() { |
| 808 | 748 | {state.projects.map((project) => { |
| 809 | 749 | const visibleThreads = state.threads |
| 810 | 750 | .filter((thread) => thread.projectId === project.id) |
| 811 | .filter(shouldShowThreadInSidebar) | |
| 812 | 751 | .toSorted((a, b) => { |
| 813 | 752 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| 814 | 753 | if (byDate !== 0) return byDate; |
apps/web/src/composerDraftStore.test.ts+55-4| ... | ... | @@ -33,7 +33,11 @@ describe("composerDraftStore addImages", () => { |
| 33 | 33 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 34 | 34 | |
| 35 | 35 | beforeEach(() => { |
| 36 | useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); | |
| 36 | useComposerDraftStore.setState({ | |
| 37 | draftsByThreadId: {}, | |
| 38 | draftThreadsByThreadId: {}, | |
| 39 | projectDraftThreadIdByProjectId: {}, | |
| 40 | }); | |
| 37 | 41 | originalRevokeObjectUrl = URL.revokeObjectURL; |
| 38 | 42 | revokeSpy = vi.fn(); |
| 39 | 43 | URL.revokeObjectURL = revokeSpy; |
| ... | ... | @@ -118,7 +122,11 @@ describe("composerDraftStore clearComposerContent", () => { |
| 118 | 122 | let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; |
| 119 | 123 | |
| 120 | 124 | beforeEach(() => { |
| 121 | useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); | |
| 125 | useComposerDraftStore.setState({ | |
| 126 | draftsByThreadId: {}, | |
| 127 | draftThreadsByThreadId: {}, | |
| 128 | projectDraftThreadIdByProjectId: {}, | |
| 129 | }); | |
| 122 | 130 | originalRevokeObjectUrl = URL.revokeObjectURL; |
| 123 | 131 | revokeSpy = vi.fn(); |
| 124 | 132 | URL.revokeObjectURL = revokeSpy; |
| ... | ... | @@ -149,15 +157,30 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 149 | 157 | const otherThreadId = ThreadId.makeUnsafe("thread-b"); |
| 150 | 158 | |
| 151 | 159 | beforeEach(() => { |
| 152 | useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); | |
| 160 | useComposerDraftStore.setState({ | |
| 161 | draftsByThreadId: {}, | |
| 162 | draftThreadsByThreadId: {}, | |
| 163 | projectDraftThreadIdByProjectId: {}, | |
| 164 | }); | |
| 153 | 165 | }); |
| 154 | 166 | |
| 155 | 167 | it("stores and reads project draft thread ids via actions", () => { |
| 156 | 168 | const store = useComposerDraftStore.getState(); |
| 157 | 169 | expect(store.readProjectDraftThreadId(projectId)).toBeNull(); |
| 170 | expect(store.readDraftThread(threadId)).toBeNull(); | |
| 158 | 171 | |
| 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 | 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 | }); |
| 162 | 185 | |
| 163 | 186 | it("clears only matching project draft mapping entries", () => { |
| ... | ... | @@ -169,6 +192,7 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 169 | 192 | |
| 170 | 193 | store.clearProjectDraftThreadById(projectId, threadId); |
| 171 | 194 | expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); |
| 195 | expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull(); | |
| 172 | 196 | }); |
| 173 | 197 | |
| 174 | 198 | it("clears project draft mapping by project id", () => { |
| ... | ... | @@ -176,5 +200,32 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 176 | 200 | store.setProjectDraftThreadId(projectId, threadId); |
| 177 | 201 | store.clearProjectDraftThreadId(projectId); |
| 178 | 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 | 66 | effort?: ReasoningEffort | null; |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | interface PersistedDraftThreadState { | |
| 70 | projectId: ProjectId; | |
| 71 | createdAt: string; | |
| 72 | branch: string | null; | |
| 73 | worktreePath: string | null; | |
| 74 | } | |
| 75 | ||
| 69 | 76 | interface PersistedComposerDraftStoreState { |
| 70 | 77 | draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; |
| 78 | draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>; | |
| 71 | 79 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; |
| 72 | 80 | } |
| 73 | 81 | |
| ... | ... | @@ -81,13 +89,41 @@ export interface ComposerThreadDraftState { |
| 81 | 89 | effort: ReasoningEffort | null; |
| 82 | 90 | } |
| 83 | 91 | |
| 92 | export interface DraftThreadState { | |
| 93 | projectId: ProjectId; | |
| 94 | createdAt: string; | |
| 95 | branch: string | null; | |
| 96 | worktreePath: string | null; | |
| 97 | } | |
| 98 | ||
| 84 | 99 | interface ComposerDraftStoreState { |
| 85 | 100 | draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; |
| 101 | draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; | |
| 86 | 102 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; |
| 103 | readDraftThread: (threadId: ThreadId) => DraftThreadState | null; | |
| 104 | isDraftThread: (threadId: ThreadId) => boolean; | |
| 87 | 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 | 124 | clearProjectDraftThreadId: (projectId: ProjectId) => void; |
| 90 | 125 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; |
| 126 | clearDraftThread: (threadId: ThreadId) => void; | |
| 91 | 127 | setPrompt: (threadId: ThreadId, prompt: string) => void; |
| 92 | 128 | setCursor: (threadId: ThreadId, cursor: number) => void; |
| 93 | 129 | setModel: (threadId: ThreadId, model: string | null | undefined) => void; |
| ... | ... | @@ -187,11 +223,39 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt |
| 187 | 223 | |
| 188 | 224 | function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { |
| 189 | 225 | if (!value || typeof value !== "object") { |
| 190 | return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 226 | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 191 | 227 | } |
| 192 | 228 | const candidate = value as Record<string, unknown>; |
| 193 | 229 | const rawDraftMap = candidate.draftsByThreadId; |
| 230 | const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; | |
| 194 | 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 | 259 | const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = |
| 196 | 260 | {}; |
| 197 | 261 | if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") { |
| ... | ... | @@ -205,11 +269,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 205 | 269 | threadId.length > 0 |
| 206 | 270 | ) { |
| 207 | 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 | 288 | if (!rawDraftMap || typeof rawDraftMap !== "object") { |
| 212 | return { draftsByThreadId: {}, projectDraftThreadIdByProjectId }; | |
| 289 | return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; | |
| 213 | 290 | } |
| 214 | 291 | const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; |
| 215 | 292 | for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) { |
| ... | ... | @@ -250,7 +327,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 250 | 327 | ...(effort ? { effort } : {}), |
| 251 | 328 | }; |
| 252 | 329 | } |
| 253 | return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId }; | |
| 330 | return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; | |
| 254 | 331 | } |
| 255 | 332 | |
| 256 | 333 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { |
| ... | ... | @@ -304,12 +381,12 @@ function hydrateImagesFromPersisted( |
| 304 | 381 | |
| 305 | 382 | function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { |
| 306 | 383 | if (typeof window === "undefined") { |
| 307 | return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 384 | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 308 | 385 | } |
| 309 | 386 | try { |
| 310 | 387 | const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 311 | 388 | if (!raw) { |
| 312 | return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 389 | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 313 | 390 | } |
| 314 | 391 | const parsed = JSON.parse(raw) as unknown; |
| 315 | 392 | if (parsed && typeof parsed === "object" && "state" in parsed) { |
| ... | ... | @@ -317,7 +394,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS |
| 317 | 394 | } |
| 318 | 395 | return normalizePersistedComposerDraftState(parsed); |
| 319 | 396 | } catch { |
| 320 | return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 397 | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; | |
| 321 | 398 | } |
| 322 | 399 | } |
| 323 | 400 | |
| ... | ... | @@ -339,26 +416,122 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 339 | 416 | persist( |
| 340 | 417 | (set, get) => ({ |
| 341 | 418 | draftsByThreadId: {}, |
| 419 | draftThreadsByThreadId: {}, | |
| 342 | 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 | 433 | readProjectDraftThreadId: (projectId) => { |
| 344 | 434 | if (projectId.length === 0) { |
| 345 | 435 | return null; |
| 346 | 436 | } |
| 347 | 437 | return get().projectDraftThreadIdByProjectId[projectId] ?? null; |
| 348 | 438 | }, |
| 349 | setProjectDraftThreadId: (projectId, threadId) => { | |
| 439 | setProjectDraftThreadId: (projectId, threadId, options) => { | |
| 350 | 440 | if (projectId.length === 0 || threadId.length === 0) { |
| 351 | 441 | return; |
| 352 | 442 | } |
| 353 | 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 | 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 | 529 | return { |
| 358 | projectDraftThreadIdByProjectId: { | |
| 359 | ...state.projectDraftThreadIdByProjectId, | |
| 360 | [projectId]: threadId, | |
| 530 | draftThreadsByThreadId: { | |
| 531 | ...state.draftThreadsByThreadId, | |
| 532 | [threadId]: nextDraftThread, | |
| 361 | 533 | }, |
| 534 | projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, | |
| 362 | 535 | }; |
| 363 | 536 | }); |
| 364 | 537 | }, |
| ... | ... | @@ -367,12 +540,21 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 367 | 540 | return; |
| 368 | 541 | } |
| 369 | 542 | set((state) => { |
| 370 | if (state.projectDraftThreadIdByProjectId[projectId] === undefined) { | |
| 543 | const threadId = state.projectDraftThreadIdByProjectId[projectId]; | |
| 544 | if (threadId === undefined) { | |
| 371 | 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 | 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 | 566 | if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { |
| 385 | 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 | 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 | 901 | } |
| 689 | 902 | } |
| 690 | 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 | 908 | return state; |
| 693 | 909 | } |
| 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 | }; | |
| 696 | 924 | }); |
| 697 | 925 | }, |
| 698 | 926 | }), |
| ... | ... | @@ -729,6 +957,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 729 | 957 | } |
| 730 | 958 | return { |
| 731 | 959 | draftsByThreadId: persistedDraftsByThreadId, |
| 960 | draftThreadsByThreadId: state.draftThreadsByThreadId, | |
| 732 | 961 | projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, |
| 733 | 962 | }; |
| 734 | 963 | }, |
| ... | ... | @@ -743,6 +972,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 743 | 972 | return { |
| 744 | 973 | ...currentState, |
| 745 | 974 | draftsByThreadId, |
| 975 | draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, | |
| 746 | 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 | 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; |
| 4 | 4 | |
| 5 | 5 | import ChatView from "../components/ChatView"; |
| 6 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 6 | 7 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 7 | 8 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 8 | 9 | import { useStore } from "../store"; |
| ... | ... | @@ -139,6 +140,10 @@ function ChatThreadRouteView() { |
| 139 | 140 | }); |
| 140 | 141 | const search = Route.useSearch(); |
| 141 | 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 | 147 | const diffOpen = search.diff === "1"; |
| 143 | 148 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 144 | 149 | const closeDiff = useCallback(() => { |
| ... | ... | @@ -166,13 +171,13 @@ function ChatThreadRouteView() { |
| 166 | 171 | return; |
| 167 | 172 | } |
| 168 | 173 | |
| 169 | if (!threadExists) { | |
| 174 | if (!routeThreadExists) { | |
| 170 | 175 | void navigate({ to: "/", replace: true }); |
| 171 | 176 | return; |
| 172 | 177 | } |
| 173 | }, [navigate, state.threadsHydrated, threadExists, threadId]); | |
| 178 | }, [navigate, routeThreadExists, state.threadsHydrated, threadId]); | |
| 174 | 179 | |
| 175 | if (!state.threadsHydrated || !threadExists) { | |
| 180 | if (!state.threadsHydrated || !routeThreadExists) { | |
| 176 | 181 | return null; |
| 177 | 182 | } |
| 178 | 183 |