authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 23:29:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 20:29:21-07:00
log05eb051184ac4d486795ac6f8be29129b8b8845f
treef18a87a6508b349b9e5fd1335081cb484c25bac7
parentddaa6afef55e0da53950c7ed45f7d91b717475a5
signature Signed by PGP key B5690EEEBB952194

feat(web): keep unsent drafts one click away in the sidebar (#5777)

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
163163import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
164164import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
165165import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
166import { useComposerDraftStore } from "../composerDraftStore";
166import {
167 composerDraftHasUserContent,
168 DraftId,
169 useComposerDraftStore,
170 type ComposerThreadDraftState,
171 type DraftSessionState,
172} from "../composerDraftStore";
167173
168174// Settled-tail paging: recent history is the common lookup; the deep tail
169175// stays behind an explicit Show more.
......@@ -404,6 +410,229 @@ function SortablePinnedThreadRow(props: {
404410 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
405411}
406412
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.
420const 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
514interface 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.
524const 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
407636const SidebarThreadRow = memo(function SidebarThreadRow(props: {
408637 thread: SidebarThreadSummary;
409638 variant: "card" | "slim";
......@@ -1554,6 +1783,32 @@ export default function Sidebar() {
15541783 setProjectScopeKey(null);
15551784 }
15561785 }, [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 });
15571812 // Scope flips drop the selection: rows selected under the old scope may be
15581813 // hidden now, and bulk actions must never count or touch invisible rows.
15591814 useEffect(() => {
......@@ -1876,6 +2131,21 @@ export default function Sidebar() {
18762131 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
18772132 );
18782133
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
18792149 const clearThreadSearch = useCallback(() => {
18802150 setThreadSearchQuery("");
18812151 setActiveSearchResultIndex(0);
......@@ -3198,13 +3468,22 @@ export default function Sidebar() {
31983468 />
31993469 );
32003470 };
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
32053476 // reorder-capable rows register as sortable (legacy-server
32063477 // pins render in place as plain rows).
32073478 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 />,
32083487 <DndContext
32093488 key="pinned-dnd"
32103489 sensors={pinnedDndSensors}
......@@ -3340,6 +3619,7 @@ export default function Sidebar() {
33403619 </TooltipProvider>
33413620 ) : null}
33423621 {!isSearchingThreads &&
3622 visibleDraftSessionCount === 0 &&
33433623 pinnedThreads.length +
33443624 activeThreads.length +
33453625 snoozedThreads.length +
apps/web/src/composerDraftStore.test.ts+34-2
......@@ -836,10 +836,9 @@ describe("composerDraftStore project draft thread mapping", () => {
836836 }
837837 });
838838
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", () => {
840840 const store = useComposerDraftStore.getState();
841841 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
842 store.setPrompt(draftId, "orphan me");
843842
844843 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
845844
......@@ -850,6 +849,39 @@ describe("composerDraftStore project draft thread mapping", () => {
850849 expect(draftByKey(draftId)).toBeUndefined();
851850 });
852851
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
853885 it("keeps composer drafts when the thread is still mapped by another project", () => {
854886 const store = useComposerDraftStore.getState();
855887 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
apps/web/src/composerDraftStore.ts+108-10
......@@ -277,6 +277,31 @@ export interface ComposerThreadDraftState {
277277 interactionMode: ProviderInteractionMode | null;
278278}
279279
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 */
288export 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
280305/**
281306 * Mutable routing and execution context for a pre-thread draft session.
282307 *
......@@ -1831,6 +1856,24 @@ function migratePersistedComposerDraftStoreState(
18311856function partializeComposerDraftStoreState(
18321857 state: ComposerDraftStoreState,
18331858): 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 );
18341877 const persistedDraftsByThreadKey: DeepMutable<
18351878 PersistedComposerDraftStoreState["draftsByThreadKey"]
18361879 > = {};
......@@ -1838,6 +1881,11 @@ function partializeComposerDraftStoreState(
18381881 if (typeof threadKey !== "string" || threadKey.length === 0) {
18391882 continue;
18401883 }
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 }
18411889 const hasModelData =
18421890 Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null;
18431891 if (
......@@ -1911,9 +1959,18 @@ function partializeComposerDraftStoreState(
19111959 };
19121960 persistedDraftsByThreadKey[threadKey] = persistedDraft;
19131961 }
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 }
19141971 return {
19151972 draftsByThreadKey: persistedDraftsByThreadKey,
1916 draftThreadsByThreadKey: state.draftThreadsByThreadKey,
1973 draftThreadsByThreadKey: persistedDraftThreadsByThreadKey,
19171974 logicalProjectDraftThreadKeyByLogicalProjectKey:
19181975 state.logicalProjectDraftThreadKeyByLogicalProjectKey,
19191976 stickyModelSelectionByProvider: compactModelSelectionByProvider(
......@@ -2214,7 +2271,25 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22142271 return get().getDraftSessionByProjectRef(projectRef);
22152272 },
22162273 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)) {
22182293 if (isDraftThreadPromoting(draftThread)) {
22192294 continue;
22202295 }
......@@ -2289,6 +2364,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22892364 previousThreadKeyForLogicalProject === undefined
22902365 ? undefined
22912366 : 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.
22922372 if (
22932373 previousThreadKeyForLogicalProject &&
22942374 previousThreadKeyForLogicalProject !== draftId &&
......@@ -2296,7 +2376,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22962376 nextLogicalProjectDraftThreadKeyByLogicalProjectKey,
22972377 previousThreadKeyForLogicalProject,
22982378 ) &&
2299 !isDraftThreadPromoting(previousDraftThread)
2379 !isDraftThreadPromoting(previousDraftThread) &&
2380 !composerDraftHasUserContent(
2381 state.draftsByThreadKey[previousThreadKeyForLogicalProject],
2382 )
23002383 ) {
23012384 delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject];
23022385 if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) {
......@@ -2405,15 +2488,30 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
24052488 },
24062489 clearProjectDraftThreadId: (projectRef) => {
24072490 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) {
24142503 return state;
24152504 }
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;
24172515 });
24182516 },
24192517 clearProjectDraftThreadById: (projectRef, threadRef) => {
apps/web/src/hooks/useHandleNewThread.ts+52-25
......@@ -8,6 +8,7 @@ import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"
88import { useParams, useRouter } from "@tanstack/react-router";
99import { useCallback, useMemo } from "react";
1010import {
11 composerDraftHasUserContent,
1112 markPromotedDraftThreadByRef,
1213 type DraftThreadEnvMode,
1314 type DraftThreadState,
......@@ -160,48 +161,66 @@ export function useNewThreadHandler() {
160161 if (storedDraftThreadRef && reusableStoredDraftThread === null) {
161162 markPromotedDraftThreadByRef(storedDraftThreadRef);
162163 }
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;
163175 const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget
164176 ? currentRouteTarget.kind === "server"
165177 ? getDraftThread(currentRouteTarget.threadRef)
166178 : getDraftSession(currentRouteTarget.draftId)
167179 : null;
168 if (reusableStoredDraftThread) {
180 if (emptyStoredDraftThread) {
169181 return (async () => {
170182 const isDraftAlreadyOpen =
171183 currentRouteTarget?.kind === "draft" &&
172 currentRouteTarget.draftId === reusableStoredDraftThread.draftId;
184 currentRouteTarget.draftId === emptyStoredDraftThread.draftId;
173185 const hasExplicitWorkspaceOption =
174186 hasBranchOption ||
175187 hasWorktreePathOption ||
176188 hasEnvModeOption ||
177189 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.
186197 let workspaceContext: NewThreadWorkspaceOptions | null = null;
187198 if (hasExplicitWorkspaceOption) {
188199 workspaceContext = pickExplicitWorkspaceOptions(options);
189200 } else if (!isDraftAlreadyOpen) {
190201 const defaultEnvMode = await resolveDefaultEnvMode();
191202 // 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.
198211 const routeTargetNow = getCurrentRouteTarget();
199212 const openedMeanwhile =
200213 routeTargetNow?.kind === "draft" &&
201 routeTargetNow.draftId === reusableStoredDraftThread.draftId;
214 routeTargetNow.draftId === emptyStoredDraftThread.draftId;
202215 const promotedMeanwhile =
203216 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) {
205224 return;
206225 }
207226 workspaceContext = {
......@@ -215,7 +234,7 @@ export function useNewThreadHandler() {
215234 };
216235 }
217236 if (workspaceContext) {
218 setDraftThreadContext(reusableStoredDraftThread.draftId, {
237 setDraftThreadContext(emptyStoredDraftThread.draftId, {
219238 ...workspaceContext,
220239 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
221240 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
......@@ -224,7 +243,7 @@ export function useNewThreadHandler() {
224243 // The carried selection is a complete snapshot of the viewed
225244 // thread's model state: absent options mean "no options", not
226245 // "keep the stale draft's options".
227 setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, {
246 setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, {
228247 replaceOptions: true,
229248 });
230249 }
......@@ -236,9 +255,9 @@ export function useNewThreadHandler() {
236255 setLogicalProjectDraftThreadId(
237256 logicalProjectKey,
238257 projectRef,
239 reusableStoredDraftThread.draftId,
258 emptyStoredDraftThread.draftId,
240259 {
241 threadId: reusableStoredDraftThread.threadId,
260 threadId: emptyStoredDraftThread.threadId,
242261 ...workspaceContext,
243262 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
244263 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
......@@ -250,13 +269,13 @@ export function useNewThreadHandler() {
250269 const routeTargetAfterWrites = getCurrentRouteTarget();
251270 if (
252271 routeTargetAfterWrites?.kind === "draft" &&
253 routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId
272 routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId
254273 ) {
255274 return;
256275 }
257276 await router.navigate({
258277 to: "/draft/$draftId",
259 params: { draftId: reusableStoredDraftThread.draftId },
278 params: { draftId: emptyStoredDraftThread.draftId },
260279 replace: options?.replace ?? false,
261280 });
262281 })();
......@@ -266,7 +285,10 @@ export function useNewThreadHandler() {
266285 latestActiveDraftThread &&
267286 currentRouteTarget?.kind === "draft" &&
268287 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))
270292 ) {
271293 if (
272294 hasBranchOption ||
......@@ -298,6 +320,11 @@ export function useNewThreadHandler() {
298320 const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);
299321 if (
300322 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 &&
301328 readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null
302329 ) {
303330 // Same remap the reuse paths above perform: point the draft at the