authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 05:30:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 05:30:51-07:00
logf0347322441f3b8e473a8d13ea7006cbcb4fb761
treecb3237f8cb11fee352d5c010e9a78104cd4ae3a9
parentd5b94100863057fb4629f9ad4a35753d16917924
signature Signed by PGP key B5690EEEBB952194

feat(web): show which sidebar threads hold an unsent draft (#9658)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 106 insertions(+), 11 deletions(-)

apps/web/src/components/Sidebar.tsx+65-11
......@@ -203,6 +203,7 @@ import {
203203 composerDraftHasUserContent,
204204 DraftId,
205205 useComposerDraftStore,
206 useThreadHasUnsentDraft,
206207 type ComposerThreadDraftState,
207208 type DraftSessionState,
208209} from "../composerDraftStore";
......@@ -487,6 +488,11 @@ function SortablePinnedThreadRow(props: {
487488 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
488489}
489490
491// Unsent work shares one look: the new-thread draft rows and thread rows
492// with unsent composer text both use this tint and pen so they read alike.
493const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]";
494const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80";
495
490496// One unsent draft session the user has invested content in. Two lines,
491497// nothing else: project name, then the typed prompt. All the draft's
492498// settings (model, env mode, branch, worktree) still travel with it —
......@@ -552,19 +558,14 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
552558 data-testid="sidebar-draft-row"
553559 className={cn(
554560 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none",
555 props.isActive
556 ? "bg-sidebar-row-active"
557 : "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]",
561 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
558562 )}
559563 onClick={handleActivate}
560564 onKeyDown={handleKeyDown}
561565 >
562566 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
563567 <div className="flex h-5 min-w-0 items-center gap-1.5">
564 <SquarePenIcon
565 aria-hidden
566 className="size-3 shrink-0 text-amber-600 dark:text-amber-300/80"
567 />
568 <SquarePenIcon aria-hidden className={draftPenClassName} />
568569 <ProjectFavicon
569570 environmentId={session.environmentId}
570571 cwd={props.projectCwd ?? ""}
......@@ -827,6 +828,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
827828 });
828829 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
829830 const terminalProcessCount = runningTerminalIds.length;
831 // Unsent composer text on this thread. The open thread shows its own
832 // composer, so the marker only decorates rows you have navigated away from.
833 const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
834 const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
835 const handleDiscardDraftClick = useCallback(
836 (event: ReactMouseEvent) => {
837 event.preventDefault();
838 event.stopPropagation();
839 releaseComposerDraftUploads(threadRef);
840 clearComposerContent(threadRef);
841 },
842 [clearComposerContent, threadRef],
843 );
830844
831845 const gitCwd = thread.worktreePath ?? props.projectCwd;
832846 const linkedPullRequestStatus = useLinkedThreadPullRequest(
......@@ -1163,9 +1177,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11631177 ? "bg-sidebar-row-active text-sidebar-foreground"
11641178 : isSelected
11651179 ? "bg-sidebar-row-selected text-sidebar-foreground"
1166 : shouldRecede
1167 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1168 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
1180 : hasUnsentDraft
1181 ? cn(draftSurfaceClassName, "text-sidebar-foreground")
1182 : shouldRecede
1183 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1184 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
11691185 isInFlight &&
11701186 !props.isActive &&
11711187 !isSelected &&
......@@ -1251,6 +1267,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12511267 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
12521268 </span>
12531269 ) : null;
1270 // Same pen the new-thread draft rows lead with, so both kinds of unsent
1271 // work read the same way in the list.
1272 const draftIndicator = hasUnsentDraft ? (
1273 <Tooltip>
1274 <TooltipTrigger
1275 render={
1276 <span
1277 role="img"
1278 aria-label="Unsent draft"
1279 data-testid={`sidebar-draft-indicator-${thread.id}`}
1280 className="inline-flex shrink-0 items-center"
1281 />
1282 }
1283 >
1284 <SquarePenIcon aria-hidden className={draftPenClassName} />
1285 </TooltipTrigger>
1286 <TooltipPopup side="top">Unsent draft</TooltipPopup>
1287 </Tooltip>
1288 ) : null;
12541289 const pinIndicator = props.isPinned ? (
12551290 props.pinningSupported ? (
12561291 <Tooltip>
......@@ -1318,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13181353 className="size-4"
13191354 />
13201355 </span>
1356 {draftIndicator}
13211357 {title}
13221358 {pinIndicator}
13231359 {terminalStatusIcon}
......@@ -1465,6 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14651501 >
14661502 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
14671503 <div className="flex h-5 min-w-0 items-center gap-1.5">
1504 {draftIndicator}
14681505 <ProjectFavicon
14691506 environmentId={thread.environmentId}
14701507 cwd={props.projectCwd ?? ""}
......@@ -1551,7 +1588,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15511588 threadTimeLabel(thread)
15521589 )}
15531590 </span>
1554 {props.settlementSupported || showSnoozeButton ? (
1591 {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
15551592 <span
15561593 className={cn(
15571594 // focus-visible, not focus-within: a mouse click leaves
......@@ -1563,6 +1600,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15631600 snoozeMenuOpen && "pointer-events-auto static opacity-100",
15641601 )}
15651602 >
1603 {hasUnsentDraft ? (
1604 <Tooltip>
1605 <TooltipTrigger
1606 render={
1607 <button
1608 type="button"
1609 aria-label="Discard draft"
1610 onClick={handleDiscardDraftClick}
1611 className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1612 />
1613 }
1614 >
1615 <XIcon className="size-3.5" />
1616 </TooltipTrigger>
1617 <TooltipPopup side="top">Discard draft</TooltipPopup>
1618 </Tooltip>
1619 ) : null}
15661620 {showSnoozeButton ? (
15671621 <SnoozePopoverButton
15681622 open={snoozeMenuOpen}
apps/web/src/composerDraftStore.test.ts+26
......@@ -66,6 +66,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
6666import {
6767 COMPOSER_DRAFT_STORAGE_KEY,
6868 clearComposerDraftsEnvironment,
69 composerDraftHasUserContent,
6970 finalizePromotedDraftThreadByRef,
7071 markPromotedDraftThread,
7172 markPromotedDraftThreadByRef,
......@@ -347,6 +348,31 @@ describe("composerDraftStore clearComposerContent", () => {
347348 });
348349});
349350
351describe("composerDraftStore unsent draft marker", () => {
352 const threadId = ThreadId.make("thread-unsent-marker");
353 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
354
355 beforeEach(() => {
356 resetComposerDraftStore();
357 });
358
359 it("reports content for typed text and clears when the composer is emptied", () => {
360 const hasDraft = () =>
361 composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef));
362
363 expect(hasDraft()).toBe(false);
364
365 useComposerDraftStore.getState().setPrompt(threadRef, " ");
366 expect(hasDraft()).toBe(false);
367
368 useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case");
369 expect(hasDraft()).toBe(true);
370
371 useComposerDraftStore.getState().clearComposerContent(threadRef);
372 expect(hasDraft()).toBe(false);
373 });
374});
375
350376describe("composerDraftStore file attachments", () => {
351377 const threadId = ThreadId.make("thread-files");
352378 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
apps/web/src/composerDraftStore.ts+11
......@@ -4009,6 +4009,17 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose
40094009 });
40104010}
40114011
4012/**
4013 * True when a real thread's composer holds unsent user content. Selects a
4014 * boolean so the sidebar row that reads it re-renders only when the draft
4015 * appears or disappears, not on every keystroke.
4016 */
4017export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean {
4018 return useComposerDraftStore((state) =>
4019 composerDraftHasUserContent(getComposerDraftState(state, threadRef)),
4020 );
4021}
4022
40124023export function useComposerDraftModelState(
40134024 threadRef: ComposerThreadTarget,
40144025): ComposerDraftModelState {
docs/user/thread-sidebar.md+4
......@@ -36,6 +36,10 @@ by older clients on one device no longer control this behavior.
3636When you un-settle a thread, it returns to the top of the active list so you can find it right
3737away. Its timestamps do not change. Other threads keep their positions.
3838
39A thread whose composer holds unsent text or attachments shows an amber tint and a pen icon in the
40sidebar, the same marks a new-thread draft uses. On web and desktop, hover the row and choose the
41**X** to discard that draft without opening the thread.
42
3943Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
4044in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
4145threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.