From 1826fb55cc4c9c6246d31175e15a496ba4778e07 Mon Sep 17 00:00:00 2001 From: Argo <126553318+argofowl@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:39:33 +0500 Subject: [PATCH] feat(web): sweep sidebar buttons to settle, un-settle, and wake threads (#14768) Co-authored-by: maria-rcks --- apps/web/src/components/Sidebar.logic.test.ts | 17 ++ apps/web/src/components/Sidebar.logic.ts | 28 +++ .../src/components/Sidebar.pointer.test.ts | 15 +- apps/web/src/components/Sidebar.pointer.ts | 7 +- apps/web/src/components/Sidebar.tsx | 181 +++++++++++++++--- docs/user/thread-sidebar.md | 5 + 6 files changed, 225 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5b8b7688953a823972079dafa6fdfe73f064f080..6d3432f3ddb9611f7817fcb61a9a50c0434be36d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, + resolveSidebarSweepKeys, resolveSidebarStageBadgeLabel, resolveSidebarThreadSection, resolveSidebarRowAccessibility, @@ -2014,6 +2015,22 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); +describe("resolveSidebarSweepKeys", () => { + const ordered = ["a", "b", "c", "d", "blocked"]; + const canSettle = (key: string) => key !== "blocked"; + + it("covers every row between the pressed row and the pointer, in either direction", () => { + expect(resolveSidebarSweepKeys(ordered, "b", "b", canSettle)).toEqual(["b"]); + expect(resolveSidebarSweepKeys(ordered, "b", "d", canSettle)).toEqual(["b", "c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "d", "a", canSettle)).toEqual(["a", "b", "c", "d"]); + }); + + it("leaves out rows that cannot settle and rows that left the list", () => { + expect(resolveSidebarSweepKeys(ordered, "c", "blocked", canSettle)).toEqual(["c", "d"]); + expect(resolveSidebarSweepKeys(ordered, "gone", "a", canSettle)).toEqual([]); + }); +}); + describe("navigation after parking a thread", () => { it.each([ ["settle", "settled", null, "thread", true], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5dd6444ad8fbc5cfad1ee50a2b13f74667328475..3bd900ac4c67b954afc61222d2bf124a4063832d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -274,6 +274,34 @@ export function resolveSidebarDropVerb( return "wake"; } +/** Eligible rows between the pressed action and the pointer, in sidebar order. */ +export function resolveSidebarSweepKeys( + orderedKeys: readonly string[], + originKey: string, + targetKey: string, + canApply: (key: string) => boolean, +): string[] { + const origin = orderedKeys.indexOf(originKey); + const target = orderedKeys.indexOf(targetKey); + if (origin === -1 || target === -1) return []; + return orderedKeys.slice(Math.min(origin, target), Math.max(origin, target) + 1).filter(canApply); +} + +/** The thread row at a pointer height, clamped to the rows visible in the + sidebar's scroll viewport. A gap between rows resolves to the row above + it. Rows carry their key in data-thread-item, which departing motion + clones drop. */ +export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null { + const viewport = list.closest('[data-slot="scroll-area-viewport"]')?.getBoundingClientRect(); + const visibleY = viewport ? Math.min(Math.max(y, viewport.top), viewport.bottom - 1) : y; + let key: string | null = null; + for (const row of list.querySelectorAll("li[data-thread-item]")) { + if (key !== null && row.getBoundingClientRect().top > visibleY) break; + key = row.dataset.threadItem ?? null; + } + return key; +} + export function planSidebarThreadDrop(input: { readonly activeKey: string; readonly activeSection: SidebarSection; diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index a70486673b648373839987b1fda0a7abd7336fd5..3a54498c230408652844722e78dda7e35a236eee 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -80,6 +80,13 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onFinish).toHaveBeenCalledOnce(); }); + it("moves to the pointer on the move that starts the drag", () => { + const drag = gesture(); + document.dispatchEvent(pointer("pointermove", { clientY: 90 })); + expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); + expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 }); + }); + const interruptions = { blur: () => window.dispatchEvent(new Event("blur")), hidden: () => { @@ -122,10 +129,11 @@ describe("sidebar pointer lifecycle", () => { const drag = gesture({ onMove, onDrop }); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 }); + expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); // Outside the list the sort no longer sees the pointer. - expect(drag.onMove).toHaveBeenCalledOnce(); + expect(drag.onMove).toHaveBeenCalledTimes(2); document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 }); expect(drag.onCancel).toHaveBeenCalledOnce(); @@ -148,7 +156,8 @@ describe("sidebar pointer lifecycle", () => { document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 })); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 })); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 30 }); + expect(drag.onMove).toHaveBeenCalledTimes(2); + expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 }); document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 })); expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 }); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index e2294b37ba0ce99133f93fb0586cca37e58a4a40..5ae83901ed1e1b2cd3f0f6bd7f1de5422ccf9fed 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -34,7 +34,9 @@ export class SidebarPointerSensor { private readonly document: Document; private readonly window: Window; - constructor(private readonly props: SensorProps) { + // The settle sweep constructs this sensor directly, outside dnd-kit, so it + // takes only the props the gesture reads. + constructor(private readonly props: Omit, "activeNode" | "context">) { this.pointer = props.event as PointerEvent; this.document = getOwnerDocument(this.pointer.target); this.window = getWindow(this.pointer.target); @@ -89,8 +91,9 @@ export class SidebarPointerSensor { this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); this.props.onStart(this.coordinates()); - return; } + // The move that starts a drag also moves it, so a release before the + // next pointermove still lands where the pointer is. if (this.phase === "dragging") { if (event.cancelable) event.preventDefault(); if (this.props.options.onMove?.(coordinates) === true) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 716f4cad9cc732dfc932660d533dc7484f5996d0..3a78102c9c758fc1534e5eef572d797f824d466b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -92,6 +92,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -188,6 +189,7 @@ import { planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarSweepKeys, resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, @@ -202,6 +204,7 @@ import { resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, + sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, sortSidebarV2ProjectGroups, @@ -706,6 +709,12 @@ function SidebarSectionPlaceholder(props: { ); } +// Pointer travel before a press on a row starts a drag, or a press on its +// action button starts a sweep. Shorter presses stay clicks. +const SIDEBAR_DRAG_DISTANCE = 6; + +type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; + // Zero-height markers reserve no label space at rest. During a drag the // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; @@ -1064,9 +1073,8 @@ const dropVerbBadge: Record = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; - // Slim rows are either settled (action: un-settle) or merely quiet - // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle" | "unsnooze"; + // Settled rows un-settle, snoozed rows wake, and cards settle. + variantAction: SidebarSweepAction; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -1090,6 +1098,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: boolean; + // The action this row will take when the sweep is released. + sweepAction: SidebarSweepAction | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1115,6 +1125,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; + onActionSweepStart: ( + threadRef: ScopedThreadRef, + action: SidebarSweepAction, + event: PointerEvent, + ) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; @@ -1138,6 +1153,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onFileDropThreads, onRenameTitleChange, onSettle, + onActionSweepStart, onSnooze, onStartRename, onThreadActivate, @@ -1433,6 +1449,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onSettle, threadRef], ); + const handleActionPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!event.isPrimary || event.button !== 0) return; + // Action buttons sweep their section rather than picking up the row. + event.stopPropagation(); + onActionSweepStart(threadRef, variantAction, event.nativeEvent); + }, + [onActionSweepStart, threadRef, variantAction], + ); const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1510,7 +1535,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" - : isSelected + : isSelected || props.sweepAction !== null ? "bg-sidebar-row-selected text-sidebar-foreground" : hasUnsentDraft ? cn(draftSurfaceClassName, "text-sidebar-foreground") @@ -1550,13 +1575,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ...sortable.listeners, } : {}; + // Sweeps reuse the corresponding row-drop action badge. + const destinationVerb = sortable?.isDragging + ? props.dropVerb + : props.sweepAction === "unsnooze" + ? "wake" + : props.sweepAction; const dragDestination = - sortable?.isDragging && props.dropVerb !== null ? ( + destinationVerb !== null ? ( - {dropVerbBadge[props.dropVerb]} + {dropVerbBadge[destinationVerb]} ) : null; @@ -1701,7 +1732,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { if (variant === "slim") { return (
  • + )} + {props.sweepAction !== null ? dragDestination : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1857,7 +1897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • + {/* 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. */} @@ -2024,6 +2069,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} + onPointerDown={handleActionPointerDown} 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" /> } @@ -2038,6 +2084,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} )} + {/* A sweep hides the slot rather than unmounting it, so the + pressed action button stays connected and a cancelled sweep's + release click still fires and is consumed. */} + {props.sweepAction !== null ? dragDestination : null}
    {title} @@ -3252,6 +3302,22 @@ export default function Sidebar() { }, [planForwardNavigation, settleThread], ); + // Post-settle navigation must skip threads settling in this same batch — + // they are all leaving the card block together. Rows that are already + // explicitly settled are skipped: nothing to do on a valid mixed selection. + // Pinned rows ARE included: the decider clears the pin as part of settling, + // so they park like the rest. + const settleThreads = useCallback( + (threadKeys: readonly string[]) => { + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + }, + [attemptSettle], + ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -3378,7 +3444,7 @@ export default function Sidebar() { ); const dndSensors = useSensors( useSensor(SidebarPointerSensor, { - distance: 6, + distance: SIDEBAR_DRAG_DISTANCE, onAttach: attachDragSensor, onFinish: finishThreadDrag, onMove: moveThreadContextDrag, @@ -3399,6 +3465,77 @@ export default function Sidebar() { add(settledThreads, "settled"); return map; }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]); + const sectionByThreadKeyRef = useRef(sectionByThreadKey); + sectionByThreadKeyRef.current = sectionByThreadKey; + // Drag a row action to apply it to the armed rows in the same section. + // Staying in one section keeps an overshoot from changing neighboring + // sections, whose boundaries may be hidden outside a row drag. The sweep + // runs on the row drag's sensor and ref, so unmounting the list cancels it. + const [actionSweep, setActionSweep] = useState<{ + action: SidebarSweepAction; + keys: ReadonlySet; + } | null>(null); + const startActionSweep = useCallback( + (threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => { + const originKey = scopedThreadKey(threadRef); + const originSection = sectionByThreadKeyRef.current.get(originKey); + const canApply = (key: string) => { + const thread = threadByKeyRef.current.get(key); + const capabilities = + thread && serverConfigs.get(thread.environmentId)?.environment.capabilities; + return ( + thread !== undefined && + sectionByThreadKeyRef.current.get(key) === originSection && + (action === "unsnooze" + ? capabilities?.threadSnooze === true + : capabilities?.threadSettlement === true) + ); + }; + let targetKey: string | null = null; + let sweptKeys: string[] = []; + const sweepTo = (key: string | null) => { + if (key === null || key === targetKey) return; + targetKey = key; + sweptKeys = resolveSidebarSweepKeys(orderedThreadKeysRef.current, originKey, key, canApply); + setActionSweep({ action, keys: new Set(sweptKeys) }); + }; + dragSensorRef.current = new SidebarPointerSensor({ + active: originKey, + event, + options: { + distance: SIDEBAR_DRAG_DISTANCE, + onAttach: () => {}, + onFinish: () => { + dragSensorRef.current = null; + setActionSweep(null); + }, + }, + onPending: () => {}, + onStart: () => sweepTo(originKey), + onMove: ({ y }) => + sweepTo(threadListRef.current && sidebarThreadKeyAtY(threadListRef.current, y)), + // Also runs after a press that never moved. Nothing is swept then, + // and the button's own click applies its action. Rows that changed + // section mid-gesture, say pinned from another device, are skipped. + onEnd: () => { + const keys = sweptKeys.filter(canApply); + if (action === "settle") { + settleThreads(keys); + return; + } + for (const key of keys) { + const ref = parseScopedThreadKey(key); + if (ref === null) continue; + if (action === "unsettle") attemptUnsettle(ref); + else attemptUnsnooze(ref); + } + }, + onCancel: () => {}, + onAbort: () => {}, + }); + }, + [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], + ); const pinnedKeys = useMemo( () => pinnedThreads.map((thread) => @@ -4155,17 +4292,7 @@ export default function Sidebar() { return; } if (clicked.value === "settle") { - // Post-settle navigation must skip threads settling in this same - // batch — they are all leaving the card block together. Rows that - // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. Pinned rows ARE included: the decider - // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; - attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); - } + settleThreads(threadKeys); clearSelection(); return; } @@ -4218,7 +4345,6 @@ export default function Sidebar() { ); }, [ - attemptSettle, attemptUnpin, clearSelection, confirmThreadDelete, @@ -4227,6 +4353,7 @@ export default function Sidebar() { performSnooze, removeFromSelection, serverConfigs, + settleThreads, updateThreadMetadata, timestampFormat, ], @@ -4953,6 +5080,10 @@ export default function Sidebar() { className={cn( "relative flex flex-col gap-px", sidebarListItems.length > 0 && "flex-1", + // An action sweep owns the pointer: rows it passes over + // neither show hover actions nor open tooltips, even + // controls that opt back in, like the Woke pill. + actionSweep !== null && "**:pointer-events-none", )} > {(() => { @@ -5009,6 +5140,9 @@ export default function Sidebar() { dragOverPinned={ dragState?.activeKey === threadKey && dragTargetSection === "pinned" } + sweepAction={ + actionSweep?.keys.has(threadKey) ? actionSweep.action : null + } snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -5057,6 +5191,7 @@ export default function Sidebar() { renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} onSettle={attemptSettle} + onActionSweepStart={startActionSweep} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 38c5caaa8255ead3c2f960a827fa39fd2fa29eb9..285d713a83e8f95e6cc0908e72f3d42d64b794bb 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -131,6 +131,11 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. +On web and desktop, press a thread's **Settle** button and drag up or down to +settle every thread in that section between it and the one you release on. +The **Un-settle** and **Wake** buttons work the same way in their sections. +Press `Escape` while dragging to cancel. + By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work -- 2.54.0