diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..0b191925f0d 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,25 @@ describe("getVisibleThreadsForProject", () => { ); expect(result.hiddenThreads).toEqual([]); }); + + 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..80712716f29 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,27 +496,29 @@ 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; }): { hasHiddenThreads: boolean; visibleThreads: T[]; hiddenThreads: T[]; } { const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; + 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)) { + if (!activeThreadId || previewThreads.some((thread) => getThreadId(thread) === activeThreadId)) { return { hasHiddenThreads: true, hiddenThreads: threads.slice(previewLimit), @@ -472,7 +526,7 @@ export function getVisibleThreadsForProject>(input: }; } - const activeThread = threads.find((thread) => thread.id === activeThreadId); + const activeThread = threads.find((thread) => getThreadId(thread) === activeThreadId); if (!activeThread) { return { hasHiddenThreads: true, @@ -481,12 +535,15 @@ export function getVisibleThreadsForProject>(input: }; } - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); + const visibleThreadIds = new Set( + [...previewThreads, activeThread].map((thread) => getThreadId(thread)), + ); + 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 21525b56b77..c080560b73c 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,9 @@ interface SidebarThreadRowProps { orderedProjectThreadKeys: readonly string[]; isActive: boolean; jumpLabel: string | null; + isPinned: boolean; + isThreadDragActive: boolean; + togglePinned: (threadKey: string, pinned: boolean) => void; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; @@ -360,6 +374,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr orderedProjectThreadKeys, isActive, jumpLabel, + isPinned, + isThreadDragActive, + togglePinned, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, @@ -383,6 +400,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 +477,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 +489,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 +520,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr if (currentTarget.contains(document.activeElement)) { return; } + setRowActionsFocusedOrHovered(false); clearConfirmingArchive(); }); }, @@ -667,13 +701,37 @@ 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) => { + if (shouldBlockThreadDragActivation(event.target as HTMLElement)) { + return; + } + threadDrag.listeners?.onPointerDown?.(event); + }, + [threadDrag.listeners], + ); const rowButtonRender = useMemo(() =>
, []); return (
{prStatus && ( @@ -781,6 +841,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 ? (