diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..304c5b0a921 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, + prioritizeThreadsByPinnedKeys, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -18,6 +19,10 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + shouldBlockThreadDragActivation, + shouldShowPinnedThreadDivider, + shouldHideThreadMeta, + resolveThreadPinCrossingAction, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; @@ -37,6 +42,118 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("shouldBlockThreadDragActivation", () => { + it("blocks drag activation from interactive descendants", () => { + const input = { closest: vi.fn(() => ({})) } as unknown as HTMLElement; + const button = { closest: vi.fn(() => ({})) } as unknown as HTMLElement; + const row = { closest: vi.fn(() => null) } as unknown as HTMLElement; + + expect(shouldBlockThreadDragActivation(input)).toBe(true); + expect(shouldBlockThreadDragActivation(button)).toBe(true); + expect(shouldBlockThreadDragActivation(row)).toBe(false); + }); +}); + +describe("shouldHideThreadMeta", () => { + it("keeps mobile metadata visible beside always-visible actions", () => { + expect( + shouldHideThreadMeta({ + isConfirmingArchive: false, + isMobile: true, + showRowActions: true, + }), + ).toBe(false); + }); + + it("hides metadata for desktop actions and archive confirmation", () => { + expect( + shouldHideThreadMeta({ + isConfirmingArchive: false, + isMobile: false, + showRowActions: true, + }), + ).toBe(true); + expect( + shouldHideThreadMeta({ + isConfirmingArchive: true, + isMobile: true, + showRowActions: true, + }), + ).toBe(true); + }); +}); + +describe("prioritizeThreadsByPinnedKeys", () => { + it("moves only pinned threads to the front while preserving the current sort order", () => { + const threads = [{ id: "newest" }, { id: "pinned" }, { id: "oldest" }]; + + expect(prioritizeThreadsByPinnedKeys(threads, ["pinned"], (thread) => thread.id)).toEqual([ + { id: "pinned" }, + { id: "newest" }, + { id: "oldest" }, + ]); + }); +}); + +describe("resolveThreadPinCrossingAction", () => { + it("pins an unpinned thread after its center crosses above the divider", () => { + expect( + resolveThreadPinCrossingAction({ + activeIsPinned: false, + draggedCenterY: 90, + dividerCenterY: 100, + }), + ).toBe("pin"); + expect( + resolveThreadPinCrossingAction({ + activeIsPinned: false, + draggedCenterY: 110, + dividerCenterY: 100, + }), + ).toBeNull(); + }); + + it("unpins a pinned thread after its center crosses below the divider", () => { + expect( + resolveThreadPinCrossingAction({ + activeIsPinned: true, + draggedCenterY: 110, + dividerCenterY: 100, + }), + ).toBe("unpin"); + expect( + resolveThreadPinCrossingAction({ + activeIsPinned: true, + draggedCenterY: 90, + dividerCenterY: 100, + }), + ).toBeNull(); + }); +}); + +describe("shouldShowPinnedThreadDivider", () => { + it("shows between mixed groups and during edge-case drags", () => { + expect( + shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 1, regularCount: 1 }), + ).toBe(true); + expect( + shouldShowPinnedThreadDivider({ isDragging: true, pinnedCount: 0, regularCount: 2 }), + ).toBe(true); + expect( + shouldShowPinnedThreadDivider({ isDragging: true, pinnedCount: 2, regularCount: 0 }), + ).toBe(true); + }); + + it("stays hidden outside a drag when only one group exists", () => { + expect( + shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 0, regularCount: 2 }), + ).toBe(false); + expect( + shouldShowPinnedThreadDivider({ isDragging: false, pinnedCount: 2, regularCount: 0 }), + ).toBe(false); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( @@ -790,6 +907,56 @@ describe("getVisibleThreadsForProject", () => { ); expect(result.hiddenThreads).toEqual([]); }); + + it("includes every always-visible thread beyond the folded preview limit", () => { + const threads = Array.from({ length: 9 }, (_, index) => + makeThread({ + id: ThreadId.make(`thread-${index + 1}`), + }), + ); + + const result = getVisibleThreadsForProject({ + threads, + activeThreadId: undefined, + isThreadListExpanded: false, + previewLimit: 3, + isThreadAlwaysVisible: (thread) => Number(thread.id.split("-")[1]) <= 5, + }); + + expect(result.hasHiddenThreads).toBe(true); + expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-1"), + ThreadId.make("thread-2"), + ThreadId.make("thread-3"), + ThreadId.make("thread-4"), + ThreadId.make("thread-5"), + ]); + expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-6"), + ThreadId.make("thread-7"), + ThreadId.make("thread-8"), + ThreadId.make("thread-9"), + ]); + }); + + it("supports scoped identities for grouped projects", () => { + const threads = [ + { id: ThreadId.make("shared"), scopedKey: "env-a:shared" }, + { id: ThreadId.make("shared"), scopedKey: "env-b:shared" }, + ]; + + const result = getVisibleThreadsForProject({ + threads, + activeThreadId: "env-b:shared", + isThreadListExpanded: false, + previewLimit: 1, + getThreadId: (thread) => thread.scopedKey, + }); + + expect(result.hasHiddenThreads).toBe(false); + expect(result.hiddenThreads).toEqual([]); + expect(result.visibleThreads).toEqual(threads); + }); }); function makeProject(overrides: Partial = {}): Project { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..318a4f1284f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -26,6 +26,43 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; +export function prioritizeThreadsByPinnedKeys( + threads: readonly T[], + pinnedKeys: readonly string[], + getKey: (thread: T) => string, +): T[] { + if (pinnedKeys.length === 0) return [...threads]; + const pinned = new Set(pinnedKeys); + return [ + ...threads.filter((thread) => pinned.has(getKey(thread))), + ...threads.filter((thread) => !pinned.has(getKey(thread))), + ]; +} + +export type ThreadPinDropAction = "pin" | "unpin"; + +export function resolveThreadPinCrossingAction(input: { + activeIsPinned: boolean; + draggedCenterY: number; + dividerCenterY: number; +}): ThreadPinDropAction | null { + if (input.activeIsPinned) { + return input.draggedCenterY > input.dividerCenterY ? "unpin" : null; + } + return input.draggedCenterY < input.dividerCenterY ? "pin" : null; +} + +export function shouldShowPinnedThreadDivider(input: { + isDragging: boolean; + pinnedCount: number; + regularCount: number; +}): boolean { + return ( + (input.pinnedCount > 0 && input.regularCount > 0) || + (input.isDragging && input.pinnedCount + input.regularCount > 0) + ); +} + export interface ThreadStatusPill { label: | "Working" @@ -168,6 +205,21 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +const THREAD_DRAG_BLOCKED_SELECTOR = + "input, textarea, select, button, a, [contenteditable='true'], [data-thread-selection-safe]"; + +export function shouldBlockThreadDragActivation(target: HTMLElement | null): boolean { + return target !== null && target.closest(THREAD_DRAG_BLOCKED_SELECTOR) !== null; +} + +export function shouldHideThreadMeta(input: { + isConfirmingArchive: boolean; + isMobile: boolean; + showRowActions: boolean; +}): boolean { + return input.isConfirmingArchive || (!input.isMobile && input.showRowActions); +} + // A double-click dispatches two `click` events before `dblclick`: the first has // `detail === 1`, the second `detail === 2`. The second click must not run the // row's single-click navigation, otherwise double-click-to-rename would also @@ -444,49 +496,45 @@ export function resolveProjectStatusIndicator( export function getVisibleThreadsForProject>(input: { threads: readonly T[]; - activeThreadId: T["id"] | undefined; + activeThreadId: string | undefined; isThreadListExpanded: boolean; previewLimit: number; + getThreadId?: (thread: T) => string; + isThreadAlwaysVisible?: (thread: T) => boolean; }): { hasHiddenThreads: boolean; visibleThreads: T[]; hiddenThreads: T[]; } { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; + const { activeThreadId, isThreadAlwaysVisible, isThreadListExpanded, previewLimit, threads } = + input; + const getThreadId = input.getThreadId ?? ((thread: T) => thread.id); + const exceedsPreviewLimit = threads.length > previewLimit; - if (!hasHiddenThreads || isThreadListExpanded) { + if (!exceedsPreviewLimit || isThreadListExpanded) { return { - hasHiddenThreads, + hasHiddenThreads: exceedsPreviewLimit, hiddenThreads: [], visibleThreads: [...threads], }; } const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const activeThread = threads.find((thread) => thread.id === activeThreadId); - if (!activeThread) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; + const visibleThreadIds = new Set(previewThreads.map((thread) => getThreadId(thread))); + for (const thread of threads) { + if ( + (activeThreadId !== undefined && getThreadId(thread) === activeThreadId) || + isThreadAlwaysVisible?.(thread) + ) { + visibleThreadIds.add(getThreadId(thread)); + } } - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); - + const hiddenThreads = threads.filter((thread) => !visibleThreadIds.has(getThreadId(thread))); return { - hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), + hasHiddenThreads: hiddenThreads.length > 0, + hiddenThreads, + visibleThreads: threads.filter((thread) => visibleThreadIds.has(getThreadId(thread))), }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6a76494daa3..6cf28982ec2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,6 +7,8 @@ import { FolderPlusIcon, Globe2Icon, LoaderIcon, + PinIcon, + PinOffIcon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -30,10 +32,12 @@ import { DndContext, type DragCancelEvent, type CollisionDetection, + type DragMoveEvent, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, + useDraggable, useSensor, useSensors, type DragEndEvent, @@ -185,6 +189,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, + getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -195,7 +200,13 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + prioritizeThreadsByPinnedKeys, shouldClearThreadSelectionOnMouseDown, + shouldBlockThreadDragActivation, + shouldShowPinnedThreadDivider, + shouldHideThreadMeta, + resolveThreadPinCrossingAction, + type ThreadPinDropAction, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, @@ -323,6 +334,10 @@ interface SidebarThreadRowProps { orderedProjectThreadKeys: readonly string[]; isActive: boolean; jumpLabel: string | null; + isPinned: boolean; + isThreadDragActive: boolean; + suppressClickAfterDragRef: React.RefObject; + togglePinned: (threadKey: string, pinned: boolean) => void; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; @@ -360,6 +375,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr orderedProjectThreadKeys, isActive, jumpLabel, + isPinned, + isThreadDragActive, + suppressClickAfterDragRef, + togglePinned, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, @@ -383,6 +402,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const threadDrag = useDraggable({ id: threadKey }); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ @@ -459,6 +479,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; + const [rowActionsFocusedOrHovered, setRowActionsFocusedOrHovered] = useState(false); + const showRowActions = !isThreadDragActive && (isMobile || rowActionsFocusedOrHovered); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -469,17 +491,30 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive + const hideThreadMeta = shouldHideThreadMeta({ + isConfirmingArchive, + isMobile, + showRowActions, + }); + const threadMetaClassName = hideThreadMeta ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; + : "pointer-events-none transition-opacity duration-150 max-sm:pr-12"; + const rowActionVisibilityClass = showRowActions + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0"; const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { + setRowActionsFocusedOrHovered(false); clearConfirmingArchive(); }, [clearConfirmingArchive]); + const handleMouseEnter = useCallback(() => { + setRowActionsFocusedOrHovered(true); + }, []); + const handleFocusCapture = useCallback(() => { + setRowActionsFocusedOrHovered(true); + }, []); const handleBlurCapture = useCallback( (event: React.FocusEvent) => { const currentTarget = event.currentTarget; @@ -487,6 +522,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr if (currentTarget.contains(document.activeElement)) { return; } + setRowActionsFocusedOrHovered(false); clearConfirmingArchive(); }); }, @@ -494,9 +530,15 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const handleRowClick = useCallback( (event: React.MouseEvent) => { + if (suppressClickAfterDragRef.current) { + suppressClickAfterDragRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + [handleThreadClick, orderedProjectThreadKeys, suppressClickAfterDragRef, threadRef], ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { @@ -667,13 +709,38 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptArchiveThread, threadRef], ); + const handleTogglePinnedClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + togglePinned(threadKey, !isPinned); + }, + [isPinned, threadKey, togglePinned], + ); + const handleThreadDragPointerDown = useCallback( + (event: React.PointerEvent) => { + suppressClickAfterDragRef.current = false; + if (shouldBlockThreadDragActivation(event.target as HTMLElement)) { + return; + } + threadDrag.listeners?.onPointerDown?.(event); + }, + [suppressClickAfterDragRef, threadDrag.listeners], + ); const rowButtonRender = useMemo(() =>
, []); return (
{prStatus && ( @@ -783,6 +852,34 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20" }`} > + {!isConfirmingArchive && ( + + + +
+ } + /> + {isPinned ? "Unpin" : "Pin"} + + )} {isConfirmingArchive ? (