authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-10 18:03:37+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 00:03:37-07:00
logafb6e89bfdd9164c142162597bb865540b1909d2
tree8545ce1a68d37391dd5b48828c3b546e21aca27c
parent9309edf4f42c59de6489f0ea1a6b72578d637b9d
signature Signed by PGP key B5690EEEBB952194

feat(web): add shift-click multi-select for sidebar threads (#651)

* feat(web): add shift-click multi-select for sidebar threads Add support for selecting multiple threads in the sidebar via Cmd/Ctrl+Click (toggle) and Shift+Click (range select), with bulk context menu actions for Delete and Mark Unread. - New threadSelectionStore (Zustand) for ephemeral selection state with anchor-based range selection and no-op guards - Platform-aware modifier detection (Cmd on Mac, Ctrl elsewhere) matching existing isMacPlatform pattern in terminal-links - Extracted deleteThread helper from handleThreadContextMenu for reuse by bulk delete, with deletedThreadIds set for correct fallback navigation during batch operations - Selected threads share the active thread highlight style - Escape key clears selection; plain click clears and navigates - Right-click on selected thread shows bulk menu; right-click on unselected thread clears selection and shows single-thread menu - 22 unit tests covering toggle, range select, clear, remove, and edge cases (cross-project fallback, anchor stability) * fix(web): filter deleted threads from worktree orphan check during bulk delete When bulk-deleting threads that share a worktree, the stale threads closure caused getOrphanedWorktreePathForThread to see the other threads-being-deleted as still alive, suppressing the orphaned worktree cleanup prompt. Filter out deletedThreadIds (keeping the current thread) so the check correctly identifies worktrees that will have no surviving references. * fix(web): fix shift-click range selection, distinct selection styling, and deselect on click away * Preserve thread multi-select on safe sidebar control clicks - avoid clearing selection on mousedown for thread items and marked safe controls - centralize the selection-safe selector and add logic tests for clear vs preserve behavior - simplify thread selection store typing by merging state/actions interface --------- Co-authored-by: Julius Marminge <julius0216@outlook.com>

5 files changed, 691 insertions(+), 81 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+33-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vitest";
22
3import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic";
3import {
4 hasUnseenCompletion,
5 resolveThreadStatusPill,
6 shouldClearThreadSelectionOnMouseDown,
7} from "./Sidebar.logic";
48
59function makeLatestTurn(overrides?: {
610 completedAt?: string | null;
......@@ -30,6 +34,34 @@ describe("hasUnseenCompletion", () => {
3034 });
3135});
3236
37describe("shouldClearThreadSelectionOnMouseDown", () => {
38 it("preserves selection for thread items", () => {
39 const child = {
40 closest: (selector: string) =>
41 selector.includes("[data-thread-item]") ? ({} as Element) : null,
42 } as unknown as HTMLElement;
43
44 expect(shouldClearThreadSelectionOnMouseDown(child)).toBe(false);
45 });
46
47 it("preserves selection for thread list toggle controls", () => {
48 const selectionSafe = {
49 closest: (selector: string) =>
50 selector.includes("[data-thread-selection-safe]") ? ({} as Element) : null,
51 } as unknown as HTMLElement;
52
53 expect(shouldClearThreadSelectionOnMouseDown(selectionSafe)).toBe(false);
54 });
55
56 it("clears selection for unrelated sidebar clicks", () => {
57 const unrelated = {
58 closest: () => null,
59 } as unknown as HTMLElement;
60
61 expect(shouldClearThreadSelectionOnMouseDown(unrelated)).toBe(true);
62 });
63});
64
3365describe("resolveThreadStatusPill", () => {
3466 const baseThread = {
3567 interactionMode: "plan" as const,
apps/web/src/components/Sidebar.logic.ts+8
......@@ -1,6 +1,9 @@
11import type { Thread } from "../types";
22import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
33
4export const THREAD_SELECTION_SAFE_SELECTOR =
5 "[data-thread-item], [data-thread-selection-safe]";
6
47export interface ThreadStatusPill {
58 label:
69 | "Working"
......@@ -30,6 +33,11 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
3033 return completedAt > lastVisitedAt;
3134}
3235
36export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
37 if (target === null) return true;
38 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
39}
40
3341export function resolveThreadStatusPill(input: {
3442 thread: ThreadStatusInput;
3543 hasPendingApprovals: boolean;
apps/web/src/components/Sidebar.tsx+234-80
......@@ -10,7 +10,7 @@ import {
1010 TerminalIcon,
1111 TriangleAlertIcon,
1212} from "lucide-react";
13import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
1414import {
1515 DndContext,
1616 type DragCancelEvent,
......@@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
4040import { useAppSettings } from "../appSettings";
4141import { isElectron } from "../env";
4242import { APP_STAGE_LABEL } from "../branding";
43import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
43import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils";
4444import { useStore } from "../store";
4545import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
4646import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
......@@ -80,9 +80,13 @@ import {
8080 SidebarSeparator,
8181 SidebarTrigger,
8282} from "./ui/sidebar";
83import { useThreadSelectionStore } from "../threadSelectionStore";
8384import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
8485import { isNonEmpty as isNonEmptyString } from "effect/String";
85import { resolveThreadStatusPill } from "./Sidebar.logic";
86import {
87 resolveThreadStatusPill,
88 shouldClearThreadSelectionOnMouseDown,
89} from "./Sidebar.logic";
8690
8791const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
8892const THREAD_PREVIEW_LIMIT = 6;
......@@ -301,6 +305,12 @@ export default function Sidebar() {
301305 const dragInProgressRef = useRef(false);
302306 const suppressProjectClickAfterDragRef = useRef(false);
303307 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
308 const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
309 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
310 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
311 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
312 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
313 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
304314 const shouldBrowseForProjectImmediately = isElectron;
305315 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
306316 const pendingApprovalByThreadId = useMemo(() => {
......@@ -628,64 +638,31 @@ export default function Sidebar() {
628638 [],
629639 );
630640
631 const handleThreadContextMenu = useCallback(
632 async (threadId: ThreadId, position: { x: number; y: number }) => {
641 /**
642 * Delete a single thread: stop session, close terminal, dispatch delete,
643 * clean up drafts/state, and optionally remove orphaned worktree.
644 * Callers handle thread-level confirmation; this still prompts for worktree removal.
645 */
646 const deleteThread = useCallback(
647 async (
648 threadId: ThreadId,
649 opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {},
650 ): Promise<void> => {
633651 const api = readNativeApi();
634652 if (!api) return;
635 const clicked = await api.contextMenu.show(
636 [
637 { id: "rename", label: "Rename thread" },
638 { id: "mark-unread", label: "Mark unread" },
639 { id: "copy-thread-id", label: "Copy Thread ID" },
640 { id: "delete", label: "Delete", destructive: true },
641 ],
642 position,
643 );
644653 const thread = threads.find((t) => t.id === threadId);
645654 if (!thread) return;
646655
647 if (clicked === "rename") {
648 setRenamingThreadId(threadId);
649 setRenamingTitle(thread.title);
650 renamingCommittedRef.current = false;
651 return;
652 }
653
654 if (clicked === "mark-unread") {
655 markThreadUnread(threadId);
656 return;
657 }
658 if (clicked === "copy-thread-id") {
659 try {
660 await copyTextToClipboard(threadId);
661 toastManager.add({
662 type: "success",
663 title: "Thread ID copied",
664 description: threadId,
665 });
666 } catch (error) {
667 toastManager.add({
668 type: "error",
669 title: "Failed to copy thread ID",
670 description: error instanceof Error ? error.message : "An error occurred.",
671 });
672 }
673 return;
674 }
675 if (clicked !== "delete") return;
676 if (appSettings.confirmThreadDelete) {
677 const confirmed = await api.dialogs.confirm(
678 [
679 `Delete thread "${thread.title}"?`,
680 "This permanently clears conversation history for this thread.",
681 ].join("\n"),
682 );
683 if (!confirmed) {
684 return;
685 }
686 }
687656 const threadProject = projects.find((project) => project.id === thread.projectId);
688 const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId);
657 // When bulk-deleting, exclude the other threads being deleted so
658 // getOrphanedWorktreePathForThread correctly detects that no surviving
659 // threads will reference this worktree.
660 const deletedIds = opts.deletedThreadIds;
661 const survivingThreads =
662 deletedIds && deletedIds.size > 0
663 ? threads.filter((t) => t.id === threadId || !deletedIds.has(t.id))
664 : threads;
665 const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId);
689666 const displayWorktreePath = orphanedWorktreePath
690667 ? formatWorktreePathForDisplay(orphanedWorktreePath)
691668 : null;
......@@ -713,16 +690,15 @@ export default function Sidebar() {
713690 }
714691
715692 try {
716 await api.terminal.close({
717 threadId,
718 deleteHistory: true,
719 });
693 await api.terminal.close({ threadId, deleteHistory: true });
720694 } catch {
721695 // Terminal may already be closed
722696 }
723697
698 const allDeletedIds = deletedIds ?? new Set<ThreadId>();
724699 const shouldNavigateToFallback = routeThreadId === threadId;
725 const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null;
700 const fallbackThreadId =
701 threads.find((entry) => entry.id !== threadId && !allDeletedIds.has(entry.id))?.id ?? null;
726702 await api.orchestration.dispatchCommand({
727703 type: "thread.delete",
728704 commandId: newCommandId(),
......@@ -769,11 +745,9 @@ export default function Sidebar() {
769745 }
770746 },
771747 [
772 appSettings.confirmThreadDelete,
773748 clearComposerDraftForThread,
774749 clearProjectDraftThreadById,
775750 clearTerminalState,
776 markThreadUnread,
777751 navigate,
778752 projects,
779753 removeWorktreeMutation,
......@@ -782,6 +756,150 @@ export default function Sidebar() {
782756 ],
783757 );
784758
759 const handleThreadContextMenu = useCallback(
760 async (threadId: ThreadId, position: { x: number; y: number }) => {
761 const api = readNativeApi();
762 if (!api) return;
763 const clicked = await api.contextMenu.show(
764 [
765 { id: "rename", label: "Rename thread" },
766 { id: "mark-unread", label: "Mark unread" },
767 { id: "copy-thread-id", label: "Copy Thread ID" },
768 { id: "delete", label: "Delete", destructive: true },
769 ],
770 position,
771 );
772 const thread = threads.find((t) => t.id === threadId);
773 if (!thread) return;
774
775 if (clicked === "rename") {
776 setRenamingThreadId(threadId);
777 setRenamingTitle(thread.title);
778 renamingCommittedRef.current = false;
779 return;
780 }
781
782 if (clicked === "mark-unread") {
783 markThreadUnread(threadId);
784 return;
785 }
786 if (clicked === "copy-thread-id") {
787 try {
788 await copyTextToClipboard(threadId);
789 toastManager.add({
790 type: "success",
791 title: "Thread ID copied",
792 description: threadId,
793 });
794 } catch (error) {
795 toastManager.add({
796 type: "error",
797 title: "Failed to copy thread ID",
798 description: error instanceof Error ? error.message : "An error occurred.",
799 });
800 }
801 return;
802 }
803 if (clicked !== "delete") return;
804 if (appSettings.confirmThreadDelete) {
805 const confirmed = await api.dialogs.confirm(
806 [
807 `Delete thread "${thread.title}"?`,
808 "This permanently clears conversation history for this thread.",
809 ].join("\n"),
810 );
811 if (!confirmed) {
812 return;
813 }
814 }
815 await deleteThread(threadId);
816 },
817 [appSettings.confirmThreadDelete, deleteThread, markThreadUnread, threads],
818 );
819
820 const handleMultiSelectContextMenu = useCallback(
821 async (position: { x: number; y: number }) => {
822 const api = readNativeApi();
823 if (!api) return;
824 const ids = [...selectedThreadIds];
825 if (ids.length === 0) return;
826 const count = ids.length;
827
828 const clicked = await api.contextMenu.show(
829 [
830 { id: "mark-unread", label: `Mark unread (${count})` },
831 { id: "delete", label: `Delete (${count})`, destructive: true },
832 ],
833 position,
834 );
835
836 if (clicked === "mark-unread") {
837 for (const id of ids) {
838 markThreadUnread(id);
839 }
840 clearSelection();
841 return;
842 }
843
844 if (clicked !== "delete") return;
845
846 if (appSettings.confirmThreadDelete) {
847 const confirmed = await api.dialogs.confirm(
848 [
849 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
850 "This permanently clears conversation history for these threads.",
851 ].join("\n"),
852 );
853 if (!confirmed) return;
854 }
855
856 const deletedIds = new Set<ThreadId>(ids);
857 for (const id of ids) {
858 await deleteThread(id, { deletedThreadIds: deletedIds });
859 }
860 removeFromSelection(ids);
861 },
862 [
863 appSettings.confirmThreadDelete,
864 clearSelection,
865 deleteThread,
866 markThreadUnread,
867 removeFromSelection,
868 selectedThreadIds,
869 ],
870 );
871
872 const handleThreadClick = useCallback(
873 (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => {
874 const isMac = isMacPlatform(navigator.platform);
875 const isModClick = isMac ? event.metaKey : event.ctrlKey;
876 const isShiftClick = event.shiftKey;
877
878 if (isModClick) {
879 event.preventDefault();
880 toggleThreadSelection(threadId);
881 return;
882 }
883
884 if (isShiftClick) {
885 event.preventDefault();
886 rangeSelectTo(threadId, orderedProjectThreadIds);
887 return;
888 }
889
890 // Plain click — clear selection, set anchor for future shift-clicks, and navigate
891 if (selectedThreadIds.size > 0) {
892 clearSelection();
893 }
894 setSelectionAnchor(threadId);
895 void navigate({
896 to: "/$threadId",
897 params: { threadId },
898 });
899 },
900 [clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection],
901 );
902
785903 const handleProjectContextMenu = useCallback(
786904 async (projectId: ProjectId, position: { x: number; y: number }) => {
787905 const api = readNativeApi();
......@@ -894,9 +1012,12 @@ export default function Sidebar() {
8941012 event.stopPropagation();
8951013 return;
8961014 }
1015 if (selectedThreadIds.size > 0) {
1016 clearSelection();
1017 }
8971018 toggleProject(projectId);
8981019 },
899 [toggleProject],
1020 [clearSelection, selectedThreadIds.size, toggleProject],
9001021 );
9011022
9021023 const handleProjectTitleKeyDown = useCallback(
......@@ -913,6 +1034,12 @@ export default function Sidebar() {
9131034
9141035 useEffect(() => {
9151036 const onWindowKeyDown = (event: KeyboardEvent) => {
1037 if (event.key === "Escape" && selectedThreadIds.size > 0) {
1038 event.preventDefault();
1039 clearSelection();
1040 return;
1041 }
1042
9161043 const activeThread = routeThreadId
9171044 ? threads.find((thread) => thread.id === routeThreadId)
9181045 : undefined;
......@@ -937,11 +1064,20 @@ export default function Sidebar() {
9371064 });
9381065 };
9391066
1067 const onMouseDown = (event: globalThis.MouseEvent) => {
1068 if (selectedThreadIds.size === 0) return;
1069 const target = event.target instanceof HTMLElement ? event.target : null;
1070 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
1071 clearSelection();
1072 };
1073
9401074 window.addEventListener("keydown", onWindowKeyDown);
1075 window.addEventListener("mousedown", onMouseDown);
9411076 return () => {
9421077 window.removeEventListener("keydown", onWindowKeyDown);
1078 window.removeEventListener("mousedown", onMouseDown);
9431079 };
944 }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]);
1080 }, [clearSelection, getDraftThread, handleNewThread, keybindings, projects, routeThreadId, selectedThreadIds.size, threads]);
9451081
9461082 useEffect(() => {
9471083 if (!isElectron) return;
......@@ -1277,6 +1413,7 @@ export default function Sidebar() {
12771413 hasHiddenThreads && !isThreadListExpanded
12781414 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
12791415 : projectThreads;
1416 const orderedProjectThreadIds = projectThreads.map((t) => t.id);
12801417
12811418 return (
12821419 <SortableProjectItem key={project.id} projectId={project.id}>
......@@ -1343,9 +1480,11 @@ export default function Sidebar() {
13431480 </div>
13441481
13451482 <CollapsibleContent keepMounted>
1346 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1483 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0">
13471484 {visibleThreads.map((thread) => {
13481485 const isActive = routeThreadId === thread.id;
1486 const isSelected = selectedThreadIds.has(thread.id);
1487 const isHighlighted = isActive || isSelected;
13491488 const threadStatus = resolveThreadStatusPill({
13501489 thread,
13511490 hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,
......@@ -1358,25 +1497,28 @@ export default function Sidebar() {
13581497 );
13591498
13601499 return (
1361 <SidebarMenuSubItem key={thread.id} className="w-full">
1500 <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
13621501 <SidebarMenuSubButton
13631502 render={<div role="button" tabIndex={0} />}
13641503 size="sm"
13651504 isActive={isActive}
1366 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${
1367 isActive
1368 ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50"
1369 : "text-muted-foreground"
1505 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 ${
1506 isSelected
1507 ? "bg-primary/15 text-foreground dark:bg-primary/10"
1508 : isActive
1509 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1510 : "text-muted-foreground"
13701511 }`}
1371 onClick={() => {
1372 void navigate({
1373 to: "/$threadId",
1374 params: { threadId: thread.id },
1375 });
1512 onClick={(event) => {
1513 handleThreadClick(event, thread.id, orderedProjectThreadIds);
13761514 }}
13771515 onKeyDown={(event) => {
13781516 if (event.key !== "Enter" && event.key !== " ") return;
13791517 event.preventDefault();
1518 if (selectedThreadIds.size > 0) {
1519 clearSelection();
1520 }
1521 setSelectionAnchor(thread.id);
13801522 void navigate({
13811523 to: "/$threadId",
13821524 params: { threadId: thread.id },
......@@ -1384,10 +1526,20 @@ export default function Sidebar() {
13841526 }}
13851527 onContextMenu={(event) => {
13861528 event.preventDefault();
1387 void handleThreadContextMenu(thread.id, {
1388 x: event.clientX,
1389 y: event.clientY,
1390 });
1529 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1530 void handleMultiSelectContextMenu({
1531 x: event.clientX,
1532 y: event.clientY,
1533 });
1534 } else {
1535 if (selectedThreadIds.size > 0) {
1536 clearSelection();
1537 }
1538 void handleThreadContextMenu(thread.id, {
1539 x: event.clientX,
1540 y: event.clientY,
1541 });
1542 }
13911543 }}
13921544 >
13931545 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
......@@ -1474,7 +1626,7 @@ export default function Sidebar() {
14741626 )}
14751627 <span
14761628 className={`text-[10px] ${
1477 isActive ? "text-foreground/65" : "text-muted-foreground/40"
1629 isHighlighted ? "text-foreground/65" : "text-muted-foreground/40"
14781630 }`}
14791631 >
14801632 {formatRelativeTime(thread.createdAt)}
......@@ -1489,6 +1641,7 @@ export default function Sidebar() {
14891641 <SidebarMenuSubItem className="w-full">
14901642 <SidebarMenuSubButton
14911643 render={<button type="button" />}
1644 data-thread-selection-safe
14921645 size="sm"
14931646 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"
14941647 onClick={() => {
......@@ -1503,6 +1656,7 @@ export default function Sidebar() {
15031656 <SidebarMenuSubItem className="w-full">
15041657 <SidebarMenuSubButton
15051658 render={<button type="button" />}
1659 data-thread-selection-safe
15061660 size="sm"
15071661 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"
15081662 onClick={() => {
apps/web/src/threadSelectionStore.test.ts created+292
......@@ -0,0 +1,292 @@
1import { ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";
3
4import { useThreadSelectionStore } from "./threadSelectionStore";
5
6const THREAD_A = ThreadId.makeUnsafe("thread-a");
7const THREAD_B = ThreadId.makeUnsafe("thread-b");
8const THREAD_C = ThreadId.makeUnsafe("thread-c");
9const THREAD_D = ThreadId.makeUnsafe("thread-d");
10const THREAD_E = ThreadId.makeUnsafe("thread-e");
11
12const ORDERED = [THREAD_A, THREAD_B, THREAD_C, THREAD_D, THREAD_E] as const;
13
14describe("threadSelectionStore", () => {
15 beforeEach(() => {
16 useThreadSelectionStore.getState().clearSelection();
17 });
18
19 describe("toggleThread", () => {
20 it("adds a thread to empty selection", () => {
21 useThreadSelectionStore.getState().toggleThread(THREAD_A);
22
23 const state = useThreadSelectionStore.getState();
24 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
25 expect(state.selectedThreadIds.size).toBe(1);
26 expect(state.anchorThreadId).toBe(THREAD_A);
27 });
28
29 it("removes a thread that is already selected", () => {
30 const store = useThreadSelectionStore.getState();
31 store.toggleThread(THREAD_A);
32 store.toggleThread(THREAD_A);
33
34 const state = useThreadSelectionStore.getState();
35 expect(state.selectedThreadIds.has(THREAD_A)).toBe(false);
36 expect(state.selectedThreadIds.size).toBe(0);
37 });
38
39 it("preserves existing selections when toggling a new thread", () => {
40 const store = useThreadSelectionStore.getState();
41 store.toggleThread(THREAD_A);
42 store.toggleThread(THREAD_B);
43
44 const state = useThreadSelectionStore.getState();
45 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
46 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
47 expect(state.selectedThreadIds.size).toBe(2);
48 });
49
50 it("sets anchor to the newly added thread", () => {
51 const store = useThreadSelectionStore.getState();
52 store.toggleThread(THREAD_A);
53 store.toggleThread(THREAD_B);
54
55 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);
56 });
57
58 it("preserves anchor when deselecting a non-anchor thread", () => {
59 const store = useThreadSelectionStore.getState();
60 store.toggleThread(THREAD_A);
61 store.toggleThread(THREAD_B);
62 store.toggleThread(THREAD_A); // deselect A, anchor should stay B
63
64 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);
65 });
66 });
67
68 describe("setAnchor", () => {
69 it("sets anchor without adding to selection", () => {
70 useThreadSelectionStore.getState().setAnchor(THREAD_B);
71
72 const state = useThreadSelectionStore.getState();
73 expect(state.anchorThreadId).toBe(THREAD_B);
74 expect(state.selectedThreadIds.size).toBe(0);
75 });
76
77 it("enables range select from a plain-click anchor", () => {
78 const store = useThreadSelectionStore.getState();
79 store.setAnchor(THREAD_B); // simulate plain-click navigate to B
80 store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D
81
82 const state = useThreadSelectionStore.getState();
83 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
84 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
85 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
86 expect(state.selectedThreadIds.size).toBe(3);
87 });
88
89 it("is a no-op when anchor is already set to the same thread", () => {
90 const store = useThreadSelectionStore.getState();
91 store.setAnchor(THREAD_B);
92 const stateBefore = useThreadSelectionStore.getState();
93 store.setAnchor(THREAD_B);
94 const stateAfter = useThreadSelectionStore.getState();
95
96 // Should be referentially the same (no unnecessary re-render)
97 expect(stateAfter).toBe(stateBefore);
98 });
99
100 it("survives clearSelection followed by setAnchor", () => {
101 const store = useThreadSelectionStore.getState();
102 store.toggleThread(THREAD_A);
103 store.toggleThread(THREAD_B);
104 store.clearSelection();
105 store.setAnchor(THREAD_C);
106
107 const state = useThreadSelectionStore.getState();
108 expect(state.anchorThreadId).toBe(THREAD_C);
109 expect(state.selectedThreadIds.size).toBe(0);
110 });
111 });
112
113 describe("rangeSelectTo", () => {
114 it("selects a single thread when no anchor exists", () => {
115 useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED);
116
117 const state = useThreadSelectionStore.getState();
118 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
119 expect(state.selectedThreadIds.size).toBe(1);
120 expect(state.anchorThreadId).toBe(THREAD_C);
121 });
122
123 it("selects range from anchor to target (forward)", () => {
124 const store = useThreadSelectionStore.getState();
125 store.toggleThread(THREAD_B); // sets anchor to B
126 store.rangeSelectTo(THREAD_D, ORDERED);
127
128 const state = useThreadSelectionStore.getState();
129 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
130 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
131 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
132 expect(state.selectedThreadIds.size).toBe(3);
133 });
134
135 it("selects range from anchor to target (backward)", () => {
136 const store = useThreadSelectionStore.getState();
137 store.toggleThread(THREAD_D); // sets anchor to D
138 store.rangeSelectTo(THREAD_B, ORDERED);
139
140 const state = useThreadSelectionStore.getState();
141 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
142 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
143 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
144 expect(state.selectedThreadIds.size).toBe(3);
145 });
146
147 it("keeps anchor stable across multiple range selects", () => {
148 const store = useThreadSelectionStore.getState();
149 store.toggleThread(THREAD_B); // anchor = B
150 store.rangeSelectTo(THREAD_D, ORDERED); // selects B-D
151 store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B)
152
153 const state = useThreadSelectionStore.getState();
154 expect(state.anchorThreadId).toBe(THREAD_B);
155 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
156 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
157 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
158 expect(state.selectedThreadIds.has(THREAD_E)).toBe(true);
159 });
160
161 it("falls back to toggle when anchor is not in the ordered list", () => {
162 const store = useThreadSelectionStore.getState();
163 store.toggleThread(THREAD_A); // anchor = A
164 // Range-select with a list that does NOT contain the anchor
165 store.rangeSelectTo(THREAD_C, [THREAD_B, THREAD_C, THREAD_D]);
166
167 const state = useThreadSelectionStore.getState();
168 // Should have added C and reset anchor to C
169 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
170 expect(state.anchorThreadId).toBe(THREAD_C);
171 });
172
173 it("falls back to toggle when target is not in the ordered list", () => {
174 const store = useThreadSelectionStore.getState();
175 store.toggleThread(THREAD_B); // anchor = B
176 const unknownThread = ThreadId.makeUnsafe("thread-unknown");
177 store.rangeSelectTo(unknownThread, ORDERED);
178
179 const state = useThreadSelectionStore.getState();
180 expect(state.selectedThreadIds.has(unknownThread)).toBe(true);
181 expect(state.anchorThreadId).toBe(unknownThread);
182 });
183
184 it("selects the single thread when anchor equals target", () => {
185 const store = useThreadSelectionStore.getState();
186 store.toggleThread(THREAD_C); // anchor = C
187 store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C
188
189 const state = useThreadSelectionStore.getState();
190 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
191 expect(state.selectedThreadIds.size).toBe(1);
192 });
193
194 it("preserves previously selected threads outside the range", () => {
195 const store = useThreadSelectionStore.getState();
196 store.toggleThread(THREAD_A); // select A, anchor = A
197 store.toggleThread(THREAD_B); // select B, anchor = B
198
199 // Now shift-select from B (anchor) to D — should add B, C, D but keep A
200 store.rangeSelectTo(THREAD_D, ORDERED);
201
202 const state = useThreadSelectionStore.getState();
203 expect(state.selectedThreadIds.has(THREAD_A)).toBe(true);
204 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
205 expect(state.selectedThreadIds.has(THREAD_C)).toBe(true);
206 expect(state.selectedThreadIds.has(THREAD_D)).toBe(true);
207 expect(state.selectedThreadIds.size).toBe(4);
208 });
209 });
210
211 describe("clearSelection", () => {
212 it("clears all selected threads and anchor", () => {
213 const store = useThreadSelectionStore.getState();
214 store.toggleThread(THREAD_A);
215 store.toggleThread(THREAD_B);
216 store.clearSelection();
217
218 const state = useThreadSelectionStore.getState();
219 expect(state.selectedThreadIds.size).toBe(0);
220 expect(state.anchorThreadId).toBeNull();
221 });
222
223 it("is a no-op when already empty", () => {
224 const stateBefore = useThreadSelectionStore.getState();
225 stateBefore.clearSelection();
226 const stateAfter = useThreadSelectionStore.getState();
227
228 // Should be referentially the same (no unnecessary re-render)
229 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);
230 });
231 });
232
233 describe("removeFromSelection", () => {
234 it("removes specified threads from selection", () => {
235 const store = useThreadSelectionStore.getState();
236 store.toggleThread(THREAD_A);
237 store.toggleThread(THREAD_B);
238 store.toggleThread(THREAD_C);
239 store.removeFromSelection([THREAD_A, THREAD_C]);
240
241 const state = useThreadSelectionStore.getState();
242 expect(state.selectedThreadIds.has(THREAD_B)).toBe(true);
243 expect(state.selectedThreadIds.size).toBe(1);
244 });
245
246 it("clears anchor when the anchor thread is removed", () => {
247 const store = useThreadSelectionStore.getState();
248 store.toggleThread(THREAD_A);
249 store.toggleThread(THREAD_B); // anchor = B
250 store.removeFromSelection([THREAD_B]);
251
252 expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull();
253 });
254
255 it("preserves anchor when the anchor thread is not removed", () => {
256 const store = useThreadSelectionStore.getState();
257 store.toggleThread(THREAD_A);
258 store.toggleThread(THREAD_B); // anchor = B
259 store.removeFromSelection([THREAD_A]);
260
261 expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B);
262 });
263
264 it("is a no-op when none of the specified threads are selected", () => {
265 const store = useThreadSelectionStore.getState();
266 store.toggleThread(THREAD_A);
267 const stateBefore = useThreadSelectionStore.getState();
268 store.removeFromSelection([THREAD_B, THREAD_C]);
269 const stateAfter = useThreadSelectionStore.getState();
270
271 expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds);
272 });
273 });
274
275 describe("hasSelection", () => {
276 it("returns false when nothing is selected", () => {
277 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
278 });
279
280 it("returns true when threads are selected", () => {
281 useThreadSelectionStore.getState().toggleThread(THREAD_A);
282 expect(useThreadSelectionStore.getState().hasSelection()).toBe(true);
283 });
284
285 it("returns false after clearing selection", () => {
286 const store = useThreadSelectionStore.getState();
287 store.toggleThread(THREAD_A);
288 store.clearSelection();
289 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
290 });
291 });
292});
apps/web/src/threadSelectionStore.ts created+124
......@@ -0,0 +1,124 @@
1/**
2 * Zustand store for sidebar thread multi-selection state.
3 *
4 * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select),
5 * and bulk actions on the selected set.
6 */
7
8import type { ThreadId } from "@t3tools/contracts";
9import { create } from "zustand";
10
11export interface ThreadSelectionState {
12 /** Currently selected thread IDs. */
13 selectedThreadIds: ReadonlySet<ThreadId>;
14 /** The thread ID that anchors shift-click range selection. */
15 anchorThreadId: ThreadId | null;
16}
17
18interface ThreadSelectionStore extends ThreadSelectionState {
19 /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */
20 toggleThread: (threadId: ThreadId) => void;
21 /**
22 * Select a range of threads (Shift+Click).
23 * Requires the ordered list of thread IDs within the same project
24 * so the store can compute which threads fall between anchor and target.
25 */
26 rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void;
27 /** Clear all selection state. */
28 clearSelection: () => void;
29 /** Remove specific thread IDs from the selection (e.g. after deletion). */
30 removeFromSelection: (threadIds: readonly ThreadId[]) => void;
31 /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */
32 setAnchor: (threadId: ThreadId) => void;
33 /** Check if any threads are selected. */
34 hasSelection: () => boolean;
35}
36
37const EMPTY_SET = new Set<ThreadId>();
38
39export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
40 selectedThreadIds: EMPTY_SET,
41 anchorThreadId: null,
42
43 toggleThread: (threadId) => {
44 set((state) => {
45 const next = new Set(state.selectedThreadIds);
46 if (next.has(threadId)) {
47 next.delete(threadId);
48 } else {
49 next.add(threadId);
50 }
51 return {
52 selectedThreadIds: next,
53 anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId,
54 };
55 });
56 },
57
58 rangeSelectTo: (threadId, orderedThreadIds) => {
59 set((state) => {
60 const anchor = state.anchorThreadId;
61 if (anchor === null) {
62 // No anchor yet — treat as a single toggle
63 const next = new Set(state.selectedThreadIds);
64 next.add(threadId);
65 return { selectedThreadIds: next, anchorThreadId: threadId };
66 }
67
68 const anchorIndex = orderedThreadIds.indexOf(anchor);
69 const targetIndex = orderedThreadIds.indexOf(threadId);
70 if (anchorIndex === -1 || targetIndex === -1) {
71 // Anchor or target not in this list (different project?) — fallback to toggle
72 const next = new Set(state.selectedThreadIds);
73 next.add(threadId);
74 return { selectedThreadIds: next, anchorThreadId: threadId };
75 }
76
77 const start = Math.min(anchorIndex, targetIndex);
78 const end = Math.max(anchorIndex, targetIndex);
79 const next = new Set(state.selectedThreadIds);
80 for (let i = start; i <= end; i++) {
81 const id = orderedThreadIds[i];
82 if (id !== undefined) {
83 next.add(id);
84 }
85 }
86 // Keep anchor stable so subsequent shift-clicks extend from the same point
87 return { selectedThreadIds: next, anchorThreadId: anchor };
88 });
89 },
90
91 clearSelection: () => {
92 const state = get();
93 if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return;
94 set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null });
95 },
96
97 setAnchor: (threadId) => {
98 if (get().anchorThreadId === threadId) return;
99 set({ anchorThreadId: threadId });
100 },
101
102 removeFromSelection: (threadIds) => {
103 set((state) => {
104 const toRemove = new Set(threadIds);
105 let changed = false;
106 const next = new Set<ThreadId>();
107 for (const id of state.selectedThreadIds) {
108 if (toRemove.has(id)) {
109 changed = true;
110 } else {
111 next.add(id);
112 }
113 }
114 if (!changed) return state;
115 const newAnchor =
116 state.anchorThreadId !== null && toRemove.has(state.anchorThreadId)
117 ? null
118 : state.anchorThreadId;
119 return { selectedThreadIds: next, anchorThreadId: newAnchor };
120 });
121 },
122
123 hasSelection: () => get().selectedThreadIds.size > 0,
124}));