diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d0216d0e4..634d91e9d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic"; +import { + hasUnseenCompletion, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; function makeLatestTurn(overrides?: { completedAt?: string | null; @@ -30,6 +34,34 @@ describe("hasUnseenCompletion", () => { }); }); +describe("shouldClearThreadSelectionOnMouseDown", () => { + it("preserves selection for thread items", () => { + const child = { + closest: (selector: string) => + selector.includes("[data-thread-item]") ? ({} as Element) : null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(child)).toBe(false); + }); + + it("preserves selection for thread list toggle controls", () => { + const selectionSafe = { + closest: (selector: string) => + selector.includes("[data-thread-selection-safe]") ? ({} as Element) : null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(selectionSafe)).toBe(false); + }); + + it("clears selection for unrelated sidebar clicks", () => { + const unrelated = { + closest: () => null, + } as unknown as HTMLElement; + + expect(shouldClearThreadSelectionOnMouseDown(unrelated)).toBe(true); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e950d8de6..379d7b60e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,9 @@ import type { Thread } from "../types"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; +export const THREAD_SELECTION_SAFE_SELECTOR = + "[data-thread-item], [data-thread-selection-safe]"; + export interface ThreadStatusPill { label: | "Working" @@ -30,6 +33,11 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } +export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { + if (target === null) return true; + return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; hasPendingApprovals: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 284ed0da9..88e9d674b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { DndContext, type DragCancelEvent, @@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; -import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -80,9 +80,13 @@ import { SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill } from "./Sidebar.logic"; +import { + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -301,6 +305,12 @@ export default function Sidebar() { const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const shouldBrowseForProjectImmediately = isElectron; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const pendingApprovalByThreadId = useMemo(() => { @@ -628,64 +638,31 @@ export default function Sidebar() { [], ); - const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + /** + * Delete a single thread: stop session, close terminal, dispatch delete, + * clean up drafts/state, and optionally remove orphaned worktree. + * Callers handle thread-level confirmation; this still prompts for worktree removal. + */ + const deleteThread = useCallback( + async ( + threadId: ThreadId, + opts: { deletedThreadIds?: ReadonlySet } = {}, + ): Promise => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); const thread = threads.find((t) => t.id === threadId); if (!thread) return; - if (clicked === "rename") { - setRenamingThreadId(threadId); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; - return; - } - - if (clicked === "mark-unread") { - markThreadUnread(threadId); - return; - } - if (clicked === "copy-thread-id") { - try { - await copyTextToClipboard(threadId); - toastManager.add({ - type: "success", - title: "Thread ID copied", - description: threadId, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to copy thread ID", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - return; - } - if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) { - return; - } - } const threadProject = projects.find((project) => project.id === thread.projectId); - const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); + // When bulk-deleting, exclude the other threads being deleted so + // getOrphanedWorktreePathForThread correctly detects that no surviving + // threads will reference this worktree. + const deletedIds = opts.deletedThreadIds; + const survivingThreads = + deletedIds && deletedIds.size > 0 + ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id)) + : threads; + const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; @@ -713,16 +690,15 @@ export default function Sidebar() { } try { - await api.terminal.close({ - threadId, - deleteHistory: true, - }); + await api.terminal.close({ threadId, deleteHistory: true }); } catch { // Terminal may already be closed } + const allDeletedIds = deletedIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; - const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; + const fallbackThreadId = + threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null; await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), @@ -769,11 +745,9 @@ export default function Sidebar() { } }, [ - appSettings.confirmThreadDelete, clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, - markThreadUnread, navigate, projects, removeWorktreeMutation, @@ -782,6 +756,150 @@ export default function Sidebar() { ], ); + const handleThreadContextMenu = useCallback( + async (threadId: ThreadId, position: { x: number; y: number }) => { + const api = readNativeApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + const thread = threads.find((t) => t.id === threadId); + if (!thread) return; + + if (clicked === "rename") { + setRenamingThreadId(threadId); + setRenamingTitle(thread.title); + renamingCommittedRef.current = false; + return; + } + + if (clicked === "mark-unread") { + markThreadUnread(threadId); + return; + } + if (clicked === "copy-thread-id") { + try { + await copyTextToClipboard(threadId); + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: threadId, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } + if (clicked !== "delete") return; + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + await deleteThread(threadId); + }, + [appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads], + ); + + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readNativeApi(); + if (!api) return; + const ids = [...selectedThreadIds]; + if (ids.length === 0) return; + const count = ids.length; + + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ); + + if (clicked === "mark-unread") { + for (const id of ids) { + markThreadUnread(id); + } + clearSelection(); + return; + } + + if (clicked !== "delete") return; + + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ); + if (!confirmed) return; + } + + const deletedIds = new Set(ids); + for (const id of ids) { + await deleteThread(id, { deletedThreadIds: deletedIds }); + } + removeFromSelection(ids); + }, + [ + appSettings.confirmThreadDelete, + clearSelection, + deleteThread, + markThreadUnread, + removeFromSelection, + selectedThreadIds, + ], + ); + + const handleThreadClick = useCallback( + (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const isShiftClick = event.shiftKey; + + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadId); + return; + } + + if (isShiftClick) { + event.preventDefault(); + rangeSelectTo(threadId, orderedProjectThreadIds); + return; + } + + // Plain click — clear selection, set anchor for future shift-clicks, and navigate + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection], + ); + const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -894,9 +1012,12 @@ export default function Sidebar() { event.stopPropagation(); return; } + if (selectedThreadIds.size > 0) { + clearSelection(); + } toggleProject(projectId); }, - [toggleProject], + [clearSelection, selectedThreadIds.size, toggleProject], ); const handleProjectTitleKeyDown = useCallback( @@ -913,6 +1034,12 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && selectedThreadIds.size > 0) { + event.preventDefault(); + clearSelection(); + return; + } + const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; @@ -937,11 +1064,20 @@ export default function Sidebar() { }); }; + const onMouseDown = (event: globalThis.MouseEvent) => { + if (selectedThreadIds.size === 0) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("mousedown", onMouseDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("mousedown", onMouseDown); }; - }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); + }, [clearSelection, getDraftThread, handleNewThread, keybindings, projects, routeThreadId, selectedThreadIds.size, threads]); useEffect(() => { if (!isElectron) return; @@ -1277,6 +1413,7 @@ export default function Sidebar() { hasHiddenThreads && !isThreadListExpanded ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) : projectThreads; + const orderedProjectThreadIds = projectThreads.map((t) => t.id); return ( @@ -1343,9 +1480,11 @@ export default function Sidebar() { - + {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; const threadStatus = resolveThreadStatusPill({ thread, hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, @@ -1358,25 +1497,28 @@ export default function Sidebar() { ); return ( - + } size="sm" isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ - isActive - ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" - : "text-muted-foreground" + className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ + isSelected + ? "bg-primary/15 text-foreground dark:bg-primary/10" + : isActive + ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" + : "text-muted-foreground" }`} - onClick={() => { - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); + onClick={(event) => { + handleThreadClick(event, thread.id, orderedProjectThreadIds); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); void navigate({ to: "/$threadId", params: { threadId: thread.id }, @@ -1384,10 +1526,20 @@ export default function Sidebar() { }} onContextMenu={(event) => { event.preventDefault(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } }} >
@@ -1474,7 +1626,7 @@ export default function Sidebar() { )} {formatRelativeTime(thread.createdAt)} @@ -1489,6 +1641,7 @@ export default function Sidebar() { } + data-thread-selection-safe size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { @@ -1503,6 +1656,7 @@ export default function Sidebar() { } + data-thread-selection-safe size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { diff --git a/apps/web/src/threadSelectionStore.test.ts b/apps/web/src/threadSelectionStore.test.ts new file mode 100644 index 000000000..b142c5c7d --- /dev/null +++ b/apps/web/src/threadSelectionStore.test.ts @@ -0,0 +1,292 @@ +import { ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useThreadSelectionStore } from "./threadSelectionStore"; + +const THREAD_A = ThreadId.makeUnsafe("thread-a"); +const THREAD_B = ThreadId.makeUnsafe("thread-b"); +const THREAD_C = ThreadId.makeUnsafe("thread-c"); +const THREAD_D = ThreadId.makeUnsafe("thread-d"); +const THREAD_E = ThreadId.makeUnsafe("thread-e"); + +const ORDERED = [THREAD_A, THREAD_B, THREAD_C, THREAD_D, THREAD_E] as const; + +describe("threadSelectionStore", () => { + beforeEach(() => { + useThreadSelectionStore.getState().clearSelection(); + }); + + describe("toggleThread", () => { + it("adds a thread to empty selection", () => { + useThreadSelectionStore.getState().toggleThread(THREAD_A); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); + expect(state.selectedThreadIds.size).toBe(1); + expect(state.anchorThreadId).toBe(THREAD_A); + }); + + it("removes a thread that is already selected", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_A); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_A)).toBe(false); + expect(state.selectedThreadIds.size).toBe(0); + }); + + it("preserves existing selections when toggling a new thread", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.size).toBe(2); + }); + + it("sets anchor to the newly added thread", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + + expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + }); + + it("preserves anchor when deselecting a non-anchor thread", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_A); // deselect A, anchor should stay B + + expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + }); + }); + + describe("setAnchor", () => { + it("sets anchor without adding to selection", () => { + useThreadSelectionStore.getState().setAnchor(THREAD_B); + + const state = useThreadSelectionStore.getState(); + expect(state.anchorThreadId).toBe(THREAD_B); + expect(state.selectedThreadIds.size).toBe(0); + }); + + it("enables range select from a plain-click anchor", () => { + const store = useThreadSelectionStore.getState(); + store.setAnchor(THREAD_B); // simulate plain-click navigate to B + store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); + expect(state.selectedThreadIds.size).toBe(3); + }); + + it("is a no-op when anchor is already set to the same thread", () => { + const store = useThreadSelectionStore.getState(); + store.setAnchor(THREAD_B); + const stateBefore = useThreadSelectionStore.getState(); + store.setAnchor(THREAD_B); + const stateAfter = useThreadSelectionStore.getState(); + + // Should be referentially the same (no unnecessary re-render) + expect(stateAfter).toBe(stateBefore); + }); + + it("survives clearSelection followed by setAnchor", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.clearSelection(); + store.setAnchor(THREAD_C); + + const state = useThreadSelectionStore.getState(); + expect(state.anchorThreadId).toBe(THREAD_C); + expect(state.selectedThreadIds.size).toBe(0); + }); + }); + + describe("rangeSelectTo", () => { + it("selects a single thread when no anchor exists", () => { + useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.size).toBe(1); + expect(state.anchorThreadId).toBe(THREAD_C); + }); + + it("selects range from anchor to target (forward)", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_B); // sets anchor to B + store.rangeSelectTo(THREAD_D, ORDERED); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); + expect(state.selectedThreadIds.size).toBe(3); + }); + + it("selects range from anchor to target (backward)", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_D); // sets anchor to D + store.rangeSelectTo(THREAD_B, ORDERED); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); + expect(state.selectedThreadIds.size).toBe(3); + }); + + it("keeps anchor stable across multiple range selects", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_B); // anchor = B + store.rangeSelectTo(THREAD_D, ORDERED); // selects B-D + store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) + + const state = useThreadSelectionStore.getState(); + expect(state.anchorThreadId).toBe(THREAD_B); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_E)).toBe(true); + }); + + it("falls back to toggle when anchor is not in the ordered list", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); // anchor = A + // Range-select with a list that does NOT contain the anchor + store.rangeSelectTo(THREAD_C, [THREAD_B, THREAD_C, THREAD_D]); + + const state = useThreadSelectionStore.getState(); + // Should have added C and reset anchor to C + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.anchorThreadId).toBe(THREAD_C); + }); + + it("falls back to toggle when target is not in the ordered list", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_B); // anchor = B + const unknownThread = ThreadId.makeUnsafe("thread-unknown"); + store.rangeSelectTo(unknownThread, ORDERED); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(unknownThread)).toBe(true); + expect(state.anchorThreadId).toBe(unknownThread); + }); + + it("selects the single thread when anchor equals target", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_C); // anchor = C + store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.size).toBe(1); + }); + + it("preserves previously selected threads outside the range", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); // select A, anchor = A + store.toggleThread(THREAD_B); // select B, anchor = B + + // Now shift-select from B (anchor) to D — should add B, C, D but keep A + store.rangeSelectTo(THREAD_D, ORDERED); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); + expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); + expect(state.selectedThreadIds.size).toBe(4); + }); + }); + + describe("clearSelection", () => { + it("clears all selected threads and anchor", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.clearSelection(); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.size).toBe(0); + expect(state.anchorThreadId).toBeNull(); + }); + + it("is a no-op when already empty", () => { + const stateBefore = useThreadSelectionStore.getState(); + stateBefore.clearSelection(); + const stateAfter = useThreadSelectionStore.getState(); + + // Should be referentially the same (no unnecessary re-render) + expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + }); + }); + + describe("removeFromSelection", () => { + it("removes specified threads from selection", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_C); + store.removeFromSelection([THREAD_A, THREAD_C]); + + const state = useThreadSelectionStore.getState(); + expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); + expect(state.selectedThreadIds.size).toBe(1); + }); + + it("clears anchor when the anchor thread is removed", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); // anchor = B + store.removeFromSelection([THREAD_B]); + + expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull(); + }); + + it("preserves anchor when the anchor thread is not removed", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); // anchor = B + store.removeFromSelection([THREAD_A]); + + expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + }); + + it("is a no-op when none of the specified threads are selected", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + const stateBefore = useThreadSelectionStore.getState(); + store.removeFromSelection([THREAD_B, THREAD_C]); + const stateAfter = useThreadSelectionStore.getState(); + + expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + }); + }); + + describe("hasSelection", () => { + it("returns false when nothing is selected", () => { + expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); + }); + + it("returns true when threads are selected", () => { + useThreadSelectionStore.getState().toggleThread(THREAD_A); + expect(useThreadSelectionStore.getState().hasSelection()).toBe(true); + }); + + it("returns false after clearing selection", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.clearSelection(); + expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); + }); + }); +}); diff --git a/apps/web/src/threadSelectionStore.ts b/apps/web/src/threadSelectionStore.ts new file mode 100644 index 000000000..8360bc5c6 --- /dev/null +++ b/apps/web/src/threadSelectionStore.ts @@ -0,0 +1,124 @@ +/** + * Zustand store for sidebar thread multi-selection state. + * + * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), + * and bulk actions on the selected set. + */ + +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +export interface ThreadSelectionState { + /** Currently selected thread IDs. */ + selectedThreadIds: ReadonlySet; + /** The thread ID that anchors shift-click range selection. */ + anchorThreadId: ThreadId | null; +} + +interface ThreadSelectionStore extends ThreadSelectionState { + /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */ + toggleThread: (threadId: ThreadId) => void; + /** + * Select a range of threads (Shift+Click). + * Requires the ordered list of thread IDs within the same project + * so the store can compute which threads fall between anchor and target. + */ + rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void; + /** Clear all selection state. */ + clearSelection: () => void; + /** Remove specific thread IDs from the selection (e.g. after deletion). */ + removeFromSelection: (threadIds: readonly ThreadId[]) => void; + /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */ + setAnchor: (threadId: ThreadId) => void; + /** Check if any threads are selected. */ + hasSelection: () => boolean; +} + +const EMPTY_SET = new Set(); + +export const useThreadSelectionStore = create((set, get) => ({ + selectedThreadIds: EMPTY_SET, + anchorThreadId: null, + + toggleThread: (threadId) => { + set((state) => { + const next = new Set(state.selectedThreadIds); + if (next.has(threadId)) { + next.delete(threadId); + } else { + next.add(threadId); + } + return { + selectedThreadIds: next, + anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId, + }; + }); + }, + + rangeSelectTo: (threadId, orderedThreadIds) => { + set((state) => { + const anchor = state.anchorThreadId; + if (anchor === null) { + // No anchor yet — treat as a single toggle + const next = new Set(state.selectedThreadIds); + next.add(threadId); + return { selectedThreadIds: next, anchorThreadId: threadId }; + } + + const anchorIndex = orderedThreadIds.indexOf(anchor); + const targetIndex = orderedThreadIds.indexOf(threadId); + if (anchorIndex === -1 || targetIndex === -1) { + // Anchor or target not in this list (different project?) — fallback to toggle + const next = new Set(state.selectedThreadIds); + next.add(threadId); + return { selectedThreadIds: next, anchorThreadId: threadId }; + } + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const next = new Set(state.selectedThreadIds); + for (let i = start; i <= end; i++) { + const id = orderedThreadIds[i]; + if (id !== undefined) { + next.add(id); + } + } + // Keep anchor stable so subsequent shift-clicks extend from the same point + return { selectedThreadIds: next, anchorThreadId: anchor }; + }); + }, + + clearSelection: () => { + const state = get(); + if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return; + set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null }); + }, + + setAnchor: (threadId) => { + if (get().anchorThreadId === threadId) return; + set({ anchorThreadId: threadId }); + }, + + removeFromSelection: (threadIds) => { + set((state) => { + const toRemove = new Set(threadIds); + let changed = false; + const next = new Set(); + for (const id of state.selectedThreadIds) { + if (toRemove.has(id)) { + changed = true; + } else { + next.add(id); + } + } + if (!changed) return state; + const newAnchor = + state.anchorThreadId !== null && toRemove.has(state.anchorThreadId) + ? null + : state.anchorThreadId; + return { selectedThreadIds: next, anchorThreadId: newAnchor }; + }); + }, + + hasSelection: () => get().selectedThreadIds.size > 0, +}));