| author | |
| committer | |
| log | 05eb051184ac4d486795ac6f8be29129b8b8845f |
| tree | f18a87a6508b349b9e5fd1335081cb484c25bac7 |
| parent | ddaa6afef55e0da53950c7ed45f7d91b717475a5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>4 files changed, 479 insertions(+), 42 deletions(-)
apps/web/src/components/Sidebar.tsx+285-5| ... | ... | @@ -163,7 +163,13 @@ import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./u |
| 163 | 163 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 164 | 164 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 165 | 165 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| 166 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 166 | import { | |
| 167 | composerDraftHasUserContent, | |
| 168 | DraftId, | |
| 169 | useComposerDraftStore, | |
| 170 | type ComposerThreadDraftState, | |
| 171 | type DraftSessionState, | |
| 172 | } from "../composerDraftStore"; | |
| 167 | 173 | |
| 168 | 174 | // Settled-tail paging: recent history is the common lookup; the deep tail |
| 169 | 175 | // stays behind an explicit Show more. |
| ... | ... | @@ -404,6 +410,229 @@ function SortablePinnedThreadRow(props: { |
| 404 | 410 | return props.children({ listeners, setNodeRef, transform, transition, isDragging }); |
| 405 | 411 | } |
| 406 | 412 | |
| 413 | // One unsent draft session the user has invested content in. Two lines, | |
| 414 | // nothing else: project name, then the typed prompt. All the draft's | |
| 415 | // settings (model, env mode, branch, worktree) still travel with it — | |
| 416 | // clicking is a plain navigation to /draft/$draftId, which touches nothing. | |
| 417 | // While the draft is open the row renders a frozen snapshot (see | |
| 418 | // SidebarDraftBlock); memoized so per-keystroke block re-renders skip it | |
| 419 | // entirely. | |
| 420 | const SidebarDraftRow = memo(function SidebarDraftRow(props: { | |
| 421 | draftId: DraftId; | |
| 422 | session: DraftSessionState; | |
| 423 | composer: ComposerThreadDraftState; | |
| 424 | projectTitle: string | null; | |
| 425 | projectCwd: string | null; | |
| 426 | isActive: boolean; | |
| 427 | onNavigate: (draftId: DraftId) => void; | |
| 428 | onDiscard: (draftId: DraftId) => void; | |
| 429 | }) { | |
| 430 | const { composer, draftId, onDiscard, onNavigate, session } = props; | |
| 431 | const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; | |
| 432 | // images mirrors persistedAttachments once rehydration finishes; before | |
| 433 | // that only the persisted list is populated, hence max not sum. | |
| 434 | const attachmentCount = | |
| 435 | Math.max(composer.images.length, composer.persistedAttachments.length) + | |
| 436 | composer.terminalContexts.length + | |
| 437 | composer.elementContexts.length + | |
| 438 | composer.previewAnnotations.length + | |
| 439 | composer.reviewComments.length; | |
| 440 | const preview = | |
| 441 | promptPreview.length > 0 | |
| 442 | ? promptPreview | |
| 443 | : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; | |
| 444 | const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); | |
| 445 | const handleKeyDown = useCallback( | |
| 446 | (event: ReactKeyboardEvent) => { | |
| 447 | // Keys targeting the nested discard button belong to the button: | |
| 448 | // preventDefault here would swallow Space's synthesized click and | |
| 449 | // navigate instead of discarding. | |
| 450 | if ((event.target as HTMLElement).closest("button")) return; | |
| 451 | if (event.key === "Enter" || event.key === " ") { | |
| 452 | event.preventDefault(); | |
| 453 | onNavigate(draftId); | |
| 454 | } | |
| 455 | }, | |
| 456 | [draftId, onNavigate], | |
| 457 | ); | |
| 458 | const handleDiscard = useCallback( | |
| 459 | (event: ReactMouseEvent) => { | |
| 460 | event.preventDefault(); | |
| 461 | event.stopPropagation(); | |
| 462 | onDiscard(draftId); | |
| 463 | }, | |
| 464 | [draftId, onDiscard], | |
| 465 | ); | |
| 466 | return ( | |
| 467 | <li className="list-none py-0.5"> | |
| 468 | <div | |
| 469 | role="button" | |
| 470 | tabIndex={0} | |
| 471 | data-testid="sidebar-draft-row" | |
| 472 | className={cn( | |
| 473 | "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none", | |
| 474 | props.isActive | |
| 475 | ? "bg-sidebar-row-active" | |
| 476 | : "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]", | |
| 477 | )} | |
| 478 | onClick={handleActivate} | |
| 479 | onKeyDown={handleKeyDown} | |
| 480 | > | |
| 481 | <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]"> | |
| 482 | <div className="flex h-5 min-w-0 items-center gap-1.5"> | |
| 483 | <SquarePenIcon | |
| 484 | aria-hidden | |
| 485 | className="size-3 shrink-0 text-amber-600 dark:text-amber-300/80" | |
| 486 | /> | |
| 487 | <ProjectFavicon | |
| 488 | environmentId={session.environmentId} | |
| 489 | cwd={props.projectCwd ?? ""} | |
| 490 | className="size-4 shrink-0" | |
| 491 | /> | |
| 492 | <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label"> | |
| 493 | {props.projectTitle} | |
| 494 | </span> | |
| 495 | <span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end"> | |
| 496 | <button | |
| 497 | type="button" | |
| 498 | aria-label="Discard draft" | |
| 499 | title="Discard draft" | |
| 500 | onClick={handleDiscard} | |
| 501 | className="pointer-events-none inline-flex cursor-pointer items-center rounded-md bg-transparent px-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100" | |
| 502 | > | |
| 503 | <XIcon className="size-3" /> | |
| 504 | </button> | |
| 505 | </span> | |
| 506 | </div> | |
| 507 | <div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div> | |
| 508 | </div> | |
| 509 | </div> | |
| 510 | </li> | |
| 511 | ); | |
| 512 | }); | |
| 513 | ||
| 514 | interface SidebarDraftRowData { | |
| 515 | draftId: DraftId; | |
| 516 | session: DraftSessionState; | |
| 517 | composer: ComposerThreadDraftState; | |
| 518 | } | |
| 519 | ||
| 520 | // Draft sessions with user content, surfaced above the pinned block so an | |
| 521 | // interrupted "new thread" stays one click away. Self-contained (own store | |
| 522 | // subscription + closing divider) so per-keystroke composer updates | |
| 523 | // re-render only this block, never the whole sidebar. Vanishes at count 0. | |
| 524 | const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { | |
| 525 | projectDisplayNameByKey: ReadonlyMap<string, string>; | |
| 526 | projectCwdByKey: ReadonlyMap<string, string>; | |
| 527 | scopedProjectKeys: ReadonlySet<string> | null; | |
| 528 | routeDraftId: string | null; | |
| 529 | onNavigateToDraft: (draftId: DraftId) => void; | |
| 530 | }) { | |
| 531 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); | |
| 532 | const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); | |
| 533 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); | |
| 534 | // The open draft's row is FROZEN at the moment the draft became the route: | |
| 535 | // it stays visible (like a thread row) but never repaints while the user | |
| 536 | // types. A draft that was never navigated away from has no snapshot to | |
| 537 | // freeze, so a fresh typing session shows no row at all. Captured | |
| 538 | // synchronously on route change (setState-during-render derived state) so | |
| 539 | // the row never flickers out for a frame between route change and capture. | |
| 540 | const [frozenActive, setFrozenActive] = useState<{ | |
| 541 | routeDraftId: string | null; | |
| 542 | row: SidebarDraftRowData | null; | |
| 543 | }>({ routeDraftId: null, row: null }); | |
| 544 | if (frozenActive.routeDraftId !== props.routeDraftId) { | |
| 545 | let row: SidebarDraftRowData | null = null; | |
| 546 | if (props.routeDraftId !== null) { | |
| 547 | const draftId = DraftId.make(props.routeDraftId); | |
| 548 | const store = useComposerDraftStore.getState(); | |
| 549 | const session = store.getDraftSession(draftId); | |
| 550 | const composer = store.getComposerDraft(draftId); | |
| 551 | row = | |
| 552 | session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) | |
| 553 | ? { draftId, session, composer } | |
| 554 | : null; | |
| 555 | } | |
| 556 | setFrozenActive({ routeDraftId: props.routeDraftId, row }); | |
| 557 | } | |
| 558 | const drafts = useMemo(() => { | |
| 559 | const rows: SidebarDraftRowData[] = []; | |
| 560 | // Every non-promoted session with content gets a row, mapped or not: | |
| 561 | // new-thread surfaces mint fresh drafts and leave invested ones behind | |
| 562 | // unmapped, so the mapping only knows about the latest per project. | |
| 563 | for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { | |
| 564 | if (session.promotedTo != null) { | |
| 565 | continue; | |
| 566 | } | |
| 567 | if ( | |
| 568 | props.scopedProjectKeys !== null && | |
| 569 | !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) | |
| 570 | ) { | |
| 571 | continue; | |
| 572 | } | |
| 573 | if (draftKey === props.routeDraftId) { | |
| 574 | // Open draft: render the frozen entry snapshot, or nothing for a | |
| 575 | // draft that has never been left. Gated on the LIVE session above so | |
| 576 | // send/discard still removes the row immediately. | |
| 577 | if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { | |
| 578 | rows.push(frozenActive.row); | |
| 579 | } | |
| 580 | continue; | |
| 581 | } | |
| 582 | const composer = draftsByThreadKey[draftKey]; | |
| 583 | if (!composer || !composerDraftHasUserContent(composer)) { | |
| 584 | continue; | |
| 585 | } | |
| 586 | rows.push({ draftId: DraftId.make(draftKey), session, composer }); | |
| 587 | } | |
| 588 | rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); | |
| 589 | return rows; | |
| 590 | }, [ | |
| 591 | draftThreadsByThreadKey, | |
| 592 | draftsByThreadKey, | |
| 593 | frozenActive, | |
| 594 | props.routeDraftId, | |
| 595 | props.scopedProjectKeys, | |
| 596 | ]); | |
| 597 | const handleDiscard = useCallback( | |
| 598 | (draftId: DraftId) => { | |
| 599 | // The /draft/$draftId route redirects home on its own when the draft | |
| 600 | // it renders disappears, so discarding the open draft needs no | |
| 601 | // special-casing here. | |
| 602 | clearDraftThread(draftId); | |
| 603 | }, | |
| 604 | [clearDraftThread], | |
| 605 | ); | |
| 606 | if (drafts.length === 0) { | |
| 607 | return null; | |
| 608 | } | |
| 609 | return ( | |
| 610 | <> | |
| 611 | {drafts.map(({ composer, draftId, session }) => { | |
| 612 | const projectKey = `${session.environmentId}:${session.projectId}`; | |
| 613 | return ( | |
| 614 | <SidebarDraftRow | |
| 615 | key={draftId} | |
| 616 | draftId={draftId} | |
| 617 | session={session} | |
| 618 | composer={composer} | |
| 619 | projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} | |
| 620 | projectCwd={props.projectCwdByKey.get(projectKey) ?? null} | |
| 621 | isActive={draftId === props.routeDraftId} | |
| 622 | onNavigate={props.onNavigateToDraft} | |
| 623 | onDiscard={handleDiscard} | |
| 624 | /> | |
| 625 | ); | |
| 626 | })} | |
| 627 | <li | |
| 628 | aria-hidden | |
| 629 | data-testid="sidebar-draft-divider" | |
| 630 | className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60" | |
| 631 | /> | |
| 632 | </> | |
| 633 | ); | |
| 634 | }); | |
| 635 | ||
| 407 | 636 | const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 408 | 637 | thread: SidebarThreadSummary; |
| 409 | 638 | variant: "card" | "slim"; |
| ... | ... | @@ -1554,6 +1783,32 @@ export default function Sidebar() { |
| 1554 | 1783 | setProjectScopeKey(null); |
| 1555 | 1784 | } |
| 1556 | 1785 | }, [projectScopeKey, scopedProjectGroup]); |
| 1786 | // Count-only subscription: the parent needs "are there draft rows" for the | |
| 1787 | // empty state, while SidebarDraftBlock owns the per-keystroke content | |
| 1788 | // subscription. Selecting a number keeps typing in a draft composer from | |
| 1789 | // re-rendering the whole sidebar. Approximates the block's row filter | |
| 1790 | // (every non-promoted session with content); it can overcount by one for | |
| 1791 | // an open never-left draft, which only softens the empty state. | |
| 1792 | const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; | |
| 1793 | const visibleDraftSessionCount = useComposerDraftStore((store) => { | |
| 1794 | let count = 0; | |
| 1795 | for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { | |
| 1796 | if (session.promotedTo != null) { | |
| 1797 | continue; | |
| 1798 | } | |
| 1799 | if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { | |
| 1800 | continue; | |
| 1801 | } | |
| 1802 | if ( | |
| 1803 | scopedProjectKeys !== null && | |
| 1804 | !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) | |
| 1805 | ) { | |
| 1806 | continue; | |
| 1807 | } | |
| 1808 | count += 1; | |
| 1809 | } | |
| 1810 | return count; | |
| 1811 | }); | |
| 1557 | 1812 | // Scope flips drop the selection: rows selected under the old scope may be |
| 1558 | 1813 | // hidden now, and bulk actions must never count or touch invisible rows. |
| 1559 | 1814 | useEffect(() => { |
| ... | ... | @@ -1876,6 +2131,21 @@ export default function Sidebar() { |
| 1876 | 2131 | [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], |
| 1877 | 2132 | ); |
| 1878 | 2133 | |
| 2134 | const navigateToDraft = useCallback( | |
| 2135 | (draftId: DraftId) => { | |
| 2136 | // Unconditional: also drops a stale selection anchor left by | |
| 2137 | // plain-click navigation, so a later shift-click starts fresh | |
| 2138 | // instead of ranging from a row that is no longer the context. | |
| 2139 | // (clearSelection no-ops when there is nothing to clear.) | |
| 2140 | clearSelection(); | |
| 2141 | if (isMobile) { | |
| 2142 | setOpenMobile(false); | |
| 2143 | } | |
| 2144 | void router.navigate({ to: "/draft/$draftId", params: { draftId } }); | |
| 2145 | }, | |
| 2146 | [clearSelection, isMobile, router, setOpenMobile], | |
| 2147 | ); | |
| 2148 | ||
| 1879 | 2149 | const clearThreadSearch = useCallback(() => { |
| 1880 | 2150 | setThreadSearchQuery(""); |
| 1881 | 2151 | setActiveSearchResultIndex(0); |
| ... | ... | @@ -3198,13 +3468,22 @@ export default function Sidebar() { |
| 3198 | 3468 | /> |
| 3199 | 3469 | ); |
| 3200 | 3470 | }; |
| 3201 | // Pinned block: full cards above the inbox, closed by a | |
| 3202 | // thin divider (the pin glyphs carry the meaning, so no | |
| 3203 | // header text). Vanishes entirely at count 0. | |
| 3204 | // Rows render in the one shared pinned order; only | |
| 3471 | // Draft block above everything, then the pinned block: | |
| 3472 | // full cards above the inbox, closed by a thin divider (the | |
| 3473 | // pin glyphs carry the meaning, so no header text). Both | |
| 3474 | // vanish entirely at count 0. | |
| 3475 | // Pinned rows render in the one shared pinned order; only | |
| 3205 | 3476 | // reorder-capable rows register as sortable (legacy-server |
| 3206 | 3477 | // pins render in place as plain rows). |
| 3207 | 3478 | const items: ReactNode[] = [ |
| 3479 | <SidebarDraftBlock | |
| 3480 | key="draft-sessions" | |
| 3481 | projectDisplayNameByKey={projectDisplayNameByKey} | |
| 3482 | projectCwdByKey={projectCwdByKey} | |
| 3483 | scopedProjectKeys={scopedProjectKeys} | |
| 3484 | routeDraftId={routeDraftIdForRows} | |
| 3485 | onNavigateToDraft={navigateToDraft} | |
| 3486 | />, | |
| 3208 | 3487 | <DndContext |
| 3209 | 3488 | key="pinned-dnd" |
| 3210 | 3489 | sensors={pinnedDndSensors} |
| ... | ... | @@ -3340,6 +3619,7 @@ export default function Sidebar() { |
| 3340 | 3619 | </TooltipProvider> |
| 3341 | 3620 | ) : null} |
| 3342 | 3621 | {!isSearchingThreads && |
| 3622 | visibleDraftSessionCount === 0 && | |
| 3343 | 3623 | pinnedThreads.length + |
| 3344 | 3624 | activeThreads.length + |
| 3345 | 3625 | snoozedThreads.length + |
apps/web/src/composerDraftStore.test.ts+34-2| ... | ... | @@ -836,10 +836,9 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 836 | 836 | } |
| 837 | 837 | }); |
| 838 | 838 | |
| 839 | it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { | |
| 839 | it("clears empty composer drafts when remapping a project to a new draft thread", () => { | |
| 840 | 840 | const store = useComposerDraftStore.getState(); |
| 841 | 841 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
| 842 | store.setPrompt(draftId, "orphan me"); | |
| 843 | 842 | |
| 844 | 843 | store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); |
| 845 | 844 | |
| ... | ... | @@ -850,6 +849,39 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 850 | 849 | expect(draftByKey(draftId)).toBeUndefined(); |
| 851 | 850 | }); |
| 852 | 851 | |
| 852 | it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { | |
| 853 | const store = useComposerDraftStore.getState(); | |
| 854 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 855 | store.setPrompt(draftId, "keep me around"); | |
| 856 | ||
| 857 | store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); | |
| 858 | ||
| 859 | // The mapping moved to the fresh draft... | |
| 860 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( | |
| 861 | otherThreadId, | |
| 862 | ); | |
| 863 | // ...but the invested draft survives with its content for the sidebar | |
| 864 | // draft rows to surface. | |
| 865 | expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId); | |
| 866 | expect(draftByKey(draftId)?.prompt).toBe("keep me around"); | |
| 867 | }); | |
| 868 | ||
| 869 | it("clears every session for a project, including unmapped invested drafts", () => { | |
| 870 | const store = useComposerDraftStore.getState(); | |
| 871 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 872 | store.setPrompt(draftId, "invested"); | |
| 873 | // The remap leaves the invested draft alive unmapped; project removal | |
| 874 | // must still sweep it, or its sidebar row outlives the project. | |
| 875 | store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); | |
| 876 | ||
| 877 | store.clearProjectDraftThreadId(projectRef); | |
| 878 | ||
| 879 | expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); | |
| 880 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); | |
| 881 | expect(useComposerDraftStore.getState().getDraftThread(otherDraftId)).toBeNull(); | |
| 882 | expect(draftByKey(draftId)).toBeUndefined(); | |
| 883 | }); | |
| 884 | ||
| 853 | 885 | it("keeps composer drafts when the thread is still mapped by another project", () => { |
| 854 | 886 | const store = useComposerDraftStore.getState(); |
| 855 | 887 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
apps/web/src/composerDraftStore.ts+108-10| ... | ... | @@ -277,6 +277,31 @@ export interface ComposerThreadDraftState { |
| 277 | 277 | interactionMode: ProviderInteractionMode | null; |
| 278 | 278 | } |
| 279 | 279 | |
| 280 | /** | |
| 281 | * True when the user has invested real content in the draft: typed text or | |
| 282 | * any attachment/context. Model selection and mode choices alone do not | |
| 283 | * count — those are ambient defaults, not work in progress. Used by the | |
| 284 | * sidebar draft rows (which draft sessions deserve a row) and by new-thread | |
| 285 | * resurrection (a draft with content keeps its settings instead of being | |
| 286 | * reset to defaults). | |
| 287 | */ | |
| 288 | export function composerDraftHasUserContent( | |
| 289 | draft: ComposerThreadDraftState | null | undefined, | |
| 290 | ): boolean { | |
| 291 | if (!draft) { | |
| 292 | return false; | |
| 293 | } | |
| 294 | return ( | |
| 295 | draft.prompt.trim().length > 0 || | |
| 296 | draft.images.length > 0 || | |
| 297 | draft.persistedAttachments.length > 0 || | |
| 298 | draft.terminalContexts.length > 0 || | |
| 299 | draft.elementContexts.length > 0 || | |
| 300 | draft.previewAnnotations.length > 0 || | |
| 301 | draft.reviewComments.length > 0 | |
| 302 | ); | |
| 303 | } | |
| 304 | ||
| 280 | 305 | /** |
| 281 | 306 | * Mutable routing and execution context for a pre-thread draft session. |
| 282 | 307 | * |
| ... | ... | @@ -1831,6 +1856,24 @@ function migratePersistedComposerDraftStoreState( |
| 1831 | 1856 | function partializeComposerDraftStoreState( |
| 1832 | 1857 | state: ComposerDraftStoreState, |
| 1833 | 1858 | ): PersistedComposerDraftStoreState { |
| 1859 | // Draft sessions worth persisting: mapped (a new-thread flow targets | |
| 1860 | // them), promoting (mid-send), or holding real user content (they back a | |
| 1861 | // sidebar row). Everything else is a zombie — and its composer blob must | |
| 1862 | // be dropped WITH it, or model/mode-only entries would persist forever | |
| 1863 | // keyed to a session that no longer exists. | |
| 1864 | const mappedDraftKeys = new Set( | |
| 1865 | Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), | |
| 1866 | ); | |
| 1867 | const keptSessionKeys = new Set( | |
| 1868 | Object.entries(state.draftThreadsByThreadKey) | |
| 1869 | .filter( | |
| 1870 | ([threadKey, draftThread]) => | |
| 1871 | mappedDraftKeys.has(threadKey) || | |
| 1872 | isDraftThreadPromoting(draftThread) || | |
| 1873 | composerDraftHasUserContent(state.draftsByThreadKey[threadKey]), | |
| 1874 | ) | |
| 1875 | .map(([threadKey]) => threadKey), | |
| 1876 | ); | |
| 1834 | 1877 | const persistedDraftsByThreadKey: DeepMutable< |
| 1835 | 1878 | PersistedComposerDraftStoreState["draftsByThreadKey"] |
| 1836 | 1879 | > = {}; |
| ... | ... | @@ -1838,6 +1881,11 @@ function partializeComposerDraftStoreState( |
| 1838 | 1881 | if (typeof threadKey !== "string" || threadKey.length === 0) { |
| 1839 | 1882 | continue; |
| 1840 | 1883 | } |
| 1884 | // Composer content keyed to a dropped draft session goes with it. | |
| 1885 | // Server-thread keys have no session entry and are unaffected. | |
| 1886 | if (state.draftThreadsByThreadKey[threadKey] !== undefined && !keptSessionKeys.has(threadKey)) { | |
| 1887 | continue; | |
| 1888 | } | |
| 1841 | 1889 | const hasModelData = |
| 1842 | 1890 | Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null; |
| 1843 | 1891 | if ( |
| ... | ... | @@ -1911,9 +1959,18 @@ function partializeComposerDraftStoreState( |
| 1911 | 1959 | }; |
| 1912 | 1960 | persistedDraftsByThreadKey[threadKey] = persistedDraft; |
| 1913 | 1961 | } |
| 1962 | const persistedDraftThreadsByThreadKey: DeepMutable< | |
| 1963 | PersistedComposerDraftStoreState["draftThreadsByThreadKey"] | |
| 1964 | > = {}; | |
| 1965 | for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { | |
| 1966 | if (!keptSessionKeys.has(threadKey)) { | |
| 1967 | continue; | |
| 1968 | } | |
| 1969 | persistedDraftThreadsByThreadKey[threadKey] = draftThread; | |
| 1970 | } | |
| 1914 | 1971 | return { |
| 1915 | 1972 | draftsByThreadKey: persistedDraftsByThreadKey, |
| 1916 | draftThreadsByThreadKey: state.draftThreadsByThreadKey, | |
| 1973 | draftThreadsByThreadKey: persistedDraftThreadsByThreadKey, | |
| 1917 | 1974 | logicalProjectDraftThreadKeyByLogicalProjectKey: |
| 1918 | 1975 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, |
| 1919 | 1976 | stickyModelSelectionByProvider: compactModelSelectionByProvider( |
| ... | ... | @@ -2214,7 +2271,25 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2214 | 2271 | return get().getDraftSessionByProjectRef(projectRef); |
| 2215 | 2272 | }, |
| 2216 | 2273 | getDraftSessionByProjectRef: (projectRef) => { |
| 2217 | for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { | |
| 2274 | const state = get(); | |
| 2275 | // Mapped drafts win: a project can also own older unmapped drafts | |
| 2276 | // (invested ones left behind by a remap), but "the" draft for a | |
| 2277 | // project is the one new-thread flows currently target. | |
| 2278 | for (const draftId of Object.values( | |
| 2279 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 2280 | )) { | |
| 2281 | const draftThread = state.draftThreadsByThreadKey[draftId]; | |
| 2282 | if (!draftThread || isDraftThreadPromoting(draftThread)) { | |
| 2283 | continue; | |
| 2284 | } | |
| 2285 | if ( | |
| 2286 | draftThread.projectId === projectRef.projectId && | |
| 2287 | draftThread.environmentId === projectRef.environmentId | |
| 2288 | ) { | |
| 2289 | return toProjectDraftSession(DraftId.make(draftId), draftThread); | |
| 2290 | } | |
| 2291 | } | |
| 2292 | for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { | |
| 2218 | 2293 | if (isDraftThreadPromoting(draftThread)) { |
| 2219 | 2294 | continue; |
| 2220 | 2295 | } |
| ... | ... | @@ -2289,6 +2364,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2289 | 2364 | previousThreadKeyForLogicalProject === undefined |
| 2290 | 2365 | ? undefined |
| 2291 | 2366 | : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2367 | // A remap only garbage-collects the previous draft when the user | |
| 2368 | // never invested content in it. A draft with typed text or | |
| 2369 | // attachments stays alive unmapped — the sidebar draft rows list | |
| 2370 | // every such session, so "new thread" can mint a fresh draft | |
| 2371 | // without destroying the one the user walked away from. | |
| 2292 | 2372 | if ( |
| 2293 | 2373 | previousThreadKeyForLogicalProject && |
| 2294 | 2374 | previousThreadKeyForLogicalProject !== draftId && |
| ... | ... | @@ -2296,7 +2376,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2296 | 2376 | nextLogicalProjectDraftThreadKeyByLogicalProjectKey, |
| 2297 | 2377 | previousThreadKeyForLogicalProject, |
| 2298 | 2378 | ) && |
| 2299 | !isDraftThreadPromoting(previousDraftThread) | |
| 2379 | !isDraftThreadPromoting(previousDraftThread) && | |
| 2380 | !composerDraftHasUserContent( | |
| 2381 | state.draftsByThreadKey[previousThreadKeyForLogicalProject], | |
| 2382 | ) | |
| 2300 | 2383 | ) { |
| 2301 | 2384 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2302 | 2385 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { |
| ... | ... | @@ -2405,15 +2488,30 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2405 | 2488 | }, |
| 2406 | 2489 | clearProjectDraftThreadId: (projectRef) => { |
| 2407 | 2490 | set((state) => { |
| 2408 | const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find( | |
| 2409 | ([, draftThread]) => | |
| 2410 | draftThread.projectId === projectRef.projectId && | |
| 2411 | draftThread.environmentId === projectRef.environmentId, | |
| 2412 | ); | |
| 2413 | if (!matchingThreadEntry) { | |
| 2491 | // A project can own several sessions (invested drafts survive | |
| 2492 | // remaps unmapped), so project removal must sweep them all — a | |
| 2493 | // leftover would render a sidebar row for a project that no | |
| 2494 | // longer exists. | |
| 2495 | const matchingThreadKeys = Object.entries(state.draftThreadsByThreadKey) | |
| 2496 | .filter( | |
| 2497 | ([, draftThread]) => | |
| 2498 | draftThread.projectId === projectRef.projectId && | |
| 2499 | draftThread.environmentId === projectRef.environmentId, | |
| 2500 | ) | |
| 2501 | .map(([threadKey]) => threadKey); | |
| 2502 | if (matchingThreadKeys.length === 0) { | |
| 2414 | 2503 | return state; |
| 2415 | 2504 | } |
| 2416 | return removeDraftThreadReferences(state, matchingThreadEntry[0]); | |
| 2505 | let nextState = { | |
| 2506 | draftsByThreadKey: state.draftsByThreadKey, | |
| 2507 | draftThreadsByThreadKey: state.draftThreadsByThreadKey, | |
| 2508 | logicalProjectDraftThreadKeyByLogicalProjectKey: | |
| 2509 | state.logicalProjectDraftThreadKeyByLogicalProjectKey, | |
| 2510 | }; | |
| 2511 | for (const threadKey of matchingThreadKeys) { | |
| 2512 | nextState = removeDraftThreadReferences(nextState, threadKey); | |
| 2513 | } | |
| 2514 | return nextState; | |
| 2417 | 2515 | }); |
| 2418 | 2516 | }, |
| 2419 | 2517 | clearProjectDraftThreadById: (projectRef, threadRef) => { |
apps/web/src/hooks/useHandleNewThread.ts+52-25| ... | ... | @@ -8,6 +8,7 @@ import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts" |
| 8 | 8 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 9 | 9 | import { useCallback, useMemo } from "react"; |
| 10 | 10 | import { |
| 11 | composerDraftHasUserContent, | |
| 11 | 12 | markPromotedDraftThreadByRef, |
| 12 | 13 | type DraftThreadEnvMode, |
| 13 | 14 | type DraftThreadState, |
| ... | ... | @@ -160,48 +161,66 @@ export function useNewThreadHandler() { |
| 160 | 161 | if (storedDraftThreadRef && reusableStoredDraftThread === null) { |
| 161 | 162 | markPromotedDraftThreadByRef(storedDraftThreadRef); |
| 162 | 163 | } |
| 164 | // New-thread surfaces (button, hotkeys, "/" landing, palette) only | |
| 165 | // ever reuse a draft the user has NOT invested in. A draft with typed | |
| 166 | // text or attachments is work in progress: it stays alive where it is | |
| 167 | // (reachable from the sidebar draft rows) and this request mints a | |
| 168 | // fresh draft instead — the remap in the store preserves invested | |
| 169 | // drafts rather than deleting them. | |
| 170 | const emptyStoredDraftThread = | |
| 171 | reusableStoredDraftThread && | |
| 172 | !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId)) | |
| 173 | ? reusableStoredDraftThread | |
| 174 | : null; | |
| 163 | 175 | const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget |
| 164 | 176 | ? currentRouteTarget.kind === "server" |
| 165 | 177 | ? getDraftThread(currentRouteTarget.threadRef) |
| 166 | 178 | : getDraftSession(currentRouteTarget.draftId) |
| 167 | 179 | : null; |
| 168 | if (reusableStoredDraftThread) { | |
| 180 | if (emptyStoredDraftThread) { | |
| 169 | 181 | return (async () => { |
| 170 | 182 | const isDraftAlreadyOpen = |
| 171 | 183 | currentRouteTarget?.kind === "draft" && |
| 172 | currentRouteTarget.draftId === reusableStoredDraftThread.draftId; | |
| 184 | currentRouteTarget.draftId === emptyStoredDraftThread.draftId; | |
| 173 | 185 | const hasExplicitWorkspaceOption = |
| 174 | 186 | hasBranchOption || |
| 175 | 187 | hasWorktreePathOption || |
| 176 | 188 | hasEnvModeOption || |
| 177 | 189 | hasStartFromOriginOption; |
| 178 | // Resurrecting a stored draft must not resurrect its stale context: | |
| 179 | // explicit workspace options win outright; otherwise the env context | |
| 180 | // resets to the configured defaults so drafts seeded before a | |
| 181 | // defaults change (or by the old carry-over behavior) stop landing | |
| 182 | // on "current checkout" branches forever. Composer text is | |
| 183 | // preserved. When the draft is already open and no options were | |
| 184 | // passed, leave it alone entirely — the user may have just picked a | |
| 185 | // branch in the composer. | |
| 190 | // Resurrecting an empty stored draft must not resurrect its stale | |
| 191 | // context: explicit workspace options win outright; otherwise the | |
| 192 | // env context resets to the configured defaults so drafts seeded | |
| 193 | // before a defaults change (or by the old carry-over behavior) stop | |
| 194 | // landing on "current checkout" branches forever. When the draft is | |
| 195 | // already open and no options were passed, leave it alone entirely — | |
| 196 | // the user may have just picked a branch in the composer. | |
| 186 | 197 | let workspaceContext: NewThreadWorkspaceOptions | null = null; |
| 187 | 198 | if (hasExplicitWorkspaceOption) { |
| 188 | 199 | workspaceContext = pickExplicitWorkspaceOptions(options); |
| 189 | 200 | } else if (!isDraftAlreadyOpen) { |
| 190 | 201 | const defaultEnvMode = await resolveDefaultEnvMode(); |
| 191 | 202 | // The await yields. If the draft was opened (a concurrent |
| 192 | // invocation's navigation landed) or promoted to a real thread | |
| 193 | // in the meantime, this invocation is a stale loser: resetting | |
| 194 | // context, remapping (which wipes branch/worktree on a member | |
| 195 | // change), or navigating would all clobber state written after | |
| 196 | // the snapshot above. Bail out entirely — the winner already | |
| 197 | // did this work. | |
| 203 | // invocation's navigation landed), promoted to a real thread, | |
| 204 | // remapped away (a concurrent invocation registered a fresh | |
| 205 | // draft — remapping back would evict the winner and let the | |
| 206 | // store GC it), or gained content (no longer a reusable empty | |
| 207 | // draft) in the meantime, this invocation is a stale loser: | |
| 208 | // resetting context, remapping, or navigating would all clobber | |
| 209 | // state written after the snapshot above. Bail out entirely — | |
| 210 | // the winner already did this work. | |
| 198 | 211 | const routeTargetNow = getCurrentRouteTarget(); |
| 199 | 212 | const openedMeanwhile = |
| 200 | 213 | routeTargetNow?.kind === "draft" && |
| 201 | routeTargetNow.draftId === reusableStoredDraftThread.draftId; | |
| 214 | routeTargetNow.draftId === emptyStoredDraftThread.draftId; | |
| 202 | 215 | const promotedMeanwhile = |
| 203 | 216 | storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null; |
| 204 | if (openedMeanwhile || promotedMeanwhile) { | |
| 217 | const remappedMeanwhile = | |
| 218 | getDraftSessionByLogicalProjectKey(logicalProjectKey)?.draftId !== | |
| 219 | emptyStoredDraftThread.draftId; | |
| 220 | const investedMeanwhile = composerDraftHasUserContent( | |
| 221 | getComposerDraft(emptyStoredDraftThread.draftId), | |
| 222 | ); | |
| 223 | if (openedMeanwhile || promotedMeanwhile || remappedMeanwhile || investedMeanwhile) { | |
| 205 | 224 | return; |
| 206 | 225 | } |
| 207 | 226 | workspaceContext = { |
| ... | ... | @@ -215,7 +234,7 @@ export function useNewThreadHandler() { |
| 215 | 234 | }; |
| 216 | 235 | } |
| 217 | 236 | if (workspaceContext) { |
| 218 | setDraftThreadContext(reusableStoredDraftThread.draftId, { | |
| 237 | setDraftThreadContext(emptyStoredDraftThread.draftId, { | |
| 219 | 238 | ...workspaceContext, |
| 220 | 239 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 221 | 240 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| ... | ... | @@ -224,7 +243,7 @@ export function useNewThreadHandler() { |
| 224 | 243 | // The carried selection is a complete snapshot of the viewed |
| 225 | 244 | // thread's model state: absent options mean "no options", not |
| 226 | 245 | // "keep the stale draft's options". |
| 227 | setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, { | |
| 246 | setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, { | |
| 228 | 247 | replaceOptions: true, |
| 229 | 248 | }); |
| 230 | 249 | } |
| ... | ... | @@ -236,9 +255,9 @@ export function useNewThreadHandler() { |
| 236 | 255 | setLogicalProjectDraftThreadId( |
| 237 | 256 | logicalProjectKey, |
| 238 | 257 | projectRef, |
| 239 | reusableStoredDraftThread.draftId, | |
| 258 | emptyStoredDraftThread.draftId, | |
| 240 | 259 | { |
| 241 | threadId: reusableStoredDraftThread.threadId, | |
| 260 | threadId: emptyStoredDraftThread.threadId, | |
| 242 | 261 | ...workspaceContext, |
| 243 | 262 | ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), |
| 244 | 263 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| ... | ... | @@ -250,13 +269,13 @@ export function useNewThreadHandler() { |
| 250 | 269 | const routeTargetAfterWrites = getCurrentRouteTarget(); |
| 251 | 270 | if ( |
| 252 | 271 | routeTargetAfterWrites?.kind === "draft" && |
| 253 | routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId | |
| 272 | routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId | |
| 254 | 273 | ) { |
| 255 | 274 | return; |
| 256 | 275 | } |
| 257 | 276 | await router.navigate({ |
| 258 | 277 | to: "/draft/$draftId", |
| 259 | params: { draftId: reusableStoredDraftThread.draftId }, | |
| 278 | params: { draftId: emptyStoredDraftThread.draftId }, | |
| 260 | 279 | replace: options?.replace ?? false, |
| 261 | 280 | }); |
| 262 | 281 | })(); |
| ... | ... | @@ -266,7 +285,10 @@ export function useNewThreadHandler() { |
| 266 | 285 | latestActiveDraftThread && |
| 267 | 286 | currentRouteTarget?.kind === "draft" && |
| 268 | 287 | latestActiveDraftThread.logicalProjectKey === logicalProjectKey && |
| 269 | latestActiveDraftThread.promotedTo == null | |
| 288 | latestActiveDraftThread.promotedTo == null && | |
| 289 | // Same content rule as above: a new-thread request while viewing an | |
| 290 | // invested draft mints a fresh one instead of repurposing it. | |
| 291 | !composerDraftHasUserContent(getComposerDraft(currentRouteTarget.draftId)) | |
| 270 | 292 | ) { |
| 271 | 293 | if ( |
| 272 | 294 | hasBranchOption || |
| ... | ... | @@ -298,6 +320,11 @@ export function useNewThreadHandler() { |
| 298 | 320 | const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey); |
| 299 | 321 | if ( |
| 300 | 322 | racedDraft && |
| 323 | // Only a draft REGISTERED during the await counts as a raced | |
| 324 | // winner. An invested draft this invocation deliberately declined | |
| 325 | // to reuse is still mapped at this point — reusing it here would | |
| 326 | // silently undo mint-fresh semantics. | |
| 327 | racedDraft.draftId !== storedDraftThread?.draftId && | |
| 301 | 328 | readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null |
| 302 | 329 | ) { |
| 303 | 330 | // Same remap the reuse paths above perform: point the draft at the |