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 {
3030 reduceSidebarProjectScopeMenuState,
3131 resolveAdjacentThreadId,
3232 resolveProjectStatusIndicator,
33 resolveSidebarSweepKeys,
3334 resolveSidebarStageBadgeLabel,
3435 resolveSidebarThreadSection,
3536 resolveSidebarRowAccessibility,
......@@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => {
20142015 });
20152016});
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
20172034describe("navigation after parking a thread", () => {
20182035 it.each([
20192036 ["settle", "settled", null, "thread", true],
apps/web/src/components/Sidebar.logic.ts+28
......@@ -274,6 +274,34 @@ export function resolveSidebarDropVerb(
274274 return "wake";
275275}
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
277305export function planSidebarThreadDrop(input: {
278306 readonly activeKey: string;
279307 readonly activeSection: SidebarSection;
apps/web/src/components/Sidebar.pointer.test.ts+12-3
......@@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => {
8080 expect(drag.onFinish).toHaveBeenCalledOnce();
8181 });
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
8390 const interruptions = {
8491 blur: () => window.dispatchEvent(new Event("blur")),
8592 hidden: () => {
......@@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => {
122129 const drag = gesture({ onMove, onDrop });
123130 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
124131 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 });
126134 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
127135 // Outside the list the sort no longer sees the pointer.
128 expect(drag.onMove).toHaveBeenCalledOnce();
136 expect(drag.onMove).toHaveBeenCalledTimes(2);
129137 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));
130138 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 });
131139 expect(drag.onCancel).toHaveBeenCalledOnce();
......@@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => {
148156 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
149157 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 }));
150158 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
153162 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 }));
154163 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 });
apps/web/src/components/Sidebar.pointer.ts+5-2
......@@ -34,7 +34,9 @@ export class SidebarPointerSensor {
3434 private readonly document: Document;
3535 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">) {
3840 this.pointer = props.event as PointerEvent;
3941 this.document = getOwnerDocument(this.pointer.target);
4042 this.window = getWindow(this.pointer.target);
......@@ -89,8 +91,9 @@ export class SidebarPointerSensor {
8991 this.document.addEventListener("selectionchange", this.clearSelection);
9092 this.clearSelection();
9193 this.props.onStart(this.coordinates());
92 return;
9394 }
95 // The move that starts a drag also moves it, so a release before the
96 // next pointermove still lands where the pointer is.
9497 if (this.phase === "dragging") {
9598 if (event.cancelable) event.preventDefault();
9699 if (this.props.options.onMove?.(coordinates) === true) return;
apps/web/src/components/Sidebar.tsx+158-23
......@@ -92,6 +92,7 @@ import {
9292 useState,
9393 type KeyboardEvent as ReactKeyboardEvent,
9494 type MouseEvent as ReactMouseEvent,
95 type PointerEvent as ReactPointerEvent,
9596 type ReactNode,
9697} from "react";
9798import { useParams, useRouter } from "@tanstack/react-router";
......@@ -188,6 +189,7 @@ import {
188189 planSidebarThreadDrop,
189190 reduceSidebarProjectScopeMenuState,
190191 resolveAdjacentThreadId,
192 resolveSidebarSweepKeys,
191193 resolveSidebarDropTarget,
192194 resolveSidebarDropVerb,
193195 resolveSidebarThreadSection,
......@@ -202,6 +204,7 @@ import {
202204 resolveWorkingStartedAt,
203205 sidebarListItemId,
204206 sidebarMarkerId,
207 sidebarThreadKeyAtY,
205208 sortInboxThreadsByReturn,
206209 sortPinnedThreadsForSidebar,
207210 sortSidebarV2ProjectGroups,
......@@ -706,6 +709,12 @@ function SidebarSectionPlaceholder(props: {
706709 );
707710}
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
709718// Zero-height markers reserve no label space at rest. During a drag the
710719// sorting strategy opens 24px for a 16px label with 4px clearance on each side.
711720const SIDEBAR_DRAG_LABEL_HEIGHT = 24;
......@@ -1064,9 +1073,8 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
10641073const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10651074 thread: SidebarThreadSummary;
10661075 variant: "card" | "slim";
1067 // Slim rows are either settled (action: un-settle) or merely quiet
1068 // (seen Ready threads — action: settle).
1069 variantAction: "settle" | "unsettle" | "unsnooze";
1076 // Settled rows un-settle, snoozed rows wake, and cards settle.
1077 variantAction: SidebarSweepAction;
10701078 // False on environments whose server predates thread.settle/unsettle:
10711079 // the lifecycle affordances hide entirely rather than fail on click.
10721080 settlementSupported: boolean;
......@@ -1090,6 +1098,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10901098 // the pinned section. Any other position shows the verb badge instead, and
10911099 // the badge carries its own icon.
10921100 dragOverPinned: boolean;
1101 // The action this row will take when the sweep is released.
1102 sweepAction: SidebarSweepAction | null;
10931103 // Compact wake countdown ("2h") for rows in the snoozed shelf.
10941104 snoozeWakeLabelText: string | null;
10951105 // When a snooze ended (timer or early wake); drives the Woke pill until
......@@ -1115,6 +1125,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11151125 renamingTitle: string;
11161126 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
11171127 onSettle: (threadRef: ScopedThreadRef) => void;
1128 onActionSweepStart: (
1129 threadRef: ScopedThreadRef,
1130 action: SidebarSweepAction,
1131 event: PointerEvent,
1132 ) => void;
11181133 onUnsettle: (threadRef: ScopedThreadRef) => void;
11191134 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;
11201135 onUnsnooze: (threadRef: ScopedThreadRef) => void;
......@@ -1138,6 +1153,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11381153 onFileDropThreads,
11391154 onRenameTitleChange,
11401155 onSettle,
1156 onActionSweepStart,
11411157 onSnooze,
11421158 onStartRename,
11431159 onThreadActivate,
......@@ -1433,6 +1449,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14331449 },
14341450 [onSettle, threadRef],
14351451 );
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 );
14361461 const handleUnsettleClick = useCallback(
14371462 (event: ReactMouseEvent) => {
14381463 event.preventDefault();
......@@ -1510,7 +1535,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15101535 variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
15111536 props.isActive
15121537 ? "bg-sidebar-row-active text-sidebar-foreground"
1513 : isSelected
1538 : isSelected || props.sweepAction !== null
15141539 ? "bg-sidebar-row-selected text-sidebar-foreground"
15151540 : hasUnsentDraft
15161541 ? cn(draftSurfaceClassName, "text-sidebar-foreground")
......@@ -1550,13 +1575,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15501575 ...sortable.listeners,
15511576 }
15521577 : {};
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;
15531584 const dragDestination =
1554 sortable?.isDragging && props.dropVerb !== null ? (
1585 destinationVerb !== null ? (
15551586 <span
15561587 role="status"
15571588 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"
15581589 >
1559 {dropVerbBadge[props.dropVerb]}
1590 {dropVerbBadge[destinationVerb]}
15601591 </span>
15611592 ) : null;
15621593
......@@ -1701,7 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17011732 if (variant === "slim") {
17021733 return (
17031734 <li
1704 data-thread-item
1735 data-thread-item={threadKey}
17051736 {...sortableRootProps}
17061737 {...(fileDropHandlers ?? {})}
17071738 className={cn(
......@@ -1757,7 +1788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17571788 {sortable?.isDragging ? (
17581789 dragDestination
17591790 ) : (
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 >
17611797 <span
17621798 className={cn(
17631799 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
......@@ -1803,6 +1839,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18031839 type="button"
18041840 aria-label="Wake thread now"
18051841 onClick={handleUnsnoozeClick}
1842 onPointerDown={handleActionPointerDown}
18061843 className={cn(
18071844 "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",
18081845 isWoke && "group-hover/sidebar-row:static",
......@@ -1819,6 +1856,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18191856 type="button"
18201857 aria-label="Un-settle thread"
18211858 onClick={handleUnsettleClick}
1859 onPointerDown={handleActionPointerDown}
18221860 className={cn(
18231861 "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",
18241862 isWoke && "group-hover/sidebar-row:static",
......@@ -1835,6 +1873,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18351873 type="button"
18361874 aria-label="Settle thread"
18371875 onClick={handleSettleClick}
1876 onPointerDown={handleActionPointerDown}
18381877 className={cn(
18391878 "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",
18401879 isWoke && "group-hover/sidebar-row:static",
......@@ -1845,6 +1884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18451884 )}
18461885 </span>
18471886 )}
1887 {props.sweepAction !== null ? dragDestination : null}
18481888 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
18491889 </TooltipTrigger>
18501890 {detailsTooltip}
......@@ -1857,7 +1897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18571897
18581898 return (
18591899 <li
1860 data-thread-item
1900 data-thread-item={threadKey}
18611901 {...sortableRootProps}
18621902 {...(fileDropHandlers ?? {})}
18631903 className={cn(
......@@ -1912,7 +1952,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19121952 {sortable?.isDragging ? (
19131953 dragDestination
19141954 ) : (
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 >
19161961 {/* Read-only status labels yield to the hover actions. Woke is
19171962 itself an action, so it stays pointer-enabled and visible
19181963 while the other controls appear beside it. */}
......@@ -2024,6 +2069,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
20242069 type="button"
20252070 aria-label="Settle thread"
20262071 onClick={handleSettleClick}
2072 onPointerDown={handleActionPointerDown}
20272073 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"
20282074 />
20292075 }
......@@ -2038,6 +2084,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
20382084 ) : null}
20392085 </span>
20402086 )}
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}
20412091 </div>
20422092 <div className="mt-1 flex min-w-0">
20432093 {title}
......@@ -3252,6 +3302,22 @@ export default function Sidebar() {
32523302 },
32533303 [planForwardNavigation, settleThread],
32543304 );
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 );
32553321 const attemptUnsettle = useCallback(
32563322 (threadRef: ScopedThreadRef) => {
32573323 void (async () => {
......@@ -3378,7 +3444,7 @@ export default function Sidebar() {
33783444 );
33793445 const dndSensors = useSensors(
33803446 useSensor(SidebarPointerSensor, {
3381 distance: 6,
3447 distance: SIDEBAR_DRAG_DISTANCE,
33823448 onAttach: attachDragSensor,
33833449 onFinish: finishThreadDrag,
33843450 onMove: moveThreadContextDrag,
......@@ -3399,6 +3465,77 @@ export default function Sidebar() {
33993465 add(settledThreads, "settled");
34003466 return map;
34013467 }, [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 );
34023539 const pinnedKeys = useMemo(
34033540 () =>
34043541 pinnedThreads.map((thread) =>
......@@ -4155,17 +4292,7 @@ export default function Sidebar() {
41554292 return;
41564293 }
41574294 if (clicked.value === "settle") {
4158 // Post-settle navigation must skip threads settling in this same
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 }
4295 settleThreads(threadKeys);
41694296 clearSelection();
41704297 return;
41714298 }
......@@ -4218,7 +4345,6 @@ export default function Sidebar() {
42184345 );
42194346 },
42204347 [
4221 attemptSettle,
42224348 attemptUnpin,
42234349 clearSelection,
42244350 confirmThreadDelete,
......@@ -4227,6 +4353,7 @@ export default function Sidebar() {
42274353 performSnooze,
42284354 removeFromSelection,
42294355 serverConfigs,
4356 settleThreads,
42304357 updateThreadMetadata,
42314358 timestampFormat,
42324359 ],
......@@ -4953,6 +5080,10 @@ export default function Sidebar() {
49535080 className={cn(
49545081 "relative flex flex-col gap-px",
49555082 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",
49565087 )}
49575088 >
49585089 {(() => {
......@@ -5009,6 +5140,9 @@ export default function Sidebar() {
50095140 dragOverPinned={
50105141 dragState?.activeKey === threadKey && dragTargetSection === "pinned"
50115142 }
5143 sweepAction={
5144 actionSweep?.keys.has(threadKey) ? actionSweep.action : null
5145 }
50125146 snoozeWakeLabelText={
50135147 section === "snoozed" && thread.snoozedUntil != null
50145148 ? snoozeWakeLabel(thread.snoozedUntil, {
......@@ -5057,6 +5191,7 @@ export default function Sidebar() {
50575191 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
50585192 onContextMenu={handleThreadContextMenu}
50595193 onSettle={attemptSettle}
5194 onActionSweepStart={startActionSweep}
50605195 onUnsettle={attemptUnsettle}
50615196 onSnooze={attemptSnooze}
50625197 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
131131terminals that wait at an idle prompt, and keeps their output. A terminal that
132132runs 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
134139By default, environments settle inactive threads after three days and settle
135140threads whose pull request merged. A closed pull request can also settle an idle
136141thread. Work in progress, pending questions or approvals, and live background work