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 {...@@ -203,6 +203,7 @@ import {
203 composerDraftHasUserContent,203 composerDraftHasUserContent,
204 DraftId,204 DraftId,
205 useComposerDraftStore,205 useComposerDraftStore,
206 useThreadHasUnsentDraft,
206 type ComposerThreadDraftState,207 type ComposerThreadDraftState,
207 type DraftSessionState,208 type DraftSessionState,
208} from "../composerDraftStore";209} from "../composerDraftStore";
...@@ -487,6 +488,11 @@ function SortablePinnedThreadRow(props: {...@@ -487,6 +488,11 @@ function SortablePinnedThreadRow(props: {
487 return props.children({ listeners, setNodeRef, transform, transition, isDragging });488 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
488}489}
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
490// One unsent draft session the user has invested content in. Two lines,496// One unsent draft session the user has invested content in. Two lines,
491// nothing else: project name, then the typed prompt. All the draft's497// nothing else: project name, then the typed prompt. All the draft's
492// settings (model, env mode, branch, worktree) still travel with it —498// settings (model, env mode, branch, worktree) still travel with it —
...@@ -552,19 +558,14 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -552,19 +558,14 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
552 data-testid="sidebar-draft-row"558 data-testid="sidebar-draft-row"
553 className={cn(559 className={cn(
554 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none",560 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none",
555 props.isActive561 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
556 ? "bg-sidebar-row-active"
557 : "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]",
558 )}562 )}
559 onClick={handleActivate}563 onClick={handleActivate}
560 onKeyDown={handleKeyDown}564 onKeyDown={handleKeyDown}
561 >565 >
562 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">566 <div className="relative z-10 px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
563 <div className="flex h-5 min-w-0 items-center gap-1.5">567 <div className="flex h-5 min-w-0 items-center gap-1.5">
564 <SquarePenIcon568 <SquarePenIcon aria-hidden className={draftPenClassName} />
565 aria-hidden
566 className="size-3 shrink-0 text-amber-600 dark:text-amber-300/80"
567 />
568 <ProjectFavicon569 <ProjectFavicon
569 environmentId={session.environmentId}570 environmentId={session.environmentId}
570 cwd={props.projectCwd ?? ""}571 cwd={props.projectCwd ?? ""}
...@@ -827,6 +828,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -827,6 +828,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
827 });828 });
828 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);829 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
829 const terminalProcessCount = runningTerminalIds.length;830 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
831 const gitCwd = thread.worktreePath ?? props.projectCwd;845 const gitCwd = thread.worktreePath ?? props.projectCwd;
832 const linkedPullRequestStatus = useLinkedThreadPullRequest(846 const linkedPullRequestStatus = useLinkedThreadPullRequest(
...@@ -1163,9 +1177,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1163,9 +1177,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1163 ? "bg-sidebar-row-active text-sidebar-foreground"1177 ? "bg-sidebar-row-active text-sidebar-foreground"
1164 : isSelected1178 : isSelected
1165 ? "bg-sidebar-row-selected text-sidebar-foreground"1179 ? "bg-sidebar-row-selected text-sidebar-foreground"
1166 : shouldRecede1180 : hasUnsentDraft
1167 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"1181 ? cn(draftSurfaceClassName, "text-sidebar-foreground")
1168 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",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",
1169 isInFlight &&1185 isInFlight &&
1170 !props.isActive &&1186 !props.isActive &&
1171 !isSelected &&1187 !isSelected &&
...@@ -1251,6 +1267,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1251,6 +1267,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1251 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />1267 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
1252 </span>1268 </span>
1253 ) : null;1269 ) : 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;
1254 const pinIndicator = props.isPinned ? (1289 const pinIndicator = props.isPinned ? (
1255 props.pinningSupported ? (1290 props.pinningSupported ? (
1256 <Tooltip>1291 <Tooltip>
...@@ -1318,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1318,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1318 className="size-4"1353 className="size-4"
1319 />1354 />
1320 </span>1355 </span>
1356 {draftIndicator}
1321 {title}1357 {title}
1322 {pinIndicator}1358 {pinIndicator}
1323 {terminalStatusIcon}1359 {terminalStatusIcon}
...@@ -1465,6 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1465,6 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1465 >1501 >
1466 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">1502 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
1467 <div className="flex h-5 min-w-0 items-center gap-1.5">1503 <div className="flex h-5 min-w-0 items-center gap-1.5">
1504 {draftIndicator}
1468 <ProjectFavicon1505 <ProjectFavicon
1469 environmentId={thread.environmentId}1506 environmentId={thread.environmentId}
1470 cwd={props.projectCwd ?? ""}1507 cwd={props.projectCwd ?? ""}
...@@ -1551,7 +1588,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1551,7 +1588,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1551 threadTimeLabel(thread)1588 threadTimeLabel(thread)
1552 )}1589 )}
1553 </span>1590 </span>
1554 {props.settlementSupported || showSnoozeButton ? (1591 {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
1555 <span1592 <span
1556 className={cn(1593 className={cn(
1557 // focus-visible, not focus-within: a mouse click leaves1594 // focus-visible, not focus-within: a mouse click leaves
...@@ -1563,6 +1600,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1563,6 +1600,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1563 snoozeMenuOpen && "pointer-events-auto static opacity-100",1600 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1564 )}1601 )}
1565 >1602 >
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}
1566 {showSnoozeButton ? (1620 {showSnoozeButton ? (
1567 <SnoozePopoverButton1621 <SnoozePopoverButton
1568 open={snoozeMenuOpen}1622 open={snoozeMenuOpen}
apps/web/src/composerDraftStore.test.ts+26
...@@ -66,6 +66,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"...@@ -66,6 +66,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
66import {66import {
67 COMPOSER_DRAFT_STORAGE_KEY,67 COMPOSER_DRAFT_STORAGE_KEY,
68 clearComposerDraftsEnvironment,68 clearComposerDraftsEnvironment,
69 composerDraftHasUserContent,
69 finalizePromotedDraftThreadByRef,70 finalizePromotedDraftThreadByRef,
70 markPromotedDraftThread,71 markPromotedDraftThread,
71 markPromotedDraftThreadByRef,72 markPromotedDraftThreadByRef,
...@@ -347,6 +348,31 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -347,6 +348,31 @@ describe("composerDraftStore clearComposerContent", () => {
347 });348 });
348});349});
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
350describe("composerDraftStore file attachments", () => {376describe("composerDraftStore file attachments", () => {
351 const threadId = ThreadId.make("thread-files");377 const threadId = ThreadId.make("thread-files");
352 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);378 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
apps/web/src/composerDraftStore.ts+11
...@@ -4009,6 +4009,17 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose...@@ -4009,6 +4009,17 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose
4009 });4009 });
4010}4010}
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
4012export function useComposerDraftModelState(4023export function useComposerDraftModelState(
4013 threadRef: ComposerThreadTarget,4024 threadRef: ComposerThreadTarget,
4014): ComposerDraftModelState {4025): ComposerDraftModelState {
docs/user/thread-sidebar.md+4
...@@ -36,6 +36,10 @@ by older clients on one device no longer control this behavior....@@ -36,6 +36,10 @@ by older clients on one device no longer control this behavior.
36When you un-settle a thread, it returns to the top of the active list so you can find it right36When you un-settle a thread, it returns to the top of the active list so you can find it right
37away. Its timestamps do not change. Other threads keep their positions.37away. 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
39Right-click a pull request link in a thread and choose **Link to thread** to show that pull request43Right-click a pull request link in a thread and choose **Link to thread** to show that pull request
40in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged44in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged
41threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.45threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it.