authorgravatar for 126553318+argofowl@users.noreply.github.comArgo <126553318+argofowl@users.noreply.github.com> 2026-10-04 07:39:33+05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 23:39:33-03:00
log1826fb55cc4c9c6246d31175e15a496ba4778e07
tree176341c6612db7a72234af9d38d2ae3aa436d0a8
parent0c8112013760617230d382e55447ddb75627a997
signature Signed by PGP key B5690EEEBB952194

feat(web): sweep sidebar buttons to settle, un-settle, and wake threads (#14768)

Co-authored-by: maria-rcks <maria@kuuro.net>

6 files changed, 225 insertions(+), 28 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+17
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 reduceSidebarProjectScopeMenuState,30 reduceSidebarProjectScopeMenuState,
31 resolveAdjacentThreadId,31 resolveAdjacentThreadId,
32 resolveProjectStatusIndicator,32 resolveProjectStatusIndicator,
33 resolveSidebarSweepKeys,
33 resolveSidebarStageBadgeLabel,34 resolveSidebarStageBadgeLabel,
34 resolveSidebarThreadSection,35 resolveSidebarThreadSection,
35 resolveSidebarRowAccessibility,36 resolveSidebarRowAccessibility,
...@@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => {...@@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => {
2014 });2015 });
2015});2016});
20162017
2018describe("resolveSidebarSweepKeys", () => {
2019 const ordered = ["a", "b", "c", "d", "blocked"];
2020 const canSettle = (key: string) => key !== "blocked";
2021
2022 it("covers every row between the pressed row and the pointer, in either direction", () => {
2023 expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]);
2024 expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]);
2025 expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]);
2026 });
2027
2028 it("leaves out rows that cannot settle and rows that left the list", () => {
2029 expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]);
2030 expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]);
2031 });
2032});
2033
2017describe("navigation after parking a thread", () => {2034describe("navigation after parking a thread", () => {
2018 it.each([2035 it.each([
2019 ["settle", "settled", null, "thread", true],2036 ["settle", "settled", null, "thread", true],
apps/web/src/components/Sidebar.logic.ts+28
...@@ -274,6 +274,34 @@ export function resolveSidebarDropVerb(...@@ -274,6 +274,34 @@ export function resolveSidebarDropVerb(
274 return "wake";274 return "wake";
275}275}
276276
277/** Eligible rows between the pressed action and the pointer, in sidebar order. */
278export function resolveSidebarSweepKeys(
279 orderedKeys: readonly string[],
280 originKey: string,
281 targetKey: string,
282 canApply: (key: string) => boolean,
283): string[] {
284 const origin = orderedKeys.indexOf(originKey);
285 const target = orderedKeys.indexOf(targetKey);
286 if (origin === -1 || target === -1) return [];
287 return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply);
288}
289
290/** The thread row at a pointer height, clamped to the rows visible in the
291 sidebar's scroll viewport. A gap between rows resolves to the row above
292 it. Rows carry their key in data-thread-item, which departing motion
293 clones drop. */
294export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null {
295 const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect();
296 const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y;
297 let key: string | null = null;
298 for (const row of list.querySelectorAll<HTMLElement>("li[data-thread-item]")) {
299 if (key !== null && row.getBoundingClientRect().top > visibleY) break;
300 key = row.dataset.threadItem ?? null;
301 }
302 return key;
303}
304
277export function planSidebarThreadDrop(input: {305export function planSidebarThreadDrop(input: {
278 readonly activeKey: string;306 readonly activeKey: string;
279 readonly activeSection: SidebarSection;307 readonly activeSection: SidebarSection;
apps/web/src/components/Sidebar.pointer.test.ts+12-3
...@@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => {...@@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => {
80 expect(drag.onFinish).toHaveBeenCalledOnce();80 expect(drag.onFinish).toHaveBeenCalledOnce();
81 });81 });
8282
83 it("moves to the pointer on the move that starts the drag", () => {
84 const drag = gesture();
85 document.dispatchEvent(pointer("pointermove", { clientY: 90 }));
86 expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
87 expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 });
88 });
89
83 const interruptions = {90 const interruptions = {
84 blur: () => window.dispatchEvent(new Event("blur")),91 blur: () => window.dispatchEvent(new Event("blur")),
85 hidden: () => {92 hidden: () => {
...@@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => {...@@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => {
122 const drag = gesture({ onMove, onDrop });129 const drag = gesture({ onMove, onDrop });
123 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));130 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
124 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));131 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
125 expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 });132 expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 });
133 expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 });
126 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));134 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
127 // Outside the list the sort no longer sees the pointer.135 // Outside the list the sort no longer sees the pointer.
128 expect(drag.onMove).toHaveBeenCalledOnce();136 expect(drag.onMove).toHaveBeenCalledTimes(2);
129 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));137 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));
130 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 });138 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 });
131 expect(drag.onCancel).toHaveBeenCalledOnce();139 expect(drag.onCancel).toHaveBeenCalledOnce();
...@@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => {...@@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => {
148 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));156 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
149 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 }));157 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 }));
150 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 }));158 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 }));
151 expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 });159 expect(drag.onMove).toHaveBeenCalledTimes(2);
160 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 });
152161
153 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 }));162 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 }));
154 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 });163 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 });
apps/web/src/components/Sidebar.pointer.ts+5-2
...@@ -34,7 +34,9 @@ export class SidebarPointerSensor {...@@ -34,7 +34,9 @@ export class SidebarPointerSensor {
34 private readonly document: Document;34 private readonly document: Document;
35 private readonly window: Window;35 private readonly window: Window;
3636
37 constructor(private readonly props: SensorProps<Options>) {37 // The settle sweep constructs this sensor directly, outside dnd-kit, so it
38 // takes only the props the gesture reads.
39 constructor(private readonly props: Omit<SensorProps<Options>, "activeNode" | "context">) {
38 this.pointer = props.event as PointerEvent;40 this.pointer = props.event as PointerEvent;
39 this.document = getOwnerDocument(this.pointer.target);41 this.document = getOwnerDocument(this.pointer.target);
40 this.window = getWindow(this.pointer.target);42 this.window = getWindow(this.pointer.target);
...@@ -89,8 +91,9 @@ export class SidebarPointerSensor {...@@ -89,8 +91,9 @@ export class SidebarPointerSensor {
89 this.document.addEventListener("selectionchange", this.clearSelection);91 this.document.addEventListener("selectionchange", this.clearSelection);
90 this.clearSelection();92 this.clearSelection();
91 this.props.onStart(this.coordinates());93 this.props.onStart(this.coordinates());
92 return;
93 }94 }
95 // The move that starts a drag also moves it, so a release before the
96 // next pointermove still lands where the pointer is.
94 if (this.phase === "dragging") {97 if (this.phase === "dragging") {
95 if (event.cancelable) event.preventDefault();98 if (event.cancelable) event.preventDefault();
96 if (this.props.options.onMove?.(coordinates) === true) return;99 if (this.props.options.onMove?.(coordinates) === true) return;
apps/web/src/components/Sidebar.tsx+158-23
...@@ -92,6 +92,7 @@ import {...@@ -92,6 +92,7 @@ import {
92 useState,92 useState,
93 type KeyboardEvent as ReactKeyboardEvent,93 type KeyboardEvent as ReactKeyboardEvent,
94 type MouseEvent as ReactMouseEvent,94 type MouseEvent as ReactMouseEvent,
95 type PointerEvent as ReactPointerEvent,
95 type ReactNode,96 type ReactNode,
96} from "react";97} from "react";
97import { useParams, useRouter } from "@tanstack/react-router";98import { useParams, useRouter } from "@tanstack/react-router";
...@@ -188,6 +189,7 @@ import {...@@ -188,6 +189,7 @@ import {
188 planSidebarThreadDrop,189 planSidebarThreadDrop,
189 reduceSidebarProjectScopeMenuState,190 reduceSidebarProjectScopeMenuState,
190 resolveAdjacentThreadId,191 resolveAdjacentThreadId,
192 resolveSidebarSweepKeys,
191 resolveSidebarDropTarget,193 resolveSidebarDropTarget,
192 resolveSidebarDropVerb,194 resolveSidebarDropVerb,
193 resolveSidebarThreadSection,195 resolveSidebarThreadSection,
...@@ -202,6 +204,7 @@ import {...@@ -202,6 +204,7 @@ import {
202 resolveWorkingStartedAt,204 resolveWorkingStartedAt,
203 sidebarListItemId,205 sidebarListItemId,
204 sidebarMarkerId,206 sidebarMarkerId,
207 sidebarThreadKeyAtY,
205 sortInboxThreadsByReturn,208 sortInboxThreadsByReturn,
206 sortPinnedThreadsForSidebar,209 sortPinnedThreadsForSidebar,
207 sortSidebarV2ProjectGroups,210 sortSidebarV2ProjectGroups,
...@@ -706,6 +709,12 @@ function SidebarSectionPlaceholder(props: {...@@ -706,6 +709,12 @@ function SidebarSectionPlaceholder(props: {
706 );709 );
707}710}
708711
712// Pointer travel before a press on a row starts a drag, or a press on its
713// action button starts a sweep. Shorter presses stay clicks.
714const SIDEBAR_DRAG_DISTANCE = 6;
715
716type SidebarSweepAction = "settle" | "unsettle" | "unsnooze";
717
709// Zero-height markers reserve no label space at rest. During a drag the718// Zero-height markers reserve no label space at rest. During a drag the
710// sorting strategy opens 24px for a 16px label with 4px clearance on each side.719// sorting strategy opens 24px for a 16px label with 4px clearance on each side.
711const SIDEBAR_DRAG_LABEL_HEIGHT = 24;720const SIDEBAR_DRAG_LABEL_HEIGHT = 24;
...@@ -1064,9 +1073,8 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {...@@ -1064,9 +1073,8 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
1064const SidebarThreadRow = memo(function SidebarThreadRow(props: {1073const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1065 thread: SidebarThreadSummary;1074 thread: SidebarThreadSummary;
1066 variant: "card" | "slim";1075 variant: "card" | "slim";
1067 // Slim rows are either settled (action: un-settle) or merely quiet1076 // Settled rows un-settle, snoozed rows wake, and cards settle.
1068 // (seen Ready threads — action: settle).1077 variantAction: SidebarSweepAction;
1069 variantAction: "settle" | "unsettle" | "unsnooze";
1070 // False on environments whose server predates thread.settle/unsettle:1078 // False on environments whose server predates thread.settle/unsettle:
1071 // the lifecycle affordances hide entirely rather than fail on click.1079 // the lifecycle affordances hide entirely rather than fail on click.
1072 settlementSupported: boolean;1080 settlementSupported: boolean;
...@@ -1090,6 +1098,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1090,6 +1098,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1090 // the pinned section. Any other position shows the verb badge instead, and1098 // the pinned section. Any other position shows the verb badge instead, and
1091 // the badge carries its own icon.1099 // the badge carries its own icon.
1092 dragOverPinned: boolean;1100 dragOverPinned: boolean;
1101 // The action this row will take when the sweep is released.
1102 sweepAction: SidebarSweepAction | null;
1093 // Compact wake countdown ("2h") for rows in the snoozed shelf.1103 // Compact wake countdown ("2h") for rows in the snoozed shelf.
1094 snoozeWakeLabelText: string | null;1104 snoozeWakeLabelText: string | null;
1095 // When a snooze ended (timer or early wake); drives the Woke pill until1105 // When a snooze ended (timer or early wake); drives the Woke pill until
...@@ -1115,6 +1125,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1115,6 +1125,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1115 renamingTitle: string;1125 renamingTitle: string;
1116 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;1126 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
1117 onSettle: (threadRef: ScopedThreadRef) => void;1127 onSettle: (threadRef: ScopedThreadRef) => void;
1128 onActionSweepStart: (
1129 threadRef: ScopedThreadRef,
1130 action: SidebarSweepAction,
1131 event: PointerEvent,
1132 ) => void;
1118 onUnsettle: (threadRef: ScopedThreadRef) => void;1133 onUnsettle: (threadRef: ScopedThreadRef) => void;
1119 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;1134 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;
1120 onUnsnooze: (threadRef: ScopedThreadRef) => void;1135 onUnsnooze: (threadRef: ScopedThreadRef) => void;
...@@ -1138,6 +1153,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1138,6 +1153,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1138 onFileDropThreads,1153 onFileDropThreads,
1139 onRenameTitleChange,1154 onRenameTitleChange,
1140 onSettle,1155 onSettle,
1156 onActionSweepStart,
1141 onSnooze,1157 onSnooze,
1142 onStartRename,1158 onStartRename,
1143 onThreadActivate,1159 onThreadActivate,
...@@ -1433,6 +1449,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1433,6 +1449,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1433 },1449 },
1434 [onSettle, threadRef],1450 [onSettle, threadRef],
1435 );1451 );
1452 const handleActionPointerDown = useCallback(
1453 (event: ReactPointerEvent) => {
1454 if (!event.isPrimary || event.button !== 0) return;
1455 // Action buttons sweep their section rather than picking up the row.
1456 event.stopPropagation();
1457 onActionSweepStart(threadRef, variantAction, event.nativeEvent);
1458 },
1459 [onActionSweepStart, threadRef, variantAction],
1460 );
1436 const handleUnsettleClick = useCallback(1461 const handleUnsettleClick = useCallback(
1437 (event: ReactMouseEvent) => {1462 (event: ReactMouseEvent) => {
1438 event.preventDefault();1463 event.preventDefault();
...@@ -1510,7 +1535,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1510,7 +1535,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1510 variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",1535 variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
1511 props.isActive1536 props.isActive
1512 ? "bg-sidebar-row-active text-sidebar-foreground"1537 ? "bg-sidebar-row-active text-sidebar-foreground"
1513 : isSelected1538 : isSelected || props.sweepAction !== null
1514 ? "bg-sidebar-row-selected text-sidebar-foreground"1539 ? "bg-sidebar-row-selected text-sidebar-foreground"
1515 : hasUnsentDraft1540 : hasUnsentDraft
1516 ? cn(draftSurfaceClassName, "text-sidebar-foreground")1541 ? cn(draftSurfaceClassName, "text-sidebar-foreground")
...@@ -1550,13 +1575,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1550,13 +1575,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1550 ...sortable.listeners,1575 ...sortable.listeners,
1551 }1576 }
1552 : {};1577 : {};
1578 // Sweeps reuse the corresponding row-drop action badge.
1579 const destinationVerb = sortable?.isDragging
1580 ? props.dropVerb
1581 : props.sweepAction === "unsnooze"
1582 ? "wake"
1583 : props.sweepAction;
1553 const dragDestination =1584 const dragDestination =
1554 sortable?.isDragging && props.dropVerb !== null ? (1585 destinationVerb !== null ? (
1555 <span1586 <span
1556 role="status"1587 role="status"
1557 className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-2xs font-medium text-primary"1588 className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-2xs font-medium text-primary"
1558 >1589 >
1559 {dropVerbBadge[props.dropVerb]}1590 {dropVerbBadge[destinationVerb]}
1560 </span>1591 </span>
1561 ) : null;1592 ) : null;
15621593
...@@ -1701,7 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1701,7 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1701 if (variant === "slim") {1732 if (variant === "slim") {
1702 return (1733 return (
1703 <li1734 <li
1704 data-thread-item1735 data-thread-item={threadKey}
1705 {...sortableRootProps}1736 {...sortableRootProps}
1706 {...(fileDropHandlers ?? {})}1737 {...(fileDropHandlers ?? {})}
1707 className={cn(1738 className={cn(
...@@ -1757,7 +1788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1757,7 +1788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1757 {sortable?.isDragging ? (1788 {sortable?.isDragging ? (
1758 dragDestination1789 dragDestination
1759 ) : (1790 ) : (
1760 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">1791 <span
1792 className={cn(
1793 "relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end",
1794 props.sweepAction !== null && "hidden",
1795 )}
1796 >
1761 <span1797 <span
1762 className={cn(1798 className={cn(
1763 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",1799 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
...@@ -1803,6 +1839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1803,6 +1839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1803 type="button"1839 type="button"
1804 aria-label="Wake thread now"1840 aria-label="Wake thread now"
1805 onClick={handleUnsnoozeClick}1841 onClick={handleUnsnoozeClick}
1842 onPointerDown={handleActionPointerDown}
1806 className={cn(1843 className={cn(
1807 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs 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",1844 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs 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",
1808 isWoke && "group-hover/sidebar-row:static",1845 isWoke && "group-hover/sidebar-row:static",
...@@ -1819,6 +1856,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1819,6 +1856,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1819 type="button"1856 type="button"
1820 aria-label="Un-settle thread"1857 aria-label="Un-settle thread"
1821 onClick={handleUnsettleClick}1858 onClick={handleUnsettleClick}
1859 onPointerDown={handleActionPointerDown}
1822 className={cn(1860 className={cn(
1823 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs 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",1861 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs 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",
1824 isWoke && "group-hover/sidebar-row:static",1862 isWoke && "group-hover/sidebar-row:static",
...@@ -1835,6 +1873,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1835,6 +1873,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1835 type="button"1873 type="button"
1836 aria-label="Settle thread"1874 aria-label="Settle thread"
1837 onClick={handleSettleClick}1875 onClick={handleSettleClick}
1876 onPointerDown={handleActionPointerDown}
1838 className={cn(1877 className={cn(
1839 "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs 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",1878 "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs 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",
1840 isWoke && "group-hover/sidebar-row:static",1879 isWoke && "group-hover/sidebar-row:static",
...@@ -1845,6 +1884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1845,6 +1884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1845 )}1884 )}
1846 </span>1885 </span>
1847 )}1886 )}
1887 {props.sweepAction !== null ? dragDestination : null}
1848 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}1888 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1849 </TooltipTrigger>1889 </TooltipTrigger>
1850 {detailsTooltip}1890 {detailsTooltip}
...@@ -1857,7 +1897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1857,7 +1897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18571897
1858 return (1898 return (
1859 <li1899 <li
1860 data-thread-item1900 data-thread-item={threadKey}
1861 {...sortableRootProps}1901 {...sortableRootProps}
1862 {...(fileDropHandlers ?? {})}1902 {...(fileDropHandlers ?? {})}
1863 className={cn(1903 className={cn(
...@@ -1912,7 +1952,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1912,7 +1952,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1912 {sortable?.isDragging ? (1952 {sortable?.isDragging ? (
1913 dragDestination1953 dragDestination
1914 ) : (1954 ) : (
1915 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">1955 <span
1956 className={cn(
1957 "group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs",
1958 props.sweepAction !== null && "hidden",
1959 )}
1960 >
1916 {/* Read-only status labels yield to the hover actions. Woke is1961 {/* Read-only status labels yield to the hover actions. Woke is
1917 itself an action, so it stays pointer-enabled and visible1962 itself an action, so it stays pointer-enabled and visible
1918 while the other controls appear beside it. */}1963 while the other controls appear beside it. */}
...@@ -2024,6 +2069,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2024,6 +2069,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2024 type="button"2069 type="button"
2025 aria-label="Settle thread"2070 aria-label="Settle thread"
2026 onClick={handleSettleClick}2071 onClick={handleSettleClick}
2072 onPointerDown={handleActionPointerDown}
2027 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"2073 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
2028 />2074 />
2029 }2075 }
...@@ -2038,6 +2084,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2038,6 +2084,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2038 ) : null}2084 ) : null}
2039 </span>2085 </span>
2040 )}2086 )}
2087 {/* A sweep hides the slot rather than unmounting it, so the
2088 pressed action button stays connected and a cancelled sweep's
2089 release click still fires and is consumed. */}
2090 {props.sweepAction !== null ? dragDestination : null}
2041 </div>2091 </div>
2042 <div className="mt-1 flex min-w-0">2092 <div className="mt-1 flex min-w-0">
2043 {title}2093 {title}
...@@ -3252,6 +3302,22 @@ export default function Sidebar() {...@@ -3252,6 +3302,22 @@ export default function Sidebar() {
3252 },3302 },
3253 [planForwardNavigation, settleThread],3303 [planForwardNavigation, settleThread],
3254 );3304 );
3305 // Post-settle navigation must skip threads settling in this same batch —
3306 // they are all leaving the card block together. Rows that are already
3307 // explicitly settled are skipped: nothing to do on a valid mixed selection.
3308 // Pinned rows ARE included: the decider clears the pin as part of settling,
3309 // so they park like the rest.
3310 const settleThreads = useCallback(
3311 (threadKeys: readonly string[]) => {
3312 const coSettlingKeys = new Set(threadKeys);
3313 for (const threadKey of threadKeys) {
3314 const thread = threadByKeyRef.current.get(threadKey);
3315 if (!thread || thread.settledOverride === "settled") continue;
3316 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
3317 }
3318 },
3319 [attemptSettle],
3320 );
3255 const attemptUnsettle = useCallback(3321 const attemptUnsettle = useCallback(
3256 (threadRef: ScopedThreadRef) => {3322 (threadRef: ScopedThreadRef) => {
3257 void (async () => {3323 void (async () => {
...@@ -3378,7 +3444,7 @@ export default function Sidebar() {...@@ -3378,7 +3444,7 @@ export default function Sidebar() {
3378 );3444 );
3379 const dndSensors = useSensors(3445 const dndSensors = useSensors(
3380 useSensor(SidebarPointerSensor, {3446 useSensor(SidebarPointerSensor, {
3381 distance: 6,3447 distance: SIDEBAR_DRAG_DISTANCE,
3382 onAttach: attachDragSensor,3448 onAttach: attachDragSensor,
3383 onFinish: finishThreadDrag,3449 onFinish: finishThreadDrag,
3384 onMove: moveThreadContextDrag,3450 onMove: moveThreadContextDrag,
...@@ -3399,6 +3465,77 @@ export default function Sidebar() {...@@ -3399,6 +3465,77 @@ export default function Sidebar() {
3399 add(settledThreads, "settled");3465 add(settledThreads, "settled");
3400 return map;3466 return map;
3401 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]);3467 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]);
3468 const sectionByThreadKeyRef = useRef(sectionByThreadKey);
3469 sectionByThreadKeyRef.current = sectionByThreadKey;
3470 // Drag a row action to apply it to the armed rows in the same section.
3471 // Staying in one section keeps an overshoot from changing neighboring
3472 // sections, whose boundaries may be hidden outside a row drag. The sweep
3473 // runs on the row drag's sensor and ref, so unmounting the list cancels it.
3474 const [actionSweep, setActionSweep] = useState<{
3475 action: SidebarSweepAction;
3476 keys: ReadonlySet<string>;
3477 } | null>(null);
3478 const startActionSweep = useCallback(
3479 (threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => {
3480 const originKey = scopedThreadKey(threadRef);
3481 const originSection = sectionByThreadKeyRef.current.get(originKey);
3482 const canApply = (key: string) => {
3483 const thread = threadByKeyRef.current.get(key);
3484 const capabilities =
3485 thread && serverConfigs.get(thread.environmentId)?.environment.capabilities;
3486 return (
3487 thread !== undefined &&
3488 sectionByThreadKeyRef.current.get(key) === originSection &&
3489 (action === "unsnooze"
3490 ? capabilities?.threadSnooze === true
3491 : capabilities?.threadSettlement === true)
3492 );
3493 };
3494 let targetKey: string | null = null;
3495 let sweptKeys: string[] = [];
3496 const sweepTo = (key: string | null) => {
3497 if (key === null || key === targetKey) return;
3498 targetKey = key;
3499 sweptKeys = resolveSidebarSweepKeys(orderedThreadKeysRef.current, originKey, key, canApply);
3500 setActionSweep({ action, keys: new Set(sweptKeys) });
3501 };
3502 dragSensorRef.current = new SidebarPointerSensor({
3503 active: originKey,
3504 event,
3505 options: {
3506 distance: SIDEBAR_DRAG_DISTANCE,
3507 onAttach: () => {},
3508 onFinish: () => {
3509 dragSensorRef.current = null;
3510 setActionSweep(null);
3511 },
3512 },
3513 onPending: () => {},
3514 onStart: () => sweepTo(originKey),
3515 onMove: ({ y }) =>
3516 sweepTo(threadListRef.current && sidebarThreadKeyAtY(threadListRef.current, y)),
3517 // Also runs after a press that never moved. Nothing is swept then,
3518 // and the button's own click applies its action. Rows that changed
3519 // section mid-gesture, say pinned from another device, are skipped.
3520 onEnd: () => {
3521 const keys = sweptKeys.filter(canApply);
3522 if (action === "settle") {
3523 settleThreads(keys);
3524 return;
3525 }
3526 for (const key of keys) {
3527 const ref = parseScopedThreadKey(key);
3528 if (ref === null) continue;
3529 if (action === "unsettle") attemptUnsettle(ref);
3530 else attemptUnsnooze(ref);
3531 }
3532 },
3533 onCancel: () => {},
3534 onAbort: () => {},
3535 });
3536 },
3537 [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads],
3538 );
3402 const pinnedKeys = useMemo(3539 const pinnedKeys = useMemo(
3403 () =>3540 () =>
3404 pinnedThreads.map((thread) =>3541 pinnedThreads.map((thread) =>
...@@ -4155,17 +4292,7 @@ export default function Sidebar() {...@@ -4155,17 +4292,7 @@ export default function Sidebar() {
4155 return;4292 return;
4156 }4293 }
4157 if (clicked.value === "settle") {4294 if (clicked.value === "settle") {
4158 // Post-settle navigation must skip threads settling in this same4295 settleThreads(threadKeys);
4159 // batch — they are all leaving the card block together. Rows that
4160 // are already explicitly settled are skipped: nothing to do on a
4161 // valid mixed selection. Pinned rows ARE included: the decider
4162 // clears the pin as part of settling, so they park like the rest.
4163 const coSettlingKeys = new Set(threadKeys);
4164 for (const threadKey of threadKeys) {
4165 const thread = threadByKeyRef.current.get(threadKey);
4166 if (!thread || thread.settledOverride === "settled") continue;
4167 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
4168 }
4169 clearSelection();4296 clearSelection();
4170 return;4297 return;
4171 }4298 }
...@@ -4218,7 +4345,6 @@ export default function Sidebar() {...@@ -4218,7 +4345,6 @@ export default function Sidebar() {
4218 );4345 );
4219 },4346 },
4220 [4347 [
4221 attemptSettle,
4222 attemptUnpin,4348 attemptUnpin,
4223 clearSelection,4349 clearSelection,
4224 confirmThreadDelete,4350 confirmThreadDelete,
...@@ -4227,6 +4353,7 @@ export default function Sidebar() {...@@ -4227,6 +4353,7 @@ export default function Sidebar() {
4227 performSnooze,4353 performSnooze,
4228 removeFromSelection,4354 removeFromSelection,
4229 serverConfigs,4355 serverConfigs,
4356 settleThreads,
4230 updateThreadMetadata,4357 updateThreadMetadata,
4231 timestampFormat,4358 timestampFormat,
4232 ],4359 ],
...@@ -4953,6 +5080,10 @@ export default function Sidebar() {...@@ -4953,6 +5080,10 @@ export default function Sidebar() {
4953 className={cn(5080 className={cn(
4954 "relative flex flex-col gap-px",5081 "relative flex flex-col gap-px",
4955 sidebarListItems.length > 0 && "flex-1",5082 sidebarListItems.length > 0 && "flex-1",
5083 // An action sweep owns the pointer: rows it passes over
5084 // neither show hover actions nor open tooltips, even
5085 // controls that opt back in, like the Woke pill.
5086 actionSweep !== null && "**:pointer-events-none",
4956 )}5087 )}
4957 >5088 >
4958 {(() => {5089 {(() => {
...@@ -5009,6 +5140,9 @@ export default function Sidebar() {...@@ -5009,6 +5140,9 @@ export default function Sidebar() {
5009 dragOverPinned={5140 dragOverPinned={
5010 dragState?.activeKey === threadKey && dragTargetSection === "pinned"5141 dragState?.activeKey === threadKey && dragTargetSection === "pinned"
5011 }5142 }
5143 sweepAction={
5144 actionSweep?.keys.has(threadKey) ? actionSweep.action : null
5145 }
5012 snoozeWakeLabelText={5146 snoozeWakeLabelText={
5013 section === "snoozed" && thread.snoozedUntil != null5147 section === "snoozed" && thread.snoozedUntil != null
5014 ? snoozeWakeLabel(thread.snoozedUntil, {5148 ? snoozeWakeLabel(thread.snoozedUntil, {
...@@ -5057,6 +5191,7 @@ export default function Sidebar() {...@@ -5057,6 +5191,7 @@ export default function Sidebar() {
5057 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}5191 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
5058 onContextMenu={handleThreadContextMenu}5192 onContextMenu={handleThreadContextMenu}
5059 onSettle={attemptSettle}5193 onSettle={attemptSettle}
5194 onActionSweepStart={startActionSweep}
5060 onUnsettle={attemptUnsettle}5195 onUnsettle={attemptUnsettle}
5061 onSnooze={attemptSnooze}5196 onSnooze={attemptSnooze}
5062 onUnsnooze={attemptUnsnooze}5197 onUnsnooze={attemptUnsnooze}
docs/user/thread-sidebar.md+5
...@@ -131,6 +131,11 @@ sending an answer or restarting the agent. Settling also closes the thread's...@@ -131,6 +131,11 @@ sending an answer or restarting the agent. Settling also closes the thread's
131terminals that wait at an idle prompt, and keeps their output. A terminal that131terminals that wait at an idle prompt, and keeps their output. A terminal that
132runs a command, such as a dev server, stays open.132runs a command, such as a dev server, stays open.
133133
134On web and desktop, press a thread's **Settle** button and drag up or down to
135settle every thread in that section between it and the one you release on.
136The **Un-settle** and **Wake** buttons work the same way in their sections.
137Press `Escape` while dragging to cancel.
138
134By default, environments settle inactive threads after three days and settle139By default, environments settle inactive threads after three days and settle
135threads whose pull request merged. A closed pull request can also settle an idle140threads whose pull request merged. A closed pull request can also settle an idle
136thread. Work in progress, pending questions or approvals, and live background work141thread. Work in progress, pending questions or approvals, and live background work