| ... | @@ -10,7 +10,7 @@ import { | ... | @@ -10,7 +10,7 @@ import { |
| 10 | TerminalIcon, | 10 | TerminalIcon, |
| 11 | TriangleAlertIcon, | 11 | TriangleAlertIcon, |
| 12 | } from "lucide-react"; | 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 | import { | 14 | import { |
| 15 | DndContext, | 15 | DndContext, |
| 16 | type DragCancelEvent, | 16 | type DragCancelEvent, |
| ... | @@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | ... | @@ -40,7 +40,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 40 | import { useAppSettings } from "../appSettings"; | 40 | import { useAppSettings } from "../appSettings"; |
| 41 | import { isElectron } from "../env"; | 41 | import { isElectron } from "../env"; |
| 42 | import { APP_STAGE_LABEL } from "../branding"; | 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 | import { useStore } from "../store"; | 44 | import { useStore } from "../store"; |
| 45 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; | 45 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| 46 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | 46 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| ... | @@ -80,9 +80,13 @@ import { | ... | @@ -80,9 +80,13 @@ import { |
| 80 | SidebarSeparator, | 80 | SidebarSeparator, |
| 81 | SidebarTrigger, | 81 | SidebarTrigger, |
| 82 | } from "./ui/sidebar"; | 82 | } from "./ui/sidebar"; |
| | 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 83 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 84 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 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 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 91 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 88 | const THREAD_PREVIEW_LIMIT = 6; | 92 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | @@ -301,6 +305,12 @@ export default function Sidebar() { | ... | @@ -301,6 +305,12 @@ export default function Sidebar() { |
| 301 | const dragInProgressRef = useRef(false); | 305 | const dragInProgressRef = useRef(false); |
| 302 | const suppressProjectClickAfterDragRef = useRef(false); | 306 | const suppressProjectClickAfterDragRef = useRef(false); |
| 303 | const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); | 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 | const shouldBrowseForProjectImmediately = isElectron; | 314 | const shouldBrowseForProjectImmediately = isElectron; |
| 305 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | 315 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 306 | const pendingApprovalByThreadId = useMemo(() => { | 316 | const pendingApprovalByThreadId = useMemo(() => { |
| ... | @@ -628,64 +638,31 @@ export default function Sidebar() { | ... | @@ -628,64 +638,31 @@ export default function Sidebar() { |
| 628 | [], | 638 | [], |
| 629 | ); | 639 | ); |
| 630 | | 640 | |
| 631 | const handleThreadContextMenu = useCallback( | 641 | /** |
| 632 | async (threadId: ThreadId, position: { x: number; y: number }) => { | 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 | const api = readNativeApi(); | 651 | const api = readNativeApi(); |
| 634 | if (!api) return; | 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 | const thread = threads.find((t) => t.id === threadId); | 653 | const thread = threads.find((t) => t.id === threadId); |
| 645 | if (!thread) return; | 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 | const threadProject = projects.find((project) => project.id === thread.projectId); | 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 | const displayWorktreePath = orphanedWorktreePath | 666 | const displayWorktreePath = orphanedWorktreePath |
| 690 | ? formatWorktreePathForDisplay(orphanedWorktreePath) | 667 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 691 | : null; | 668 | : null; |
| ... | @@ -713,16 +690,15 @@ export default function Sidebar() { | ... | @@ -713,16 +690,15 @@ export default function Sidebar() { |
| 713 | } | 690 | } |
| 714 | | 691 | |
| 715 | try { | 692 | try { |
| 716 | await api.terminal.close({ | 693 | await api.terminal.close({ threadId, deleteHistory: true }); |
| 717 | threadId, | | |
| 718 | deleteHistory: true, | | |
| 719 | }); | | |
| 720 | } catch { | 694 | } catch { |
| 721 | // Terminal may already be closed | 695 | // Terminal may already be closed |
| 722 | } | 696 | } |
| 723 | | 697 | |
| | 698 | const allDeletedIds = deletedIds ?? new Set<ThreadId>(); |
| 724 | const shouldNavigateToFallback = routeThreadId === threadId; | 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 | await api.orchestration.dispatchCommand({ | 702 | await api.orchestration.dispatchCommand({ |
| 727 | type: "thread.delete", | 703 | type: "thread.delete", |
| 728 | commandId: newCommandId(), | 704 | commandId: newCommandId(), |
| ... | @@ -769,11 +745,9 @@ export default function Sidebar() { | ... | @@ -769,11 +745,9 @@ export default function Sidebar() { |
| 769 | } | 745 | } |
| 770 | }, | 746 | }, |
| 771 | [ | 747 | [ |
| 772 | appSettings.confirmThreadDelete, | | |
| 773 | clearComposerDraftForThread, | 748 | clearComposerDraftForThread, |
| 774 | clearProjectDraftThreadById, | 749 | clearProjectDraftThreadById, |
| 775 | clearTerminalState, | 750 | clearTerminalState, |
| 776 | markThreadUnread, | | |
| 777 | navigate, | 751 | navigate, |
| 778 | projects, | 752 | projects, |
| 779 | removeWorktreeMutation, | 753 | removeWorktreeMutation, |
| ... | @@ -782,6 +756,150 @@ export default function Sidebar() { | ... | @@ -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 | const handleProjectContextMenu = useCallback( | 903 | const handleProjectContextMenu = useCallback( |
| 786 | async (projectId: ProjectId, position: { x: number; y: number }) => { | 904 | async (projectId: ProjectId, position: { x: number; y: number }) => { |
| 787 | const api = readNativeApi(); | 905 | const api = readNativeApi(); |
| ... | @@ -894,9 +1012,12 @@ export default function Sidebar() { | ... | @@ -894,9 +1012,12 @@ export default function Sidebar() { |
| 894 | event.stopPropagation(); | 1012 | event.stopPropagation(); |
| 895 | return; | 1013 | return; |
| 896 | } | 1014 | } |
| | 1015 | if (selectedThreadIds.size > 0) { |
| | 1016 | clearSelection(); |
| | 1017 | } |
| 897 | toggleProject(projectId); | 1018 | toggleProject(projectId); |
| 898 | }, | 1019 | }, |
| 899 | [toggleProject], | 1020 | [clearSelection, selectedThreadIds.size, toggleProject], |
| 900 | ); | 1021 | ); |
| 901 | | 1022 | |
| 902 | const handleProjectTitleKeyDown = useCallback( | 1023 | const handleProjectTitleKeyDown = useCallback( |
| ... | @@ -913,6 +1034,12 @@ export default function Sidebar() { | ... | @@ -913,6 +1034,12 @@ export default function Sidebar() { |
| 913 | | 1034 | |
| 914 | useEffect(() => { | 1035 | useEffect(() => { |
| 915 | const onWindowKeyDown = (event: KeyboardEvent) => { | 1036 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| | 1037 | if (event.key === "Escape" && selectedThreadIds.size > 0) { |
| | 1038 | event.preventDefault(); |
| | 1039 | clearSelection(); |
| | 1040 | return; |
| | 1041 | } |
| | 1042 | |
| 916 | const activeThread = routeThreadId | 1043 | const activeThread = routeThreadId |
| 917 | ? threads.find((thread) => thread.id === routeThreadId) | 1044 | ? threads.find((thread) => thread.id === routeThreadId) |
| 918 | : undefined; | 1045 | : undefined; |
| ... | @@ -937,11 +1064,20 @@ export default function Sidebar() { | ... | @@ -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 | window.addEventListener("keydown", onWindowKeyDown); | 1074 | window.addEventListener("keydown", onWindowKeyDown); |
| | 1075 | window.addEventListener("mousedown", onMouseDown); |
| 941 | return () => { | 1076 | return () => { |
| 942 | window.removeEventListener("keydown", onWindowKeyDown); | 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 | useEffect(() => { | 1082 | useEffect(() => { |
| 947 | if (!isElectron) return; | 1083 | if (!isElectron) return; |
| ... | @@ -1277,6 +1413,7 @@ export default function Sidebar() { | ... | @@ -1277,6 +1413,7 @@ export default function Sidebar() { |
| 1277 | hasHiddenThreads && !isThreadListExpanded | 1413 | hasHiddenThreads && !isThreadListExpanded |
| 1278 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) | 1414 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1279 | : projectThreads; | 1415 | : projectThreads; |
| | 1416 | const orderedProjectThreadIds = projectThreads.map((t) => t.id); |
| 1280 | | 1417 | |
| 1281 | return ( | 1418 | return ( |
| 1282 | <SortableProjectItem key={project.id} projectId={project.id}> | 1419 | <SortableProjectItem key={project.id} projectId={project.id}> |
| ... | @@ -1343,9 +1480,11 @@ export default function Sidebar() { | ... | @@ -1343,9 +1480,11 @@ export default function Sidebar() { |
| 1343 | </div> | 1480 | </div> |
| 1344 | | 1481 | |
| 1345 | <CollapsibleContent keepMounted> | 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 | {visibleThreads.map((thread) => { | 1484 | {visibleThreads.map((thread) => { |
| 1348 | const isActive = routeThreadId === thread.id; | 1485 | const isActive = routeThreadId === thread.id; |
| | 1486 | const isSelected = selectedThreadIds.has(thread.id); |
| | 1487 | const isHighlighted = isActive || isSelected; |
| 1349 | const threadStatus = resolveThreadStatusPill({ | 1488 | const threadStatus = resolveThreadStatusPill({ |
| 1350 | thread, | 1489 | thread, |
| 1351 | hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, | 1490 | hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, |
| ... | @@ -1358,25 +1497,28 @@ export default function Sidebar() { | ... | @@ -1358,25 +1497,28 @@ export default function Sidebar() { |
| 1358 | ); | 1497 | ); |
| 1359 | | 1498 | |
| 1360 | return ( | 1499 | return ( |
| 1361 | <SidebarMenuSubItem key={thread.id} className="w-full"> | 1500 | <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item> |
| 1362 | <SidebarMenuSubButton | 1501 | <SidebarMenuSubButton |
| 1363 | render={<div role="button" tabIndex={0} />} | 1502 | render={<div role="button" tabIndex={0} />} |
| 1364 | size="sm" | 1503 | size="sm" |
| 1365 | isActive={isActive} | 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 ${ | 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 ${ |
| 1367 | isActive | 1506 | isSelected |
| 1368 | ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" | 1507 | ? "bg-primary/15 text-foreground dark:bg-primary/10" |
| 1369 | : "text-muted-foreground" | 1508 | : isActive |
| | 1509 | ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" |
| | 1510 | : "text-muted-foreground" |
| 1370 | }`} | 1511 | }`} |
| 1371 | onClick={() => { | 1512 | onClick={(event) => { |
| 1372 | void navigate({ | 1513 | handleThreadClick(event, thread.id, orderedProjectThreadIds); |
| 1373 | to: "/$threadId", | | |
| 1374 | params: { threadId: thread.id }, | | |
| 1375 | }); | | |
| 1376 | }} | 1514 | }} |
| 1377 | onKeyDown={(event) => { | 1515 | onKeyDown={(event) => { |
| 1378 | if (event.key !== "Enter" && event.key !== " ") return; | 1516 | if (event.key !== "Enter" && event.key !== " ") return; |
| 1379 | event.preventDefault(); | 1517 | event.preventDefault(); |
| | 1518 | if (selectedThreadIds.size > 0) { |
| | 1519 | clearSelection(); |
| | 1520 | } |
| | 1521 | setSelectionAnchor(thread.id); |
| 1380 | void navigate({ | 1522 | void navigate({ |
| 1381 | to: "/$threadId", | 1523 | to: "/$threadId", |
| 1382 | params: { threadId: thread.id }, | 1524 | params: { threadId: thread.id }, |
| ... | @@ -1384,10 +1526,20 @@ export default function Sidebar() { | ... | @@ -1384,10 +1526,20 @@ export default function Sidebar() { |
| 1384 | }} | 1526 | }} |
| 1385 | onContextMenu={(event) => { | 1527 | onContextMenu={(event) => { |
| 1386 | event.preventDefault(); | 1528 | event.preventDefault(); |
| 1387 | void handleThreadContextMenu(thread.id, { | 1529 | if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { |
| 1388 | x: event.clientX, | 1530 | void handleMultiSelectContextMenu({ |
| 1389 | y: event.clientY, | 1531 | x: event.clientX, |
| 1390 | }); | 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 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> | 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,7 +1626,7 @@ export default function Sidebar() { |
| 1474 | )} | 1626 | )} |
| 1475 | <span | 1627 | <span |
| 1476 | className={`text-[10px] ${ | 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 | {formatRelativeTime(thread.createdAt)} | 1632 | {formatRelativeTime(thread.createdAt)} |
| ... | @@ -1489,6 +1641,7 @@ export default function Sidebar() { | ... | @@ -1489,6 +1641,7 @@ export default function Sidebar() { |
| 1489 | <SidebarMenuSubItem className="w-full"> | 1641 | <SidebarMenuSubItem className="w-full"> |
| 1490 | <SidebarMenuSubButton | 1642 | <SidebarMenuSubButton |
| 1491 | render={<button type="button" />} | 1643 | render={<button type="button" />} |
| | 1644 | data-thread-selection-safe |
| 1492 | size="sm" | 1645 | size="sm" |
| 1493 | 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" | 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 | onClick={() => { | 1647 | onClick={() => { |
| ... | @@ -1503,6 +1656,7 @@ export default function Sidebar() { | ... | @@ -1503,6 +1656,7 @@ export default function Sidebar() { |
| 1503 | <SidebarMenuSubItem className="w-full"> | 1656 | <SidebarMenuSubItem className="w-full"> |
| 1504 | <SidebarMenuSubButton | 1657 | <SidebarMenuSubButton |
| 1505 | render={<button type="button" />} | 1658 | render={<button type="button" />} |
| | 1659 | data-thread-selection-safe |
| 1506 | size="sm" | 1660 | size="sm" |
| 1507 | 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" | 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 | onClick={() => { | 1662 | onClick={() => { |