| author | |
| committer | |
| log | afb6e89bfdd9164c142162597bb865540b1909d2 |
| tree | 8545ce1a68d37391dd5b48828c3b546e21aca27c |
| parent | 9309edf4f42c59de6489f0ea1a6b72578d637b9d |
| signature | Signed by PGP key B5690EEEBB952194 |
* 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 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { hasUnseenCompletion, resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 3 | import { | |
| 4 | hasUnseenCompletion, | |
| 5 | resolveThreadStatusPill, | |
| 6 | shouldClearThreadSelectionOnMouseDown, | |
| 7 | } from "./Sidebar.logic"; | |
| 4 | 8 | |
| 5 | 9 | function makeLatestTurn(overrides?: { |
| 6 | 10 | completedAt?: string | null; |
| ... | ... | @@ -30,6 +34,34 @@ describe("hasUnseenCompletion", () => { |
| 30 | 34 | }); |
| 31 | 35 | }); |
| 32 | 36 | |
| 37 | describe("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 | ||
| 33 | 65 | describe("resolveThreadStatusPill", () => { |
| 34 | 66 | const baseThread = { |
| 35 | 67 | interactionMode: "plan" as const, |
apps/web/src/components/Sidebar.logic.ts+8| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import type { Thread } from "../types"; |
| 2 | 2 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; |
| 3 | 3 | |
| 4 | export const THREAD_SELECTION_SAFE_SELECTOR = | |
| 5 | "[data-thread-item], [data-thread-selection-safe]"; | |
| 6 | ||
| 4 | 7 | export interface ThreadStatusPill { |
| 5 | 8 | label: |
| 6 | 9 | | "Working" |
| ... | ... | @@ -30,6 +33,11 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 30 | 33 | return completedAt > lastVisitedAt; |
| 31 | 34 | } |
| 32 | 35 | |
| 36 | export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { | |
| 37 | if (target === null) return true; | |
| 38 | return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); | |
| 39 | } | |
| 40 | ||
| 33 | 41 | export function resolveThreadStatusPill(input: { |
| 34 | 42 | thread: ThreadStatusInput; |
| 35 | 43 | hasPendingApprovals: boolean; |
apps/web/src/components/Sidebar.tsx+234-80| ... | ... | @@ -10,7 +10,7 @@ import { |
| 10 | 10 | TerminalIcon, |
| 11 | 11 | TriangleAlertIcon, |
| 12 | 12 | } from "lucide-react"; |
| 13 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 13 | import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; | |
| 14 | 14 | import { |
| 15 | 15 | DndContext, |
| 16 | 16 | type DragCancelEvent, |
| ... | ... | @@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 40 | 40 | import { useAppSettings } from "../appSettings"; |
| 41 | 41 | import { isElectron } from "../env"; |
| 42 | 42 | import { APP_STAGE_LABEL } from "../branding"; |
| 43 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | |
| 43 | import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; | |
| 44 | 44 | import { useStore } from "../store"; |
| 45 | 45 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 46 | 46 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| ... | ... | @@ -80,9 +80,13 @@ import { |
| 80 | 80 | SidebarSeparator, |
| 81 | 81 | SidebarTrigger, |
| 82 | 82 | } from "./ui/sidebar"; |
| 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; | |
| 83 | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 84 | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 85 | import { resolveThreadStatusPill } from "./Sidebar.logic"; | |
| 86 | import { | |
| 87 | resolveThreadStatusPill, | |
| 88 | shouldClearThreadSelectionOnMouseDown, | |
| 89 | } from "./Sidebar.logic"; | |
| 86 | 90 | |
| 87 | 91 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 88 | 92 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | ... | @@ -301,6 +305,12 @@ export default function Sidebar() { |
| 301 | 305 | const dragInProgressRef = useRef(false); |
| 302 | 306 | const suppressProjectClickAfterDragRef = useRef(false); |
| 303 | 307 | 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); | |
| 304 | 314 | const shouldBrowseForProjectImmediately = isElectron; |
| 305 | 315 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 306 | 316 | const pendingApprovalByThreadId = useMemo(() => { |
| ... | ... | @@ -628,64 +638,31 @@ export default function Sidebar() { |
| 628 | 638 | [], |
| 629 | 639 | ); |
| 630 | 640 | |
| 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> => { | |
| 633 | 651 | const api = readNativeApi(); |
| 634 | 652 | 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 | ); | |
| 644 | 653 | const thread = threads.find((t) => t.id === threadId); |
| 645 | 654 | if (!thread) return; |
| 646 | 655 | |
| 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 | } | |
| 687 | 656 | 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); | |
| 689 | 666 | const displayWorktreePath = orphanedWorktreePath |
| 690 | 667 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 691 | 668 | : null; |
| ... | ... | @@ -713,16 +690,15 @@ export default function Sidebar() { |
| 713 | 690 | } |
| 714 | 691 | |
| 715 | 692 | try { |
| 716 | await api.terminal.close({ | |
| 717 | threadId, | |
| 718 | deleteHistory: true, | |
| 719 | }); | |
| 693 | await api.terminal.close({ threadId, deleteHistory: true }); | |
| 720 | 694 | } catch { |
| 721 | 695 | // Terminal may already be closed |
| 722 | 696 | } |
| 723 | 697 | |
| 698 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); | |
| 724 | 699 | 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; | |
| 726 | 702 | await api.orchestration.dispatchCommand({ |
| 727 | 703 | type: "thread.delete", |
| 728 | 704 | commandId: newCommandId(), |
| ... | ... | @@ -769,11 +745,9 @@ export default function Sidebar() { |
| 769 | 745 | } |
| 770 | 746 | }, |
| 771 | 747 | [ |
| 772 | appSettings.confirmThreadDelete, | |
| 773 | 748 | clearComposerDraftForThread, |
| 774 | 749 | clearProjectDraftThreadById, |
| 775 | 750 | clearTerminalState, |
| 776 | markThreadUnread, | |
| 777 | 751 | navigate, |
| 778 | 752 | projects, |
| 779 | 753 | removeWorktreeMutation, |
| ... | ... | @@ -782,6 +756,150 @@ export default function Sidebar() { |
| 782 | 756 | ], |
| 783 | 757 | ); |
| 784 | 758 | |
| 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 | ||
| 785 | 903 | const handleProjectContextMenu = useCallback( |
| 786 | 904 | async (projectId: ProjectId, position: { x: number; y: number }) => { |
| 787 | 905 | const api = readNativeApi(); |
| ... | ... | @@ -894,9 +1012,12 @@ export default function Sidebar() { |
| 894 | 1012 | event.stopPropagation(); |
| 895 | 1013 | return; |
| 896 | 1014 | } |
| 1015 | if (selectedThreadIds.size > 0) { | |
| 1016 | clearSelection(); | |
| 1017 | } | |
| 897 | 1018 | toggleProject(projectId); |
| 898 | 1019 | }, |
| 899 | [toggleProject], | |
| 1020 | [clearSelection, selectedThreadIds.size, toggleProject], | |
| 900 | 1021 | ); |
| 901 | 1022 | |
| 902 | 1023 | const handleProjectTitleKeyDown = useCallback( |
| ... | ... | @@ -913,6 +1034,12 @@ export default function Sidebar() { |
| 913 | 1034 | |
| 914 | 1035 | useEffect(() => { |
| 915 | 1036 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 1037 | if (event.key === "Escape" && selectedThreadIds.size > 0) { | |
| 1038 | event.preventDefault(); | |
| 1039 | clearSelection(); | |
| 1040 | return; | |
| 1041 | } | |
| 1042 | ||
| 916 | 1043 | const activeThread = routeThreadId |
| 917 | 1044 | ? threads.find((thread) => thread.id === routeThreadId) |
| 918 | 1045 | : undefined; |
| ... | ... | @@ -937,11 +1064,20 @@ export default function Sidebar() { |
| 937 | 1064 | }); |
| 938 | 1065 | }; |
| 939 | 1066 | |
| 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 | ||
| 940 | 1074 | window.addEventListener("keydown", onWindowKeyDown); |
| 1075 | window.addEventListener("mousedown", onMouseDown); | |
| 941 | 1076 | return () => { |
| 942 | 1077 | window.removeEventListener("keydown", onWindowKeyDown); |
| 1078 | window.removeEventListener("mousedown", onMouseDown); | |
| 943 | 1079 | }; |
| 944 | }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); | |
| 1080 | }, [clearSelection, getDraftThread, handleNewThread, keybindings, projects, routeThreadId, selectedThreadIds.size, threads]); | |
| 945 | 1081 | |
| 946 | 1082 | useEffect(() => { |
| 947 | 1083 | if (!isElectron) return; |
| ... | ... | @@ -1277,6 +1413,7 @@ export default function Sidebar() { |
| 1277 | 1413 | hasHiddenThreads && !isThreadListExpanded |
| 1278 | 1414 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1279 | 1415 | : projectThreads; |
| 1416 | const orderedProjectThreadIds = projectThreads.map((t) => t.id); | |
| 1280 | 1417 | |
| 1281 | 1418 | return ( |
| 1282 | 1419 | <SortableProjectItem key={project.id} projectId={project.id}> |
| ... | ... | @@ -1343,9 +1480,11 @@ export default function Sidebar() { |
| 1343 | 1480 | </div> |
| 1344 | 1481 | |
| 1345 | 1482 | <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"> | |
| 1347 | 1484 | {visibleThreads.map((thread) => { |
| 1348 | 1485 | const isActive = routeThreadId === thread.id; |
| 1486 | const isSelected = selectedThreadIds.has(thread.id); | |
| 1487 | const isHighlighted = isActive || isSelected; | |
| 1349 | 1488 | const threadStatus = resolveThreadStatusPill({ |
| 1350 | 1489 | thread, |
| 1351 | 1490 | hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, |
| ... | ... | @@ -1358,25 +1497,28 @@ export default function Sidebar() { |
| 1358 | 1497 | ); |
| 1359 | 1498 | |
| 1360 | 1499 | return ( |
| 1361 | <SidebarMenuSubItem key={thread.id} className="w-full"> | |
| 1500 | <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item> | |
| 1362 | 1501 | <SidebarMenuSubButton |
| 1363 | 1502 | render={<div role="button" tabIndex={0} />} |
| 1364 | 1503 | size="sm" |
| 1365 | 1504 | 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" | |
| 1370 | 1511 | }`} |
| 1371 | onClick={() => { | |
| 1372 | void navigate({ | |
| 1373 | to: "/$threadId", | |
| 1374 | params: { threadId: thread.id }, | |
| 1375 | }); | |
| 1512 | onClick={(event) => { | |
| 1513 | handleThreadClick(event, thread.id, orderedProjectThreadIds); | |
| 1376 | 1514 | }} |
| 1377 | 1515 | onKeyDown={(event) => { |
| 1378 | 1516 | if (event.key !== "Enter" && event.key !== " ") return; |
| 1379 | 1517 | event.preventDefault(); |
| 1518 | if (selectedThreadIds.size > 0) { | |
| 1519 | clearSelection(); | |
| 1520 | } | |
| 1521 | setSelectionAnchor(thread.id); | |
| 1380 | 1522 | void navigate({ |
| 1381 | 1523 | to: "/$threadId", |
| 1382 | 1524 | params: { threadId: thread.id }, |
| ... | ... | @@ -1384,10 +1526,20 @@ export default function Sidebar() { |
| 1384 | 1526 | }} |
| 1385 | 1527 | onContextMenu={(event) => { |
| 1386 | 1528 | 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 | } | |
| 1391 | 1543 | }} |
| 1392 | 1544 | > |
| 1393 | 1545 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> |
| ... | ... | @@ -1474,7 +1626,7 @@ export default function Sidebar() { |
| 1474 | 1626 | )} |
| 1475 | 1627 | <span |
| 1476 | 1628 | className={`text-[10px] ${ |
| 1477 | isActive ? "text-foreground/65" : "text-muted-foreground/40" | |
| 1629 | isHighlighted ? "text-foreground/65" : "text-muted-foreground/40" | |
| 1478 | 1630 | }`} |
| 1479 | 1631 | > |
| 1480 | 1632 | {formatRelativeTime(thread.createdAt)} |
| ... | ... | @@ -1489,6 +1641,7 @@ export default function Sidebar() { |
| 1489 | 1641 | <SidebarMenuSubItem className="w-full"> |
| 1490 | 1642 | <SidebarMenuSubButton |
| 1491 | 1643 | render={<button type="button" />} |
| 1644 | data-thread-selection-safe | |
| 1492 | 1645 | size="sm" |
| 1493 | 1646 | 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" |
| 1494 | 1647 | onClick={() => { |
| ... | ... | @@ -1503,6 +1656,7 @@ export default function Sidebar() { |
| 1503 | 1656 | <SidebarMenuSubItem className="w-full"> |
| 1504 | 1657 | <SidebarMenuSubButton |
| 1505 | 1658 | render={<button type="button" />} |
| 1659 | data-thread-selection-safe | |
| 1506 | 1660 | size="sm" |
| 1507 | 1661 | 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" |
| 1508 | 1662 | onClick={() => { |
apps/web/src/threadSelectionStore.test.ts created+292| ... | ... | @@ -0,0 +1,292 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { useThreadSelectionStore } from "./threadSelectionStore"; | |
| 5 | ||
| 6 | const THREAD_A = ThreadId.makeUnsafe("thread-a"); | |
| 7 | const THREAD_B = ThreadId.makeUnsafe("thread-b"); | |
| 8 | const THREAD_C = ThreadId.makeUnsafe("thread-c"); | |
| 9 | const THREAD_D = ThreadId.makeUnsafe("thread-d"); | |
| 10 | const THREAD_E = ThreadId.makeUnsafe("thread-e"); | |
| 11 | ||
| 12 | const ORDERED = [THREAD_A, THREAD_B, THREAD_C, THREAD_D, THREAD_E] as const; | |
| 13 | ||
| 14 | describe("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 | ||
| 8 | import type { ThreadId } from "@t3tools/contracts"; | |
| 9 | import { create } from "zustand"; | |
| 10 | ||
| 11 | export 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 | ||
| 18 | interface 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 | ||
| 37 | const EMPTY_SET = new Set<ThreadId>(); | |
| 38 | ||
| 39 | export 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 | })); |