From 61041f51f771bbad7c223dadc1895f2a764fbde4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:40:03 -0700 Subject: [PATCH 1/6] feat(web): keep unsent drafts one click away in the sidebar Drafts with typed text or attachments now show as rows above the pinned block, frozen while open, gone on send/discard. New-thread surfaces always mint a fresh draft instead of resurrecting (and resetting) an invested one; the store keeps invested drafts alive unmapped when the per-project mapping moves. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 303 ++++++++++++++++++++++- apps/web/src/composerDraftStore.test.ts | 20 +- apps/web/src/composerDraftStore.ts | 77 +++++- apps/web/src/hooks/useHandleNewThread.ts | 55 ++-- 4 files changed, 427 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b16b2d38b26..cd74401ae21 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -163,7 +163,13 @@ import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./u import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { + composerDraftHasUserContent, + DraftId, + useComposerDraftStore, + type ComposerThreadDraftState, + type DraftSessionState, +} from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. @@ -404,6 +410,243 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } +// One unsent draft session the user has invested content in. Two lines: +// header packs the workspace context (project, model, env mode, branch) so +// the row itself proves the settings survived; the body is the typed prompt. +// Clicking is a plain navigation to /draft/$draftId — nothing about the +// draft is touched on the way back. While the draft is open the row renders +// a frozen snapshot (see SidebarDraftBlock); memoized so per-keystroke +// block re-renders skip it entirely. +const SidebarDraftRow = memo(function SidebarDraftRow(props: { + draftId: DraftId; + session: DraftSessionState; + composer: ComposerThreadDraftState; + providerEntryByInstanceId: ReadonlyMap; + projectTitle: string | null; + projectCwd: string | null; + isActive: boolean; + onNavigate: (draftId: DraftId) => void; + onDiscard: (draftId: DraftId) => void; +}) { + const { composer, draftId, onDiscard, onNavigate, session } = props; + const modelSelection = composer.activeProvider + ? (composer.modelSelectionByProvider[composer.activeProvider] ?? null) + : null; + const providerEntry = modelSelection + ? (props.providerEntryByInstanceId.get(modelSelection.instanceId) ?? null) + : null; + const selectedModel = providerEntry?.models.find((model) => model.slug === modelSelection?.model); + const modelLabel = modelSelection + ? selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : modelSelection.model + : null; + const contextLabel = [props.projectTitle, modelLabel, session.envMode, session.branch] + .filter((part): part is string => part !== null && part.length > 0) + .join(" · "); + const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; + // images mirrors persistedAttachments once rehydration finishes; before + // that only the persisted list is populated, hence max not sum. + const attachmentCount = + Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.terminalContexts.length + + composer.elementContexts.length + + composer.previewAnnotations.length + + composer.reviewComments.length; + const preview = + promptPreview.length > 0 + ? promptPreview + : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onNavigate(draftId); + } + }, + [draftId, onNavigate], + ); + const handleDiscard = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDiscard(draftId); + }, + [draftId, onDiscard], + ); + return ( +
  • +
    +
    +
    + + + + {contextLabel} + + + + +
    +
    {preview}
    +
    +
    +
  • + ); +}); + +interface SidebarDraftRowData { + draftId: DraftId; + session: DraftSessionState; + composer: ComposerThreadDraftState; +} + +// Draft sessions with user content, surfaced above the pinned block so an +// interrupted "new thread" stays one click away. Self-contained (own store +// subscription + closing divider) so per-keystroke composer updates +// re-render only this block, never the whole sidebar. Vanishes at count 0. +const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + providerEntryByInstanceId: ReadonlyMap; + projectDisplayNameByKey: ReadonlyMap; + projectCwdByKey: ReadonlyMap; + scopedProjectKeys: ReadonlySet | null; + routeDraftId: string | null; + onNavigateToDraft: (draftId: DraftId) => void; +}) { + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); + // The open draft's row is FROZEN at the moment the draft became the route: + // it stays visible (like a thread row) but never repaints while the user + // types. A draft that was never navigated away from has no snapshot to + // freeze, so a fresh typing session shows no row at all. Captured + // synchronously on route change (setState-during-render derived state) so + // the row never flickers out for a frame between route change and capture. + const [frozenActive, setFrozenActive] = useState<{ + routeDraftId: string | null; + row: SidebarDraftRowData | null; + }>({ routeDraftId: null, row: null }); + if (frozenActive.routeDraftId !== props.routeDraftId) { + let row: SidebarDraftRowData | null = null; + if (props.routeDraftId !== null) { + const draftId = DraftId.make(props.routeDraftId); + const store = useComposerDraftStore.getState(); + const session = store.getDraftSession(draftId); + const composer = store.getComposerDraft(draftId); + row = + session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) + ? { draftId, session, composer } + : null; + } + setFrozenActive({ routeDraftId: props.routeDraftId, row }); + } + const drafts = useMemo(() => { + const rows: SidebarDraftRowData[] = []; + // Every non-promoted session with content gets a row, mapped or not: + // new-thread surfaces mint fresh drafts and leave invested ones behind + // unmapped, so the mapping only knows about the latest per project. + for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if ( + props.scopedProjectKeys !== null && + !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + if (draftKey === props.routeDraftId) { + // Open draft: render the frozen entry snapshot, or nothing for a + // draft that has never been left. Gated on the LIVE session above so + // send/discard still removes the row immediately. + if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { + rows.push(frozenActive.row); + } + continue; + } + const composer = draftsByThreadKey[draftKey]; + if (!composer || !composerDraftHasUserContent(composer)) { + continue; + } + rows.push({ draftId: DraftId.make(draftKey), session, composer }); + } + rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); + return rows; + }, [ + draftThreadsByThreadKey, + draftsByThreadKey, + frozenActive, + props.routeDraftId, + props.scopedProjectKeys, + ]); + const handleDiscard = useCallback( + (draftId: DraftId) => { + // The /draft/$draftId route redirects home on its own when the draft + // it renders disappears, so discarding the open draft needs no + // special-casing here. + clearDraftThread(draftId); + }, + [clearDraftThread], + ); + if (drafts.length === 0) { + return null; + } + return ( + <> + {drafts.map(({ composer, draftId, session }) => { + const projectKey = `${session.environmentId}:${session.projectId}`; + return ( + + ); + })} +
  • + + ); +}); + const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -1554,6 +1797,32 @@ export default function Sidebar() { setProjectScopeKey(null); } }, [projectScopeKey, scopedProjectGroup]); + // Count-only subscription: the parent needs "are there draft rows" for the + // empty state, while SidebarDraftBlock owns the per-keystroke content + // subscription. Selecting a number keeps typing in a draft composer from + // re-rendering the whole sidebar. Approximates the block's row filter + // (every non-promoted session with content); it can overcount by one for + // an open never-left draft, which only softens the empty state. + const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; + const visibleDraftSessionCount = useComposerDraftStore((store) => { + let count = 0; + for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { + continue; + } + if ( + scopedProjectKeys !== null && + !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + count += 1; + } + return count; + }); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { @@ -1876,6 +2145,19 @@ export default function Sidebar() { [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const navigateToDraft = useCallback( + (draftId: DraftId) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + [clearSelection, isMobile, router, setOpenMobile], + ); + const clearThreadSearch = useCallback(() => { setThreadSearchQuery(""); setActiveSearchResultIndex(0); @@ -3198,13 +3480,23 @@ export default function Sidebar() { /> ); }; - // Pinned block: full cards above the inbox, closed by a - // thin divider (the pin glyphs carry the meaning, so no - // header text). Vanishes entirely at count 0. - // Rows render in the one shared pinned order; only + // Draft block above everything, then the pinned block: + // full cards above the inbox, closed by a thin divider (the + // pin glyphs carry the meaning, so no header text). Both + // vanish entirely at count 0. + // Pinned rows render in the one shared pinned order; only // reorder-capable rows register as sortable (legacy-server // pins render in place as plain rows). const items: ReactNode[] = [ + , ) : null} {!isSearchingThreads && + visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + snoozedThreads.length + diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 19822b8b7ee..c49224fec6d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -836,10 +836,9 @@ describe("composerDraftStore project draft thread mapping", () => { } }); - it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { + it("clears empty composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - store.setPrompt(draftId, "orphan me"); store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); @@ -850,6 +849,23 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep me around"); + + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); + + // The mapping moved to the fresh draft... + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( + otherThreadId, + ); + // ...but the invested draft survives with its content for the sidebar + // draft rows to surface. + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId); + expect(draftByKey(draftId)?.prompt).toBe("keep me around"); + }); + it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 95dde6187c8..5782b2cfc0f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -277,6 +277,31 @@ export interface ComposerThreadDraftState { interactionMode: ProviderInteractionMode | null; } +/** + * True when the user has invested real content in the draft: typed text or + * any attachment/context. Model selection and mode choices alone do not + * count — those are ambient defaults, not work in progress. Used by the + * sidebar draft rows (which draft sessions deserve a row) and by new-thread + * resurrection (a draft with content keeps its settings instead of being + * reset to defaults). + */ +export function composerDraftHasUserContent( + draft: ComposerThreadDraftState | null | undefined, +): boolean { + if (!draft) { + return false; + } + return ( + draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.persistedAttachments.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0 + ); +} + /** * Mutable routing and execution context for a pre-thread draft session. * @@ -1911,9 +1936,29 @@ function partializeComposerDraftStoreState( }; persistedDraftsByThreadKey[threadKey] = persistedDraft; } + // Unmapped sessions exist only to back a sidebar draft row, so a session + // that is neither mapped, nor promoting, nor backed by persisted composer + // content (e.g. the user emptied it out) has nothing left to resume — + // drop it instead of persisting a zombie. + const mappedDraftKeys = new Set( + Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), + ); + const persistedDraftThreadsByThreadKey: DeepMutable< + PersistedComposerDraftStoreState["draftThreadsByThreadKey"] + > = {}; + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if ( + !mappedDraftKeys.has(threadKey) && + !isDraftThreadPromoting(draftThread) && + persistedDraftsByThreadKey[threadKey] === undefined + ) { + continue; + } + persistedDraftThreadsByThreadKey[threadKey] = draftThread; + } return { draftsByThreadKey: persistedDraftsByThreadKey, - draftThreadsByThreadKey: state.draftThreadsByThreadKey, + draftThreadsByThreadKey: persistedDraftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey: state.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider( @@ -2214,7 +2259,25 @@ const composerDraftStore = create()( return get().getDraftSessionByProjectRef(projectRef); }, getDraftSessionByProjectRef: (projectRef) => { - for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { + const state = get(); + // Mapped drafts win: a project can also own older unmapped drafts + // (invested ones left behind by a remap), but "the" draft for a + // project is the one new-thread flows currently target. + for (const draftId of Object.values( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + const draftThread = state.draftThreadsByThreadKey[draftId]; + if (!draftThread || isDraftThreadPromoting(draftThread)) { + continue; + } + if ( + draftThread.projectId === projectRef.projectId && + draftThread.environmentId === projectRef.environmentId + ) { + return toProjectDraftSession(DraftId.make(draftId), draftThread); + } + } + for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { if (isDraftThreadPromoting(draftThread)) { continue; } @@ -2289,6 +2352,11 @@ const composerDraftStore = create()( previousThreadKeyForLogicalProject === undefined ? undefined : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; + // A remap only garbage-collects the previous draft when the user + // never invested content in it. A draft with typed text or + // attachments stays alive unmapped — the sidebar draft rows list + // every such session, so "new thread" can mint a fresh draft + // without destroying the one the user walked away from. if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && @@ -2296,7 +2364,10 @@ const composerDraftStore = create()( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, ) && - !isDraftThreadPromoting(previousDraftThread) + !isDraftThreadPromoting(previousDraftThread) && + !composerDraftHasUserContent( + state.draftsByThreadKey[previousThreadKeyForLogicalProject], + ) ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index bd8af376d64..9e8d19b0d5b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -8,6 +8,7 @@ import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts" import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { + composerDraftHasUserContent, markPromotedDraftThreadByRef, type DraftThreadEnvMode, type DraftThreadState, @@ -160,29 +161,39 @@ export function useNewThreadHandler() { if (storedDraftThreadRef && reusableStoredDraftThread === null) { markPromotedDraftThreadByRef(storedDraftThreadRef); } + // New-thread surfaces (button, hotkeys, "/" landing, palette) only + // ever reuse a draft the user has NOT invested in. A draft with typed + // text or attachments is work in progress: it stays alive where it is + // (reachable from the sidebar draft rows) and this request mints a + // fresh draft instead — the remap in the store preserves invested + // drafts rather than deleting them. + const emptyStoredDraftThread = + reusableStoredDraftThread && + !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId)) + ? reusableStoredDraftThread + : null; const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) : getDraftSession(currentRouteTarget.draftId) : null; - if (reusableStoredDraftThread) { + if (emptyStoredDraftThread) { return (async () => { const isDraftAlreadyOpen = currentRouteTarget?.kind === "draft" && - currentRouteTarget.draftId === reusableStoredDraftThread.draftId; + currentRouteTarget.draftId === emptyStoredDraftThread.draftId; const hasExplicitWorkspaceOption = hasBranchOption || hasWorktreePathOption || hasEnvModeOption || hasStartFromOriginOption; - // Resurrecting a stored draft must not resurrect its stale context: - // explicit workspace options win outright; otherwise the env context - // resets to the configured defaults so drafts seeded before a - // defaults change (or by the old carry-over behavior) stop landing - // on "current checkout" branches forever. Composer text is - // preserved. When the draft is already open and no options were - // passed, leave it alone entirely — the user may have just picked a - // branch in the composer. + // Resurrecting an empty stored draft must not resurrect its stale + // context: explicit workspace options win outright; otherwise the + // env context resets to the configured defaults so drafts seeded + // before a defaults change (or by the old carry-over behavior) stop + // landing on "current checkout" branches forever. When the draft is + // already open and no options were passed, leave it alone entirely — + // the user may have just picked a branch in the composer. let workspaceContext: NewThreadWorkspaceOptions | null = null; if (hasExplicitWorkspaceOption) { workspaceContext = pickExplicitWorkspaceOptions(options); @@ -198,7 +209,7 @@ export function useNewThreadHandler() { const routeTargetNow = getCurrentRouteTarget(); const openedMeanwhile = routeTargetNow?.kind === "draft" && - routeTargetNow.draftId === reusableStoredDraftThread.draftId; + routeTargetNow.draftId === emptyStoredDraftThread.draftId; const promotedMeanwhile = storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null; if (openedMeanwhile || promotedMeanwhile) { @@ -215,7 +226,7 @@ export function useNewThreadHandler() { }; } if (workspaceContext) { - setDraftThreadContext(reusableStoredDraftThread.draftId, { + setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -224,7 +235,7 @@ export function useNewThreadHandler() { // The carried selection is a complete snapshot of the viewed // thread's model state: absent options mean "no options", not // "keep the stale draft's options". - setModelSelection(reusableStoredDraftThread.draftId, carryModelSelection, { + setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, { replaceOptions: true, }); } @@ -236,9 +247,9 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId( logicalProjectKey, projectRef, - reusableStoredDraftThread.draftId, + emptyStoredDraftThread.draftId, { - threadId: reusableStoredDraftThread.threadId, + threadId: emptyStoredDraftThread.threadId, ...workspaceContext, ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -250,13 +261,13 @@ export function useNewThreadHandler() { const routeTargetAfterWrites = getCurrentRouteTarget(); if ( routeTargetAfterWrites?.kind === "draft" && - routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId + routeTargetAfterWrites.draftId === emptyStoredDraftThread.draftId ) { return; } await router.navigate({ to: "/draft/$draftId", - params: { draftId: reusableStoredDraftThread.draftId }, + params: { draftId: emptyStoredDraftThread.draftId }, replace: options?.replace ?? false, }); })(); @@ -266,7 +277,10 @@ export function useNewThreadHandler() { latestActiveDraftThread && currentRouteTarget?.kind === "draft" && latestActiveDraftThread.logicalProjectKey === logicalProjectKey && - latestActiveDraftThread.promotedTo == null + latestActiveDraftThread.promotedTo == null && + // Same content rule as above: a new-thread request while viewing an + // invested draft mints a fresh one instead of repurposing it. + !composerDraftHasUserContent(getComposerDraft(currentRouteTarget.draftId)) ) { if ( hasBranchOption || @@ -298,6 +312,11 @@ export function useNewThreadHandler() { const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey); if ( racedDraft && + // Only a draft REGISTERED during the await counts as a raced + // winner. An invested draft this invocation deliberately declined + // to reuse is still mapped at this point — reusing it here would + // silently undo mint-fresh semantics. + racedDraft.draftId !== storedDraftThread?.draftId && readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { // Same remap the reuse paths above perform: point the draft at the From 30260eb112194b5b30d8c835016fbeb67aef121d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:43:25 -0700 Subject: [PATCH 2/6] fix(web): drop the model name from sidebar draft rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header reads project · env mode · branch; the model still travels with the draft, it just doesn't earn a slot in a row this small. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cd74401ae21..b0873c0428e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -421,7 +421,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; session: DraftSessionState; composer: ComposerThreadDraftState; - providerEntryByInstanceId: ReadonlyMap; projectTitle: string | null; projectCwd: string | null; isActive: boolean; @@ -429,19 +428,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; - const modelSelection = composer.activeProvider - ? (composer.modelSelectionByProvider[composer.activeProvider] ?? null) - : null; - const providerEntry = modelSelection - ? (props.providerEntryByInstanceId.get(modelSelection.instanceId) ?? null) - : null; - const selectedModel = providerEntry?.models.find((model) => model.slug === modelSelection?.model); - const modelLabel = modelSelection - ? selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : modelSelection.model - : null; - const contextLabel = [props.projectTitle, modelLabel, session.envMode, session.branch] + // Workspace context only — the model still travels with the draft, it + // just is not worth a slot in a row this small. + const contextLabel = [props.projectTitle, session.envMode, session.branch] .filter((part): part is string => part !== null && part.length > 0) .join(" · "); const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; @@ -534,7 +523,6 @@ interface SidebarDraftRowData { // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { - providerEntryByInstanceId: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; @@ -629,7 +617,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftId={draftId} session={session} composer={composer} - providerEntryByInstanceId={props.providerEntryByInstanceId} projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} @@ -3490,7 +3477,6 @@ export default function Sidebar() { const items: ReactNode[] = [ Date: Sat, 8 Aug 2026 19:49:24 -0700 Subject: [PATCH 3/6] fix(web): draft rows show only project and prompt Env mode and branch ate the header, especially with long project names. The settings still travel with the draft; the row just stops advertising them. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/Sidebar.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b0873c0428e..34a1bcb48f8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -410,13 +410,13 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } -// One unsent draft session the user has invested content in. Two lines: -// header packs the workspace context (project, model, env mode, branch) so -// the row itself proves the settings survived; the body is the typed prompt. -// Clicking is a plain navigation to /draft/$draftId — nothing about the -// draft is touched on the way back. While the draft is open the row renders -// a frozen snapshot (see SidebarDraftBlock); memoized so per-keystroke -// block re-renders skip it entirely. +// One unsent draft session the user has invested content in. Two lines, +// nothing else: project name, then the typed prompt. All the draft's +// settings (model, env mode, branch, worktree) still travel with it — +// clicking is a plain navigation to /draft/$draftId, which touches nothing. +// While the draft is open the row renders a frozen snapshot (see +// SidebarDraftBlock); memoized so per-keystroke block re-renders skip it +// entirely. const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; session: DraftSessionState; @@ -428,11 +428,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; - // Workspace context only — the model still travels with the draft, it - // just is not worth a slot in a row this small. - const contextLabel = [props.projectTitle, session.envMode, session.branch] - .filter((part): part is string => part !== null && part.length > 0) - .join(" · "); const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; // images mirrors persistedAttachments once rehydration finishes; before // that only the persisted list is populated, hence max not sum. @@ -491,7 +486,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { className="size-4 shrink-0" /> - {contextLabel} + {props.projectTitle}