diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e7b24f0ac924..5512679f4fb0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1154,6 +1154,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; + onPin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; /** * External files dropped onto this row. The row highlights while the drag @@ -1192,6 +1193,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsettle, onUnsnooze, onUnpin, + onPin, openPullRequestsInRightPanel, renamingTitle, displayThread: thread, @@ -1446,6 +1448,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnpin, rowThreadRef], ); + const handlePinClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onPin(rowThreadRef); + }, + [onPin, rowThreadRef], + ); const handleSnoozePreset = useCallback( (preset: Pick) => { onSnooze(rowThreadRef, preset); @@ -1691,6 +1701,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); + // Unpinned rows offer pin beside the other hover actions. Pinned rows keep + // a trailing marker instead, so the two never show at once. + const showUnpinnedPinAction = props.pinningSupported && !props.isPinned; const pinIndicator = showPin ? ( props.pinningSupported && !sortable?.isDragging ? ( @@ -1700,7 +1713,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Unpin thread" onClick={handleUnpinClick} - className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className={cn( + "inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", + variant === "card" && "-mr-1 self-center px-1.5", + )} /> } > @@ -1712,7 +1728,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null; @@ -1941,148 +1960,176 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {agentIndicator} - {pinIndicator} - {/* The visible state owns this slot's width: status at rest, - actions on hover/keyboard focus or while the popover is open. Keeping - the hidden state out of flow lets the project label reclaim - space without either state overlapping it. */} + {/* Status at rest, hover actions once they fit. The pin is the + trailing item, outside that swap, so it cannot slide sideways. */} {sortable?.isDragging ? ( - dragDestination + <> + {dragDestination} + {pinIndicator} + ) : ( - - {/* Read-only status labels yield to the hover actions. Woke is + + + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - - {headerStatus ? ( - isWokeStatus ? ( - - - - {headerStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( - - - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {headerStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : unifyTabs ? null : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton || hasUnsentDraft || onNewTab ? ( - {hasUnsentDraft ? ( - - - } - > - - - Discard draft - - ) : null} - {onNewTab ? ( - - - } - > - - - New tab - - ) : null} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - - } + {headerStatus ? ( + isWokeStatus ? ( + + + + {headerStatus.label} + + } + /> + Dismiss Woke notification + + ) : ( + - - Settle - - Settle thread - - ) : null} + + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {headerStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : unifyTabs ? null : ( + threadTimeLabel(thread) + )} - ) : null} + {props.settlementSupported || + showSnoozeButton || + hasUnsentDraft || + onNewTab || + showUnpinnedPinAction ? ( + + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + + ) : null} + {onNewTab ? ( + + + } + > + + + New tab + + ) : null} + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + + } + > + + Settle + + Settle thread + + ) : null} + {showUnpinnedPinAction ? ( + + + } + > + + + Pin thread + + ) : null} + + ) : null} + + {pinIndicator} )} @@ -6276,6 +6323,7 @@ export default function Sidebar() { onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} + onPin={attemptPin} onAcknowledgeWoke={acknowledgeWoke} onFileDropThreads={handleThreadFileDrop} onNewTab={