| 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,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"; |
| 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 59 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| ... | @@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings"; | ... | @@ -113,6 +114,7 @@ import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 113 | import { | 114 | import { |
| 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 | } |
| 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 | type ComposerCommandItem = | 236 | type 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 | ); |
| 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 | 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.worktreePath | 1687 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath |
| 1640 | ? activeThread.branch | 1688 | ? 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); |
| 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 | 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 opens | 1765 | 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 | } |
| 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 | // Auto-title from first message | 1809 | // 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) { |
| 1747 | 1818 | ||
| 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 === 0 | 1858 | 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 { |
| 75 | 75 | ||
| 76 | type ThreadPr = GitStatusResult["pr"]; | 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 | function hasUnseenCompletion(thread: Thread): boolean { | 78 | function 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 }) { |
| 234 | export default function Sidebar() { | 230 | export 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 = storedDraftThreadId | 350 | 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 | } |
| 404 | 372 | ||
| 405 | const activeThread = routeThreadId | 373 | 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 | } | ||
| 452 | 392 | ||
| 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 | ); |
| 470 | 409 | ||
| ... | @@ -678,9 +617,7 @@ export default function Sidebar() { | ... | @@ -678,9 +617,7 @@ export default function Sidebar() { |
| 678 | if (!project) return; | 617 | if (!project) return; |
| 679 | 618 | ||
| 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; |
| 696 | 633 | ||
| 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 | ); |
| 724 | 663 | ||
| 725 | useEffect(() => { | 664 | useEffect(() => { |
| ... | @@ -727,8 +666,9 @@ export default function Sidebar() { | ... | @@ -727,8 +666,9 @@ export default function Sidebar() { |
| 727 | const activeThread = routeThreadId | 666 | 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 | } |
| 737 | 677 | ||
| 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 | }; |
| 747 | 687 | ||
| ... | @@ -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]); |
| 753 | 693 | ||
| 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.threads | 749 | 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>>; |
| 34 | 34 | ||
| 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>>; |
| 119 | 123 | ||
| 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"); |
| 150 | 158 | ||
| 151 | beforeEach(() => { | 159 | beforeEach(() => { |
| 152 | useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); | 160 | useComposerDraftStore.setState({ |
| 161 | draftsByThreadId: {}, | ||
| 162 | draftThreadsByThreadId: {}, | ||
| 163 | projectDraftThreadIdByProjectId: {}, | ||
| 164 | }); | ||
| 153 | }); | 165 | }); |
| 154 | 166 | ||
| 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(); | ||
| 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 | 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 | }); |
| 162 | 185 | ||
| 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", () => { |
| 169 | 192 | ||
| 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 | }); |
| 173 | 197 | ||
| 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 | } |
| 68 | 68 | ||
| 69 | interface PersistedDraftThreadState { | ||
| 70 | projectId: ProjectId; | ||
| 71 | createdAt: string; | ||
| 72 | branch: string | null; | ||
| 73 | worktreePath: string | null; | ||
| 74 | } | ||
| 75 | |||
| 69 | interface PersistedComposerDraftStoreState { | 76 | interface 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 | } |
| 73 | 81 | ||
| ... | @@ -81,13 +89,41 @@ export interface ComposerThreadDraftState { | ... | @@ -81,13 +89,41 @@ export interface ComposerThreadDraftState { |
| 81 | effort: ReasoningEffort | null; | 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 | interface ComposerDraftStoreState { | 99 | interface 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 |
| 187 | 223 | ||
| 188 | function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { | 224 | function 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 > 0 | 269 | 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 | } |
| 255 | 332 | ||
| 256 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { | 333 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { |
| ... | @@ -304,12 +381,12 @@ function hydrateImagesFromPersisted( | ... | @@ -304,12 +381,12 @@ function hydrateImagesFromPersisted( |
| 304 | 381 | ||
| 305 | function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { | 382 | function 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 | } |
| 323 | 400 | ||
| ... | @@ -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"; |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; |
| 4 | 4 | ||
| 5 | import ChatView from "../components/ChatView"; | 5 | import ChatView from "../components/ChatView"; |
| 6 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 6 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 7 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 7 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 8 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 8 | import { useStore } from "../store"; | 9 | import { 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 | } |
| 168 | 173 | ||
| 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]); |
| 174 | 179 | ||
| 175 | if (!state.threadsHydrated || !threadExists) { | 180 | if (!state.threadsHydrated || !routeThreadExists) { |
| 176 | return null; | 181 | return null; |
| 177 | } | 182 | } |
| 178 | 183 |