1import { performDesktopUpdate } from "./desktopUpdate.actions";
2import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
3import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
4import { Spinner } from "~/components/ui/spinner";
5import {
6 ArchiveIcon,
7 ArrowUpDownIcon,
8 ChevronRightIcon,
9 FolderPlusIcon,
10 Globe2Icon,
11 SearchIcon,
12 SquarePenIcon,
13 TriangleAlertIcon,
14} from "lucide-react";
15import {
16 ChangeRequestStatusIcon,
17 prStatusIndicator,
18 PrStatusTooltipContent,
19 ThreadTerminalStatusButton,
20 ThreadStatusLabel,
21 ThreadWorktreeIndicator,
22 useLinkedThreadPullRequest,
23} from "./ThreadStatusIndicators";
24import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
25import { ProjectFavicon } from "./ProjectFavicon";
26import { useAtomValue } from "@effect/atom-react";
27import { autoAnimate } from "@formkit/auto-animate";
28import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
29import { cn } from "~/lib/utils";
30import { useShallow } from "zustand/react/shallow";
31import {
32 DndContext,
33 type DragCancelEvent,
34 type CollisionDetection,
35 PointerSensor,
36 type DragStartEvent,
37 closestCorners,
38 pointerWithin,
39 useSensor,
40 useSensors,
41 type DragEndEvent,
42} from "@dnd-kit/core";
43import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
44import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
45import { CSS } from "@dnd-kit/utilities";
46import {
47 AuthOrchestrationOperateScope,
48 AuthPreviewOperateScope,
49 type ContextMenuItem,
50 type EnvironmentId,
51 ProjectId,
52 type ScopedThreadRef,
53 type ResolvedKeybindingsConfig,
54 type SidebarProjectGroupingMode,
55 resolveEnvironmentMachineKind,
56 ThreadId,
57} from "@t3tools/contracts";
58import {
59 parseScopedThreadKey,
60 scopedProjectKey,
61 scopedThreadKey,
62 scopeProjectRef,
63 scopeThreadRef,
64} from "@t3tools/client-runtime/environment";
65import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
66import {
67 isAtomCommandInterrupted,
68 settlePromise,
69 squashAtomCommandFailure,
70} from "@t3tools/client-runtime/state/runtime";
71import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
72import { useNavigate, useParams, useRouter } from "@tanstack/react-router";
73import {
74 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
75 MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
76 type SidebarProjectSortOrder,
77 type SidebarThreadPreviewCount,
78 type SidebarThreadSortOrder,
79} from "@t3tools/contracts/settings";
80import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal";
81import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
82import { isElectron } from "../env";
83import { useTerminalFocus } from "../hooks/useTerminalFocus";
84import { useOpenPrLink } from "../lib/openPullRequestLink";
85import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
86import { isTerminalFocused } from "../lib/terminalFocus";
87import { isMacPlatform } from "../lib/utils";
88import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
89import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
90import {
91 readThreadShell,
92 useProjects,
93 useThreadShells,
94 useThreadShellsForProjectRefs,
95 waitForProjectRemoval,
96} from "../state/entities";
97import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
98import { useThreadRunningTerminalIds } from "../state/terminalSessions";
99import { useThreadDiscoveredPorts } from "../portDiscoveryState";
100import { openDiscoveredPort } from "./preview/openDiscoveredPort";
101import { useAtomCommand } from "../state/use-atom-command";
102import {
103 ORCHESTRATION_OPERATE_DENIED_MESSAGE,
104 useOrchestrationCommand,
105} from "../state/use-orchestration-command";
106import { previewEnvironment } from "../state/preview";
107import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
108import {
109 legacyProjectCwdPreferenceKey,
110 resolveProjectExpanded,
111 useUiStateStore,
112} from "../uiStateStore";
113import {
114 resolveShortcutCommand,
115 shortcutLabelForCommand,
116 shouldShowThreadJumpHintsForModifiers,
117 threadJumpCommandForIndex,
118 threadJumpIndexFromCommand,
119 threadTraversalDirectionFromCommand,
120} from "../keybindings";
121import { isModelPickerOpen } from "../modelPickerVisibility";
122import { useShortcutModifierState } from "../shortcutModifierState";
123import { ensureLocalApi, readLocalApi } from "../localApi";
124import { useComposerDraftStore } from "../composerDraftStore";
125import { useNewThreadHandler } from "../hooks/useHandleNewThread";
126import { useDesktopUpdateState } from "../state/desktopUpdate";
127
128import { useThreadActions } from "../hooks/useThreadActions";
129import { projectEnvironment } from "../state/projects";
130import { threadEnvironment, useEnvironmentThread } from "../state/threads";
131import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
132import {
133 buildThreadRouteParams,
134 resolveActiveThreadRouteRef,
135 resolveThreadRouteTarget,
136} from "../threadRoutes";
137import { stackedThreadToast, toastManager } from "./ui/toast";
138import { formatRelativeTimeLabel } from "../timestampFormat";
139import { Kbd } from "./ui/kbd";
140import {
141 getArm64IntelBuildWarningDescription,
142 isDesktopUpdateButtonDisabled,
143 resolveDesktopUpdateButtonAction,
144 shouldShowArm64IntelBuildWarning,
145} from "./desktopUpdate.logic";
146import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
147import { Button } from "./ui/button";
148import {
149 Dialog,
150 DialogDescription,
151 DialogFooter,
152 DialogHeader,
153 DialogPanel,
154 DialogPopup,
155 DialogTitle,
156} from "./ui/dialog";
157import { Input } from "./ui/input";
158import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
159import {
160 NumberField,
161 NumberFieldDecrement,
162 NumberFieldGroup,
163 NumberFieldIncrement,
164 NumberFieldInput,
165} from "./ui/number-field";
166import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
167import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
168import {
169 SidebarContent,
170 SidebarGroup,
171 SidebarMenu,
172 SidebarMenuButton,
173 SidebarMenuItem,
174 SidebarMenuSub,
175 SidebarMenuSubButton,
176 SidebarMenuSubItem,
177 useSidebar,
178} from "./ui/sidebar";
179import {
180 getThreadKeysToDeselectAfterDelete,
181 useThreadSelectionStore,
182} from "../threadSelectionStore";
183import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
184import {
185 archiveSelectedThreadEntries,
186 buildMultiSelectThreadContextMenuItems,
187 deleteSelectedThreadEntries,
188 getSidebarThreadIdsToPrewarm,
189 resolveAdjacentThreadId,
190 isContextMenuPointerDown,
191 isSidebarNestedLinkClick,
192 isTrailingDoubleClick,
193 resolveProjectStatusIndicator,
194 resolveThreadRowClassName,
195 resolveThreadLastVisitedAt,
196 resolveThreadStatusPill,
197 orderItemsByPreferredIds,
198 shouldClearThreadSelectionOnMouseDown,
199 sortProjectsForSidebar,
200 useSidebarRowSubscriptionLease,
201 useThreadJumpHintVisibility,
202 ThreadStatusPill,
203} from "./Sidebar.logic";
204import { sortThreads } from "../lib/threadSort";
205import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
206import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
207import { useIsMobile } from "~/hooks/useMediaQuery";
208import { CommandDialogTrigger } from "./ui/command";
209import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
210import { primaryServerKeybindingsAtom } from "../state/server";
211import {
212 derivePhysicalProjectKey,
213 deriveProjectGroupingOverrideKey,
214 getProjectOrderKey,
215 selectProjectGroupingSettings,
216} from "../logicalProject";
217import type { SidebarThreadSummary } from "../types";
218import {
219 buildPhysicalToLogicalProjectKeyMap,
220 buildSidebarProjectSnapshots,
221 type SidebarProjectGroupMember,
222 type SidebarProjectSnapshot,
223} from "../sidebarProjectGrouping";
224import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
225const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
226 updated_at: "Last user message",
227 created_at: "Created at",
228 manual: "Manual",
229};
230const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
231 updated_at: "Last user message",
232 created_at: "Created at",
233};
234const SIDEBAR_LIST_ANIMATION_OPTIONS = {
235 duration: 180,
236 easing: "ease-out",
237} as const;
238const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
239const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
240 repository: "Group by repository",
241 repository_path: "Group by repository path",
242 separate: "Keep separate",
243};
244const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
245 "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring";
246
247function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
248 useEnvironmentThread(threadRef.environmentId, threadRef.threadId);
249 return null;
250}
251
252function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
253 return Math.min(
254 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
255 Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value),
256 ) as SidebarThreadPreviewCount;
257}
258
259function formatProjectMemberActionLabel(
260 member: SidebarProjectGroupMember,
261 groupedProjectCount: number,
262): string {
263 if (groupedProjectCount <= 1) {
264 return member.title;
265 }
266
267 return member.environmentLabel
268 ? `${member.environmentLabel} — ${member.workspaceRoot}`
269 : member.workspaceRoot;
270}
271
272function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] {
273 return [
274 project.projectKey,
275 ...project.memberProjects.map((member) => member.physicalProjectKey),
276 ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)),
277 ];
278}
279
280function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
281 switch (mode) {
282 case "repository":
283 return "Projects from the same repository share one sidebar row.";
284 case "repository_path":
285 return "Projects group only when both the repository and repo-relative path match.";
286 case "separate":
287 return "Every project path gets its own sidebar row.";
288 }
289}
290
291function buildThreadJumpLabelMap(input: {
292 keybindings: ResolvedKeybindingsConfig;
293 platform: string;
294 terminalOpen: boolean;
295 threadJumpCommandByKey: ReadonlyMap<
296 string,
297 NonNullable<ReturnType<typeof threadJumpCommandForIndex>>
298 >;
299}): ReadonlyMap<string, string> {
300 if (input.threadJumpCommandByKey.size === 0) {
301 return EMPTY_THREAD_JUMP_LABELS;
302 }
303
304 const shortcutLabelOptions = {
305 platform: input.platform,
306 context: {
307 terminalFocus: false,
308 terminalOpen: input.terminalOpen,
309 },
310 } as const;
311 const mapping = new Map<string, string>();
312 for (const [threadKey, command] of input.threadJumpCommandByKey) {
313 const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);
314 if (label) {
315 mapping.set(threadKey, label);
316 }
317 }
318 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
319}
320
321interface SidebarThreadRowProps {
322 thread: SidebarThreadSummary;
323 orderedProjectThreadKeys: readonly string[];
324 isActive: boolean;
325 openPullRequestsInRightPanel: boolean;
326 jumpLabel: string | null;
327 appSettingsConfirmThreadArchive: boolean;
328 renamingThreadKey: string | null;
329 renamingTitle: string;
330 setRenamingTitle: (title: string) => void;
331 startThreadRename: (threadKey: string, title: string) => void;
332 renamingInputRef: React.RefObject<HTMLInputElement | null>;
333 renamingCommittedRef: React.RefObject<boolean>;
334 confirmingArchiveThreadKey: string | null;
335 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
336 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
337 handleThreadClick: (
338 event: React.MouseEvent,
339 threadRef: ScopedThreadRef,
340 orderedProjectThreadKeys: readonly string[],
341 ) => void;
342 navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>;
343 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
344 handleThreadContextMenu: (
345 threadRef: ScopedThreadRef,
346 position: { x: number; y: number },
347 ) => Promise<void>;
348 clearSelection: () => void;
349 commitRename: (
350 threadRef: ScopedThreadRef,
351 newTitle: string,
352 originalTitle: string,
353 ) => Promise<void>;
354 cancelRename: () => void;
355 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
356 openPrLink: (
357 event: React.MouseEvent<HTMLElement>,
358 prUrl: string,
359 threadRef?: ScopedThreadRef,
360 ) => boolean;
361 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
362}
363
364function checkTaskPermission(environmentId: EnvironmentId): boolean {
365 if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true;
366 toastManager.add(
367 stackedThreadToast({
368 type: "error",
369 title: "Action unavailable",
370 description: ORCHESTRATION_OPERATE_DENIED_MESSAGE,
371 }),
372 );
373 return false;
374}
375
376const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
377 const {
378 orderedProjectThreadKeys,
379 isActive,
380 openPullRequestsInRightPanel,
381 jumpLabel,
382 appSettingsConfirmThreadArchive,
383 renamingThreadKey,
384 renamingTitle,
385 setRenamingTitle,
386 startThreadRename,
387 renamingInputRef,
388 renamingCommittedRef,
389 confirmingArchiveThreadKey,
390 setConfirmingArchiveThreadKey,
391 confirmArchiveButtonRefs,
392 handleThreadClick,
393 navigateToThread,
394 handleMultiSelectContextMenu,
395 handleThreadContextMenu,
396 clearSelection,
397 commitRename,
398 cancelRename,
399 attemptArchiveThread,
400 openPrLink,
401 onFileDropThreads,
402 thread,
403 } = props;
404 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
405 const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope);
406 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
407 const threadKey = scopedThreadKey(threadRef);
408 const [isFileDragOver, setIsFileDragOver] = useState(false);
409 const fileDropHandlers = useMemo(
410 () =>
411 makeWorkspaceFileDropHandlers({
412 setDragActive: setIsFileDragOver,
413 addFiles: (files) => {
414 onFileDropThreads(threadRef, files);
415 },
416 addFolders: () => {},
417 }),
418 [onFileDropThreads, threadRef],
419 );
420 useEffect(() => {
421 if (!isFileDragOver) return;
422 const clearFileDrag = () => setIsFileDragOver(false);
423 window.addEventListener("dragend", clearFileDrag);
424 return () => window.removeEventListener("dragend", clearFileDrag);
425 }, [isFileDragOver]);
426 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive);
427 const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
428 const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt);
429 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
430 const runningTerminalIds = useThreadRunningTerminalIds({
431 environmentId: thread.environmentId,
432 threadId: thread.id,
433 });
434 const isMobile = useIsMobile();
435 const discoveredPorts = useThreadDiscoveredPorts({
436 environmentId: thread.environmentId,
437 threadId: thread.id,
438 });
439 const openPreview = useAtomCommand(previewEnvironment.open, {
440 reportFailure: false,
441 });
442 const environment = useEnvironment(thread.environmentId);
443 const primaryEnvironmentId = usePrimaryEnvironmentId();
444 // No primary (the hosted app) means every thread is remote, and the machine
445 // glyph is what tells the environments apart.
446 const isRemoteThread = thread.environmentId !== primaryEnvironmentId;
447 const remoteEnvLabel = environment?.label ?? null;
448 const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);
449 // A desktop-local secondary backend (e.g. the WSL backend) shows up as a
450 // bearer environment whose connection id is prefixed "local:". It runs on the
451 // user's own machine, so the cloud icon is misleading, label it "Local" and
452 // suppress the cloud icon (the project header already shows a
453 // local-environment icon for desktop-local projects, see sidebarProjectGrouping).
454 const isDesktopLocalThread =
455 environment !== null && isDesktopLocalConnectionTarget(environment.entry.target);
456 const threadEnvironmentLabel = isRemoteThread
457 ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))
458 : null;
459 const isHighlighted = isActive || isSelected;
460 const handleOpenDiscoveredPort = useCallback(
461 (event: React.MouseEvent<HTMLButtonElement>) => {
462 const port = discoveredPorts[0];
463 if (!port || !canOperatePreview) return;
464 event.preventDefault();
465 event.stopPropagation();
466 navigateToThread(threadRef);
467 void (async () => {
468 const result = await openDiscoveredPort({ threadRef, port, openPreview });
469 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
470 return;
471 }
472 const error = squashAtomCommandFailure(result);
473 toastManager.add(
474 stackedThreadToast({
475 type: "error",
476 title: "Unable to open preview",
477 description:
478 error instanceof Error ? error.message : "The preview could not be opened.",
479 }),
480 );
481 })();
482 },
483 [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef],
484 );
485 const isThreadRunning = !threadRuntimeCanArchive(thread.runtime);
486 const threadStatus = resolveThreadStatusPill({
487 thread: {
488 ...thread,
489 lastVisitedAt,
490 },
491 });
492 const linkedPullRequestStatus = useLinkedThreadPullRequest(
493 thread.environmentId,
494 thread.linkedPullRequest,
495 leaseLiveStatus,
496 thread.pullRequests,
497 thread.branchPullRequest,
498 );
499 const pr = linkedPullRequestStatus?.pr ?? null;
500 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
501 const currentLinkedPr = supportsMultiplePullRequests
502 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
503 : null;
504 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
505 const isConfirmingArchive =
506 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
507 const threadMetaClassName = isConfirmingArchive
508 ? "pointer-events-none opacity-0"
509 : canOperateThread && !isThreadRunning
510 ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
511 : "pointer-events-none";
512 const clearConfirmingArchive = useCallback(() => {
513 setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
514 }, [setConfirmingArchiveThreadKey, threadKey]);
515 const handleMouseLeave = useCallback(() => {
516 clearConfirmingArchive();
517 }, [clearConfirmingArchive]);
518 const handleBlurCapture = useCallback(
519 (event: React.FocusEvent<HTMLLIElement>) => {
520 const currentTarget = event.currentTarget;
521 requestAnimationFrame(() => {
522 if (currentTarget.contains(document.activeElement)) {
523 return;
524 }
525 clearConfirmingArchive();
526 });
527 },
528 [clearConfirmingArchive],
529 );
530 const handleRowClick = useCallback(
531 (event: React.MouseEvent) => {
532 handleThreadClick(event, threadRef, orderedProjectThreadKeys);
533 },
534 [handleThreadClick, orderedProjectThreadKeys, threadRef],
535 );
536 const handleRowDoubleClick = useCallback(
537 (event: React.MouseEvent) => {
538 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return;
539 // Already renaming this row: a double-click on the row chrome (outside the
540 // input) must not restart and discard the in-progress edit.
541 if (renamingThreadKey === threadKey) return;
542 // On mobile the first tap navigates and closes the sidebar sheet, so the
543 // inline rename can't be shown. Renaming there stays on the context menu.
544 if (isMobile) return;
545 // cmd/ctrl/shift double-clicks are multi-select intent, not rename.
546 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
547 // Ignore double-clicks bubbling from nested controls (PR status, port,
548 // archive buttons) — only the row body should enter inline rename.
549 if ((event.target as HTMLElement).closest("button, a")) return;
550 event.preventDefault();
551 startThreadRename(threadKey, thread.title);
552 },
553 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title],
554 );
555 const handleRowKeyDown = useCallback(
556 (event: React.KeyboardEvent) => {
557 if (event.key !== "Enter" && event.key !== " ") return;
558 event.preventDefault();
559 navigateToThread(threadRef);
560 },
561 [navigateToThread, threadRef],
562 );
563 const handleRowContextMenu = useCallback(
564 (event: React.MouseEvent) => {
565 event.preventDefault();
566 const hasSelection = useThreadSelectionStore.getState().hasSelection();
567 if (hasSelection && isSelected) {
568 void (async () => {
569 const result = await settlePromise(() =>
570 handleMultiSelectContextMenu({
571 x: event.clientX,
572 y: event.clientY,
573 }),
574 );
575 if (result._tag === "Failure") {
576 const error = squashAtomCommandFailure(result);
577 toastManager.add(
578 stackedThreadToast({
579 type: "error",
580 title: "Thread action failed",
581 description: error instanceof Error ? error.message : "An error occurred.",
582 }),
583 );
584 }
585 })();
586 return;
587 }
588
589 if (hasSelection) {
590 clearSelection();
591 }
592 void (async () => {
593 const result = await settlePromise(() =>
594 handleThreadContextMenu(threadRef, {
595 x: event.clientX,
596 y: event.clientY,
597 }),
598 );
599 if (result._tag === "Failure") {
600 const error = squashAtomCommandFailure(result);
601 toastManager.add(
602 stackedThreadToast({
603 type: "error",
604 title: "Thread action failed",
605 description: error instanceof Error ? error.message : "An error occurred.",
606 }),
607 );
608 }
609 })();
610 },
611 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],
612 );
613 const handlePrClick = useCallback(
614 (event: React.MouseEvent<HTMLAnchorElement>) => {
615 const url = prStatus?.url ?? currentLinkedPr?.url;
616 if (!url) return;
617 const openedInRightPanel = openPrLink(
618 event,
619 url,
620 openPullRequestsInRightPanel ? threadRef : undefined,
621 );
622 if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) {
623 navigateToThread(threadRef);
624 }
625 },
626 [
627 isActive,
628 navigateToThread,
629 openPrLink,
630 openPullRequestsInRightPanel,
631 prStatus,
632 currentLinkedPr,
633 threadRef,
634 ],
635 );
636 const handleRenameInputRef = useCallback(
637 (element: HTMLInputElement | null) => {
638 if (element && renamingInputRef.current !== element) {
639 renamingInputRef.current = element;
640 element.focus();
641 element.select();
642 }
643 },
644 [renamingInputRef],
645 );
646 const handleRenameInputChange = useCallback(
647 (event: React.ChangeEvent<HTMLInputElement>) => {
648 setRenamingTitle(event.target.value);
649 },
650 [setRenamingTitle],
651 );
652 const handleRenameInputKeyDown = useCallback(
653 (event: React.KeyboardEvent<HTMLInputElement>) => {
654 event.stopPropagation();
655 if (event.key === "Enter") {
656 event.preventDefault();
657 renamingCommittedRef.current = true;
658 void commitRename(threadRef, renamingTitle, thread.title);
659 } else if (event.key === "Escape") {
660 event.preventDefault();
661 renamingCommittedRef.current = true;
662 cancelRename();
663 }
664 },
665 [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
666 );
667 const handleRenameInputBlur = useCallback(() => {
668 if (!renamingCommittedRef.current) {
669 void commitRename(threadRef, renamingTitle, thread.title);
670 }
671 }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
672 // Keep clicks/double-clicks inside the rename input from bubbling to the row.
673 // Without stopping `dblclick`, double-clicking to select a word would re-fire
674 // the row's rename handler and reset the in-progress edit back to the title.
675 const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
676 event.stopPropagation();
677 }, []);
678 const handleConfirmArchiveRef = useCallback(
679 (element: HTMLButtonElement | null) => {
680 if (element) {
681 confirmArchiveButtonRefs.current.set(threadKey, element);
682 } else {
683 confirmArchiveButtonRefs.current.delete(threadKey);
684 }
685 },
686 [confirmArchiveButtonRefs, threadKey],
687 );
688 const stopPropagationOnPointerDown = useCallback(
689 (event: React.PointerEvent<HTMLButtonElement>) => {
690 event.stopPropagation();
691 },
692 [],
693 );
694 const handleConfirmArchiveClick = useCallback(
695 (event: React.MouseEvent<HTMLButtonElement>) => {
696 event.preventDefault();
697 event.stopPropagation();
698 clearConfirmingArchive();
699 void attemptArchiveThread(threadRef);
700 },
701 [attemptArchiveThread, clearConfirmingArchive, threadRef],
702 );
703 const handleStartArchiveConfirmation = useCallback(
704 (event: React.MouseEvent<HTMLButtonElement>) => {
705 event.preventDefault();
706 event.stopPropagation();
707 if (!checkTaskPermission(thread.environmentId)) return;
708 setConfirmingArchiveThreadKey(threadKey);
709 requestAnimationFrame(() => {
710 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
711 });
712 },
713 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId],
714 );
715 const handleArchiveImmediateClick = useCallback(
716 (event: React.MouseEvent<HTMLButtonElement>) => {
717 event.preventDefault();
718 event.stopPropagation();
719 void attemptArchiveThread(threadRef);
720 },
721 [attemptArchiveThread, threadRef],
722 );
723
724 return (
725 <SidebarMenuSubItem
726 ref={rowRef}
727 className="w-full"
728 data-thread-item
729 {...fileDropHandlers}
730 onMouseLeave={handleMouseLeave}
731 onBlurCapture={handleBlurCapture}
732 >
733 {/* A thread row is the legacy sidebar's own control (a focusable div that hosts nested
734 links and buttons), not a SidebarMenuSubButton, so it owns its look here. */}
735 <div
736 role="button"
737 tabIndex={0}
738 data-active={isActive}
739 data-slot="sidebar-menu-sub-button"
740 data-sidebar="menu-sub-button"
741 data-size="sm"
742 data-testid={`thread-row-${thread.id}`}
743 className={cn(
744 "relative isolate flex h-8 w-full min-w-0 cursor-pointer select-none items-center gap-2 overflow-hidden rounded-md px-2 text-left text-xs outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring group-data-[collapsible=icon]:hidden [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground",
745 isActive
746 ? "bg-sidebar-row-active font-medium text-sidebar-foreground hover:bg-sidebar-row-active"
747 : isSelected
748 ? "bg-sidebar-row-selected text-sidebar-foreground hover:bg-sidebar-row-active"
749 : "text-sidebar-muted-foreground/80 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
750 isFileDragOver && "ring-1 ring-inset ring-primary/70",
751 )}
752 onClick={handleRowClick}
753 onDoubleClick={handleRowDoubleClick}
754 onKeyDown={handleRowKeyDown}
755 onContextMenu={handleRowContextMenu}
756 >
757 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
758 {prStatus && pr && (
759 <Tooltip>
760 <TooltipTrigger
761 render={
762 <a
763 href={prStatus.url}
764 target="_blank"
765 rel="noopener noreferrer"
766 aria-label={prStatus.tooltip}
767 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring`}
768 onPointerDown={(event) => event.stopPropagation()}
769 onClick={handlePrClick}
770 >
771 <ChangeRequestStatusIcon
772 state={pr.state}
773 isDraft={pr.isDraft}
774 className="size-3"
775 />
776 </a>
777 }
778 />
779 <TooltipPopup side="top">
780 <PrStatusTooltipContent status={prStatus} />
781 </TooltipPopup>
782 </Tooltip>
783 )}
784 {!pr && currentLinkedPr ? (
785 <a
786 href={currentLinkedPr.url}
787 target="_blank"
788 rel="noopener noreferrer"
789 onPointerDown={(event) => event.stopPropagation()}
790 onClick={handlePrClick}
791 className="text-muted-foreground"
792 aria-label={`PR #${currentLinkedPr.number}, status pending`}
793 >
794 <PullRequestGlyph.pullRequest className="size-3" />
795 </a>
796 ) : null}
797 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
798 {canOperateThread && renamingThreadKey === threadKey ? (
799 <input
800 ref={handleRenameInputRef}
801 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"
802 value={renamingTitle}
803 onChange={handleRenameInputChange}
804 onKeyDown={handleRenameInputKeyDown}
805 onBlur={handleRenameInputBlur}
806 onClick={handleRenameInputClick}
807 onDoubleClick={handleRenameInputClick}
808 />
809 ) : (
810 <Tooltip>
811 <TooltipTrigger
812 render={
813 <span
814 className="min-w-0 flex-1 truncate text-sm"
815 data-testid={`thread-title-${thread.id}`}
816 >
817 {thread.title}
818 </span>
819 }
820 />
821 <TooltipPopup side="top">{thread.title}</TooltipPopup>
822 </Tooltip>
823 )}
824 </div>
825 <div className="ml-auto flex shrink-0 items-center gap-1.5">
826 {canOperatePreview && discoveredPorts.length > 0 && (
827 <Tooltip>
828 <TooltipTrigger
829 render={
830 <button
831 type="button"
832 aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`}
833 className="inline-flex cursor-pointer items-center justify-center text-success-foreground outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
834 onClick={handleOpenDiscoveredPort}
835 />
836 }
837 >
838 <Globe2Icon className="size-3" />
839 </TooltipTrigger>
840 <TooltipPopup side="top">
841 Open localhost:{discoveredPorts[0]?.port}
842 {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""}
843 </TooltipPopup>
844 </Tooltip>
845 )}
846 <ThreadWorktreeIndicator thread={thread} />
847 <ThreadTerminalStatusButton
848 threadRef={threadRef}
849 terminalIds={runningTerminalIds}
850 onThreadActivate={navigateToThread}
851 />
852 <div
853 className={`flex min-w-12 justify-end ${
854 isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
855 }`}
856 >
857 {isConfirmingArchive ? (
858 <button
859 ref={handleConfirmArchiveRef}
860 type="button"
861 data-thread-selection-safe
862 data-testid={`thread-archive-confirm-${thread.id}`}
863 aria-label={`Confirm archive ${thread.title}`}
864 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-3xs font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-destructive/40"
865 onPointerDown={stopPropagationOnPointerDown}
866 onClick={handleConfirmArchiveClick}
867 >
868 Confirm
869 </button>
870 ) : canOperateThread && !isThreadRunning ? (
871 appSettingsConfirmThreadArchive ? (
872 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
873 <button
874 type="button"
875 data-thread-selection-safe
876 data-testid={`thread-archive-${thread.id}`}
877 aria-label={`Archive ${thread.title}`}
878 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
879 onPointerDown={stopPropagationOnPointerDown}
880 onClick={handleStartArchiveConfirmation}
881 >
882 <ArchiveIcon className="size-3.5" />
883 </button>
884 </div>
885 ) : (
886 <Tooltip>
887 <TooltipTrigger
888 render={
889 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
890 <button
891 type="button"
892 data-thread-selection-safe
893 data-testid={`thread-archive-${thread.id}`}
894 aria-label={`Archive ${thread.title}`}
895 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
896 onPointerDown={stopPropagationOnPointerDown}
897 onClick={handleArchiveImmediateClick}
898 >
899 <ArchiveIcon className="size-3.5" />
900 </button>
901 </div>
902 }
903 />
904 <TooltipPopup side="top">Archive</TooltipPopup>
905 </Tooltip>
906 )
907 ) : null}
908 <span className={threadMetaClassName}>
909 <span className="inline-flex items-center gap-1">
910 {isRemoteThread && !isDesktopLocalThread && (
911 <Tooltip>
912 <TooltipTrigger
913 render={
914 <span
915 aria-label={threadEnvironmentLabel ?? "Remote"}
916 className="inline-flex items-center justify-center"
917 />
918 }
919 >
920 <EnvironmentMachineIcon
921 kind={remoteMachine}
922 className="size-3 text-muted-foreground/40"
923 />
924 </TooltipTrigger>
925 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
926 </Tooltip>
927 )}
928 {jumpLabel ? (
929 <Tooltip>
930 <TooltipTrigger
931 render={
932 <span
933 aria-label={jumpLabel}
934 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-3xs font-medium tracking-tight text-foreground shadow-sm"
935 />
936 }
937 >
938 {jumpLabel}
939 </TooltipTrigger>
940 <TooltipPopup side="top">{jumpLabel}</TooltipPopup>
941 </Tooltip>
942 ) : (
943 <span
944 className={`text-3xs tabular-nums ${
945 isHighlighted ? "text-foreground" : "text-secondary-label"
946 }`}
947 >
948 {formatRelativeTimeLabel(
949 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
950 )}
951 </span>
952 )}
953 </span>
954 </span>
955 </div>
956 </div>
957 </div>
958 </SidebarMenuSubItem>
959 );
960});
961
962interface SidebarProjectThreadListProps {
963 projectKey: string;
964 projectExpanded: boolean;
965 hasOverflowingThreads: boolean;
966 hiddenThreadStatus: ThreadStatusPill | null;
967 orderedProjectThreadKeys: readonly string[];
968 renderedThreads: readonly SidebarThreadSummary[];
969 showEmptyThreadState: boolean;
970 shouldShowThreadPanel: boolean;
971 isThreadListExpanded: boolean;
972 activeRouteThreadKey: string | null;
973 openPullRequestsInRightPanel: boolean;
974 threadJumpLabelByKey: ReadonlyMap<string, string>;
975 appSettingsConfirmThreadArchive: boolean;
976 renamingThreadKey: string | null;
977 renamingTitle: string;
978 setRenamingTitle: (title: string) => void;
979 startThreadRename: (threadKey: string, title: string) => void;
980 renamingInputRef: React.RefObject<HTMLInputElement | null>;
981 renamingCommittedRef: React.RefObject<boolean>;
982 confirmingArchiveThreadKey: string | null;
983 setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;
984 confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;
985 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
986 handleThreadClick: (
987 event: React.MouseEvent,
988 threadRef: ScopedThreadRef,
989 orderedProjectThreadKeys: readonly string[],
990 ) => void;
991 navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>;
992 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
993 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
994 handleThreadContextMenu: (
995 threadRef: ScopedThreadRef,
996 position: { x: number; y: number },
997 ) => Promise<void>;
998 clearSelection: () => void;
999 commitRename: (
1000 threadRef: ScopedThreadRef,
1001 newTitle: string,
1002 originalTitle: string,
1003 ) => Promise<void>;
1004 cancelRename: () => void;
1005 attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;
1006 openPrLink: (
1007 event: React.MouseEvent<HTMLElement>,
1008 prUrl: string,
1009 threadRef?: ScopedThreadRef,
1010 ) => boolean;
1011 expandThreadListForProject: (projectKey: string) => void;
1012 collapseThreadListForProject: (projectKey: string) => void;
1013}
1014
1015const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
1016 props: SidebarProjectThreadListProps,
1017) {
1018 const {
1019 projectKey,
1020 projectExpanded,
1021 hasOverflowingThreads,
1022 hiddenThreadStatus,
1023 orderedProjectThreadKeys,
1024 renderedThreads,
1025 showEmptyThreadState,
1026 shouldShowThreadPanel,
1027 isThreadListExpanded,
1028 activeRouteThreadKey,
1029 openPullRequestsInRightPanel,
1030 threadJumpLabelByKey,
1031 appSettingsConfirmThreadArchive,
1032 renamingThreadKey,
1033 renamingTitle,
1034 setRenamingTitle,
1035 startThreadRename,
1036 renamingInputRef,
1037 renamingCommittedRef,
1038 confirmingArchiveThreadKey,
1039 setConfirmingArchiveThreadKey,
1040 confirmArchiveButtonRefs,
1041 attachThreadListAutoAnimateRef,
1042 handleThreadClick,
1043 navigateToThread,
1044 onFileDropThreads,
1045 handleMultiSelectContextMenu,
1046 handleThreadContextMenu,
1047 clearSelection,
1048 commitRename,
1049 cancelRename,
1050 attemptArchiveThread,
1051 openPrLink,
1052 expandThreadListForProject,
1053 collapseThreadListForProject,
1054 } = props;
1055 const showMoreButtonRender = useMemo(() => <button type="button" />, []);
1056 const showLessButtonRender = useMemo(() => <button type="button" />, []);
1057
1058 return (
1059 <SidebarMenuSub
1060 ref={attachThreadListAutoAnimateRef}
1061 className="mx-0.5 my-0 w-full translate-x-0 overflow-hidden sm:mx-1"
1062 >
1063 {shouldShowThreadPanel && showEmptyThreadState ? (
1064 <SidebarMenuSubItem className="w-full" data-thread-selection-safe>
1065 <div
1066 data-thread-selection-safe
1067 className="flex h-8 w-full translate-x-0 items-center px-2 text-left text-xs text-sidebar-muted-foreground/75"
1068 >
1069 <span>No threads yet</span>
1070 </div>
1071 </SidebarMenuSubItem>
1072 ) : null}
1073 {shouldShowThreadPanel &&
1074 renderedThreads.map((thread) => {
1075 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1076 return (
1077 <SidebarThreadRow
1078 key={threadKey}
1079 thread={thread}
1080 orderedProjectThreadKeys={orderedProjectThreadKeys}
1081 isActive={activeRouteThreadKey === threadKey}
1082 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
1083 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
1084 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
1085 renamingThreadKey={renamingThreadKey}
1086 renamingTitle={renamingTitle}
1087 setRenamingTitle={setRenamingTitle}
1088 startThreadRename={startThreadRename}
1089 renamingInputRef={renamingInputRef}
1090 renamingCommittedRef={renamingCommittedRef}
1091 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
1092 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
1093 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1094 handleThreadClick={handleThreadClick}
1095 navigateToThread={navigateToThread}
1096 onFileDropThreads={onFileDropThreads}
1097 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1098 handleThreadContextMenu={handleThreadContextMenu}
1099 clearSelection={clearSelection}
1100 commitRename={commitRename}
1101 cancelRename={cancelRename}
1102 attemptArchiveThread={attemptArchiveThread}
1103 openPrLink={openPrLink}
1104 />
1105 );
1106 })}
1107
1108 {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && (
1109 <SidebarMenuSubItem className="w-full">
1110 <SidebarMenuSubButton
1111 render={showMoreButtonRender}
1112 data-thread-selection-safe
1113 size="sm"
1114 onClick={() => {
1115 expandThreadListForProject(projectKey);
1116 }}
1117 >
1118 <span className="flex min-w-0 flex-1 items-center gap-2">
1119 {hiddenThreadStatus && <ThreadStatusLabel status={hiddenThreadStatus} compact />}
1120 <span>Show more</span>
1121 </span>
1122 </SidebarMenuSubButton>
1123 </SidebarMenuSubItem>
1124 )}
1125 {projectExpanded && hasOverflowingThreads && isThreadListExpanded && (
1126 <SidebarMenuSubItem className="w-full">
1127 <SidebarMenuSubButton
1128 render={showLessButtonRender}
1129 data-thread-selection-safe
1130 size="sm"
1131 onClick={() => {
1132 collapseThreadListForProject(projectKey);
1133 }}
1134 >
1135 <span>Show less</span>
1136 </SidebarMenuSubButton>
1137 </SidebarMenuSubItem>
1138 )}
1139 </SidebarMenuSub>
1140 );
1141});
1142
1143interface SidebarProjectItemProps {
1144 project: SidebarProjectSnapshot;
1145 isThreadListExpanded: boolean;
1146 activeRouteThreadKey: string | null;
1147 openPullRequestsInRightPanel: boolean;
1148 newThreadShortcutLabel: string | null;
1149 handleNewThread: ReturnType<typeof useNewThreadHandler>;
1150 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
1151 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
1152 markThreadUnread: ReturnType<typeof useThreadActions>["markThreadUnread"];
1153 threadJumpLabelByKey: ReadonlyMap<string, string>;
1154 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
1155 expandThreadListForProject: (projectKey: string) => void;
1156 collapseThreadListForProject: (projectKey: string) => void;
1157 dragInProgressRef: React.RefObject<boolean>;
1158 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
1159 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
1160 isManualProjectSorting: boolean;
1161 dragHandleProps: SortableProjectHandleProps | null;
1162}
1163
1164const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) {
1165 const {
1166 project,
1167 isThreadListExpanded,
1168 activeRouteThreadKey,
1169 openPullRequestsInRightPanel,
1170 newThreadShortcutLabel,
1171 handleNewThread,
1172 archiveThread,
1173 deleteThread,
1174 markThreadUnread,
1175 threadJumpLabelByKey,
1176 attachThreadListAutoAnimateRef,
1177 expandThreadListForProject,
1178 collapseThreadListForProject,
1179 dragInProgressRef,
1180 suppressProjectClickAfterDragRef,
1181 suppressProjectClickForContextMenuRef,
1182 isManualProjectSorting,
1183 dragHandleProps,
1184 } = props;
1185 const environmentMachine = project.allRemoteMembersAreWsl
1186 ? "linux"
1187 : project.allRemoteMembersAreDesktopLocal
1188 ? "laptop"
1189 : "cloud";
1190 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
1191 (settings) => settings.sidebarThreadSortOrder,
1192 );
1193 const appSettingsConfirmThreadDelete = useClientSettings<boolean>(
1194 (settings) => settings.confirmThreadDelete,
1195 );
1196 const appSettingsConfirmThreadArchive = useClientSettings<boolean>(
1197 (settings) => settings.confirmThreadArchive,
1198 );
1199 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1200 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
1201 reportFailure: false,
1202 });
1203 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
1204 reportFailure: false,
1205 });
1206 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
1207 reportFailure: false,
1208 });
1209 const updateSettings = useUpdateClientSettings();
1210 const sidebarThreadPreviewCount = useClientSettings<SidebarThreadPreviewCount>(
1211 (settings) => settings.sidebarThreadPreviewCount,
1212 );
1213 const router = useRouter();
1214 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
1215 const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);
1216 const { isMobile, setOpenMobile } = useSidebar();
1217 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
1218 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
1219 const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
1220 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
1221 const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
1222 const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
1223 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
1224 threadId: ThreadId;
1225 }>({
1226 onCopy: (ctx) => {
1227 toastManager.add({
1228 type: "success",
1229 title: "Thread ID copied",
1230 description: ctx.threadId,
1231 });
1232 },
1233 onError: (error) => {
1234 toastManager.add(
1235 stackedThreadToast({
1236 type: "error",
1237 title: "Failed to copy thread ID",
1238 description: error instanceof Error ? error.message : "An error occurred.",
1239 }),
1240 );
1241 },
1242 });
1243 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1244 path: string;
1245 }>({
1246 onCopy: (ctx) => {
1247 toastManager.add({
1248 type: "success",
1249 title: "Path copied",
1250 description: ctx.path,
1251 });
1252 },
1253 onError: (error) => {
1254 toastManager.add(
1255 stackedThreadToast({
1256 type: "error",
1257 title: "Failed to copy path",
1258 description: error instanceof Error ? error.message : "An error occurred.",
1259 }),
1260 );
1261 },
1262 });
1263 const openPrLink = useOpenPrLink();
1264 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
1265 const sidebarThreadByKey = useMemo(
1266 () =>
1267 new Map(
1268 sidebarThreads.map(
1269 (thread) =>
1270 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
1271 ),
1272 ),
1273 [sidebarThreads],
1274 );
1275 // Keep a ref so callbacks can read the latest map without appearing in
1276 // dependency arrays (avoids invalidating every thread-row memo on each
1277 // thread-list change).
1278 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
1279 sidebarThreadByKeyRef.current = sidebarThreadByKey;
1280 const projectThreads = sidebarThreads;
1281 const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]);
1282 const projectExpanded = useUiStateStore((state) =>
1283 resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys),
1284 );
1285 const threadLastVisitedAts = useUiStateStore(
1286 useShallow((state) =>
1287 projectThreads.map(
1288 (thread) =>
1289 state.threadLastVisitedAtById[
1290 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))
1291 ] ?? null,
1292 ),
1293 ),
1294 );
1295 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1296 const [renamingTitle, setRenamingTitle] = useState("");
1297 const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState<string | null>(null);
1298 const [projectRenameTarget, setProjectRenameTarget] = useState<SidebarProjectGroupMember | null>(
1299 null,
1300 );
1301 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1302 const canRenameProject = useEnvironmentScope(
1303 projectRenameTarget?.environmentId ?? null,
1304 AuthOrchestrationOperateScope,
1305 );
1306 const [projectGroupingTarget, setProjectGroupingTarget] =
1307 useState<SidebarProjectGroupMember | null>(null);
1308 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
1309 SidebarProjectGroupingMode | "inherit"
1310 >("inherit");
1311 const renamingCommittedRef = useRef(false);
1312 const renamingInputRef = useRef<HTMLInputElement | null>(null);
1313 const confirmArchiveButtonRefs = useRef(new Map<string, HTMLButtonElement>());
1314 const memberProjectByScopedKey = useMemo(
1315 () =>
1316 new Map(
1317 project.memberProjects.map((member) => [
1318 scopedProjectKey(scopeProjectRef(member.environmentId, member.id)),
1319 member,
1320 ]),
1321 ),
1322 [project.memberProjects],
1323 );
1324 const memberThreadCountByPhysicalKey = useMemo(() => {
1325 const counts = new Map<string, number>(
1326 project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const),
1327 );
1328 for (const thread of projectThreads) {
1329 const member = memberProjectByScopedKey.get(
1330 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
1331 );
1332 if (!member) {
1333 continue;
1334 }
1335 counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1);
1336 }
1337 return counts;
1338 }, [memberProjectByScopedKey, project.memberProjects, projectThreads]);
1339
1340 const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
1341 const lastVisitedAtByThreadKey = new Map(
1342 projectThreads.map((thread, index) => [
1343 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1344 resolveThreadLastVisitedAt(thread.lastVisitedAt, threadLastVisitedAts[index] ?? undefined),
1345 ]),
1346 );
1347 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1348 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1349 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1350 );
1351 return resolveThreadStatusPill({
1352 thread: {
1353 ...thread,
1354 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1355 },
1356 });
1357 };
1358 const visibleProjectThreads = sortThreads(
1359 projectThreads.filter((thread) => thread.archivedAt === null),
1360 threadSortOrder,
1361 );
1362 const projectStatus = resolveProjectStatusIndicator(
1363 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1364 );
1365 return {
1366 orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
1367 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1368 ),
1369 projectStatus,
1370 visibleProjectThreads,
1371 };
1372 }, [projectThreads, threadLastVisitedAts, threadSortOrder]);
1373 const pinnedCollapsedThread = useMemo(() => {
1374 const activeThreadKey = activeRouteThreadKey ?? undefined;
1375 if (!activeThreadKey || projectExpanded) {
1376 return null;
1377 }
1378 return (
1379 visibleProjectThreads.find(
1380 (thread) =>
1381 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
1382 ) ?? null
1383 );
1384 }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
1385
1386 const {
1387 hasOverflowingThreads,
1388 hiddenThreadStatus,
1389 renderedThreads,
1390 showEmptyThreadState,
1391 shouldShowThreadPanel,
1392 } = useMemo(() => {
1393 const lastVisitedAtByThreadKey = new Map(
1394 projectThreads.map((thread, index) => [
1395 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1396 resolveThreadLastVisitedAt(thread.lastVisitedAt, threadLastVisitedAts[index] ?? undefined),
1397 ]),
1398 );
1399 const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
1400 const lastVisitedAt = lastVisitedAtByThreadKey.get(
1401 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1402 );
1403 return resolveThreadStatusPill({
1404 thread: {
1405 ...thread,
1406 ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
1407 },
1408 });
1409 };
1410 const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount;
1411 const previewThreads =
1412 isThreadListExpanded || !hasOverflowingThreads
1413 ? visibleProjectThreads
1414 : visibleProjectThreads.slice(0, sidebarThreadPreviewCount);
1415 const visibleThreadKeys = new Set(
1416 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1417 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1418 ),
1419 );
1420 const renderedThreads = pinnedCollapsedThread
1421 ? [pinnedCollapsedThread]
1422 : visibleProjectThreads.filter((thread) =>
1423 visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1424 );
1425 const hiddenThreads = visibleProjectThreads.filter(
1426 (thread) =>
1427 !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1428 );
1429 return {
1430 hasOverflowingThreads,
1431 hiddenThreadStatus: resolveProjectStatusIndicator(
1432 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1433 ),
1434 renderedThreads,
1435 showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
1436 shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null,
1437 };
1438 }, [
1439 isThreadListExpanded,
1440 pinnedCollapsedThread,
1441 projectExpanded,
1442 projectThreads,
1443 sidebarThreadPreviewCount,
1444 threadLastVisitedAts,
1445 visibleProjectThreads,
1446 ]);
1447
1448 const handleProjectButtonClick = useCallback(
1449 (event: React.MouseEvent<HTMLButtonElement>) => {
1450 if (suppressProjectClickForContextMenuRef.current) {
1451 suppressProjectClickForContextMenuRef.current = false;
1452 event.preventDefault();
1453 event.stopPropagation();
1454 return;
1455 }
1456 if (dragInProgressRef.current) {
1457 event.preventDefault();
1458 event.stopPropagation();
1459 return;
1460 }
1461 if (suppressProjectClickAfterDragRef.current) {
1462 suppressProjectClickAfterDragRef.current = false;
1463 event.preventDefault();
1464 event.stopPropagation();
1465 return;
1466 }
1467 if (useThreadSelectionStore.getState().hasSelection()) {
1468 clearSelection();
1469 }
1470 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1471 },
1472 [
1473 clearSelection,
1474 dragInProgressRef,
1475 projectExpanded,
1476 projectPreferenceKeys,
1477 setProjectExpanded,
1478 suppressProjectClickAfterDragRef,
1479 suppressProjectClickForContextMenuRef,
1480 ],
1481 );
1482
1483 const handleProjectButtonKeyDown = useCallback(
1484 (event: React.KeyboardEvent<HTMLButtonElement>) => {
1485 if (event.key !== "Enter" && event.key !== " ") return;
1486 event.preventDefault();
1487 if (dragInProgressRef.current) {
1488 return;
1489 }
1490 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1491 },
1492 [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded],
1493 );
1494
1495 const handleProjectButtonPointerDownCapture = useCallback(
1496 (event: React.PointerEvent<HTMLButtonElement>) => {
1497 suppressProjectClickForContextMenuRef.current = false;
1498 if (
1499 isContextMenuPointerDown({
1500 button: event.button,
1501 ctrlKey: event.ctrlKey,
1502 isMac: isMacPlatform(navigator.platform),
1503 })
1504 ) {
1505 event.stopPropagation();
1506 }
1507
1508 suppressProjectClickAfterDragRef.current = false;
1509 },
1510 [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef],
1511 );
1512
1513 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1514 if (!checkTaskPermission(member.environmentId)) return;
1515 setProjectRenameTarget(member);
1516 setProjectRenameTitle(member.title);
1517 }, []);
1518
1519 const openProjectGroupingDialog = useCallback(
1520 (member: SidebarProjectGroupMember) => {
1521 const overrideKey = deriveProjectGroupingOverrideKey(member);
1522 setProjectGroupingTarget(member);
1523 setProjectGroupingSelection(
1524 projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit",
1525 );
1526 },
1527 [projectGroupingSettings.sidebarProjectGroupingOverrides],
1528 );
1529
1530 const removeProject = useCallback(
1531 async (member: SidebarProjectGroupMember) => {
1532 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1533 const result = await deleteProject({
1534 environmentId: member.environmentId,
1535 input: {
1536 projectId: member.id,
1537 force: true,
1538 },
1539 });
1540 if (result._tag === "Failure") {
1541 return result;
1542 }
1543 await waitForProjectRemoval(memberProjectRef);
1544 const draftStore = useComposerDraftStore.getState();
1545 releaseProjectDraftUploads(
1546 memberProjectRef,
1547 sidebarThreads
1548 .filter(
1549 (thread) =>
1550 thread.environmentId === member.environmentId && thread.projectId === member.id,
1551 )
1552 .map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
1553 );
1554 const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef);
1555 if (projectDraftThread) {
1556 draftStore.clearDraftThread(projectDraftThread.draftId);
1557 }
1558 draftStore.clearProjectDraftThreadId(memberProjectRef);
1559 return result;
1560 },
1561 [deleteProject, sidebarThreads],
1562 );
1563
1564 const handleRemoveProject = useCallback(
1565 async (member: SidebarProjectGroupMember) => {
1566 if (!checkTaskPermission(member.environmentId)) return;
1567 const api = readLocalApi();
1568 if (!api) {
1569 return;
1570 }
1571
1572 const memberProjectRef = scopeProjectRef(member.environmentId, member.id);
1573 const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
1574 if (memberThreadCount > 0) {
1575 const warningToastId = toastManager.add(
1576 stackedThreadToast({
1577 type: "warning",
1578 title: "Project is not empty",
1579 description: "Delete all threads in this project before removing it.",
1580 actionVariant: "destructive",
1581 actionProps: {
1582 children: "Delete anyway",
1583 onClick: () => {
1584 void (async () => {
1585 toastManager.close(warningToastId);
1586 await new Promise<void>((resolve) => {
1587 window.setTimeout(resolve, 180);
1588 });
1589 if (!checkTaskPermission(member.environmentId)) return;
1590
1591 const latestProjectThreads = Array.from(
1592 sidebarThreadByKeyRef.current.values(),
1593 ).filter(
1594 (thread) =>
1595 thread.environmentId === memberProjectRef.environmentId &&
1596 thread.projectId === memberProjectRef.projectId,
1597 );
1598 const confirmed = await api.dialogs.confirm(
1599 latestProjectThreads.length > 0
1600 ? [
1601 `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${
1602 latestProjectThreads.length === 1 ? "" : "s"
1603 }?`,
1604 `Path: ${member.workspaceRoot}`,
1605 ...(member.environmentLabel
1606 ? [`Environment: ${member.environmentLabel}`]
1607 : []),
1608 "This permanently clears conversation history for those threads and any archived threads.",
1609 "This removes only this project entry.",
1610 "This action cannot be undone.",
1611 ].join("\n")
1612 : [
1613 `Remove project "${member.title}"?`,
1614 `Path: ${member.workspaceRoot}`,
1615 ...(member.environmentLabel
1616 ? [`Environment: ${member.environmentLabel}`]
1617 : []),
1618 "This permanently clears any archived conversation history.",
1619 "This removes only this project entry.",
1620 ].join("\n"),
1621 { variant: "destructive" },
1622 );
1623 if (!confirmed) {
1624 return;
1625 }
1626
1627 const result = await removeProject(member);
1628 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1629 const error = squashAtomCommandFailure(result);
1630 toastManager.add(
1631 stackedThreadToast({
1632 type: "error",
1633 title: `Failed to remove "${member.title}"`,
1634 description:
1635 error instanceof Error
1636 ? error.message
1637 : "Unknown error removing project.",
1638 }),
1639 );
1640 }
1641 })().catch((error) => {
1642 const message =
1643 error instanceof Error ? error.message : "Unknown error removing project.";
1644 console.error("Failed to remove project", {
1645 projectId: member.id,
1646 environmentId: member.environmentId,
1647 ...safeErrorLogAttributes(error),
1648 });
1649 toastManager.add(
1650 stackedThreadToast({
1651 type: "error",
1652 title: `Failed to remove "${member.title}"`,
1653 description: message,
1654 }),
1655 );
1656 });
1657 },
1658 },
1659 }),
1660 );
1661 return;
1662 }
1663
1664 const message = [
1665 `Remove project "${member.title}"?`,
1666 `Path: ${member.workspaceRoot}`,
1667 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
1668 "This permanently clears any archived conversation history.",
1669 "This removes only this project entry.",
1670 ].join("\n");
1671 const confirmed = await api.dialogs.confirm(message, { variant: "destructive" });
1672 if (!confirmed) {
1673 return;
1674 }
1675
1676 const result = await removeProject(member);
1677 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1678 const error = squashAtomCommandFailure(result);
1679 const message = error instanceof Error ? error.message : "Unknown error removing project.";
1680 console.error("Failed to remove project", {
1681 projectId: member.id,
1682 environmentId: member.environmentId,
1683 ...safeErrorLogAttributes(error),
1684 });
1685 toastManager.add(
1686 stackedThreadToast({
1687 type: "error",
1688 title: `Failed to remove "${member.title}"`,
1689 description: message,
1690 }),
1691 );
1692 }
1693 },
1694 [memberThreadCountByPhysicalKey, removeProject],
1695 );
1696
1697 const handleProjectButtonContextMenu = useCallback(
1698 (event: React.MouseEvent<HTMLButtonElement>) => {
1699 event.preventDefault();
1700 suppressProjectClickForContextMenuRef.current = true;
1701 void (async () => {
1702 const api = readLocalApi();
1703 if (!api) return;
1704
1705 const actionHandlers = new Map<string, () => Promise<void> | void>();
1706 const makeLeaf = (
1707 action: "rename" | "grouping" | "copy-path" | "delete",
1708 member: SidebarProjectGroupMember,
1709 options?: {
1710 destructive?: boolean;
1711 disabled?: boolean;
1712 },
1713 ): ContextMenuItem<string> => {
1714 const id = `${action}:${member.physicalProjectKey}`;
1715 actionHandlers.set(id, () => {
1716 switch (action) {
1717 case "rename":
1718 openProjectRenameDialog(member);
1719 return;
1720 case "grouping":
1721 openProjectGroupingDialog(member);
1722 return;
1723 case "copy-path":
1724 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot });
1725 return;
1726 case "delete":
1727 return handleRemoveProject(member);
1728 }
1729 });
1730
1731 return {
1732 id,
1733 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
1734 ...(options?.destructive ? { destructive: true } : {}),
1735 ...(options?.disabled ? { disabled: true } : {}),
1736 };
1737 };
1738
1739 const buildTargetedItem = (
1740 action: "rename" | "grouping" | "copy-path" | "delete",
1741 label: string,
1742 options?: {
1743 destructive?: boolean;
1744 isDisabled?: (member: SidebarProjectGroupMember) => boolean;
1745 },
1746 ): ContextMenuItem<string> => {
1747 if (project.memberProjects.length === 1) {
1748 const singleMember = project.memberProjects[0]!;
1749 return {
1750 ...makeLeaf(action, singleMember, {
1751 ...(options?.destructive ? { destructive: true } : {}),
1752 ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}),
1753 }),
1754 label,
1755 ...(action === "delete" ? { icon: "trash" } : {}),
1756 };
1757 }
1758
1759 return {
1760 id: `${action}:submenu`,
1761 label,
1762 ...(action === "delete" ? { icon: "trash" } : {}),
1763 children: project.memberProjects.map((member) =>
1764 makeLeaf(action, member, {
1765 ...(options?.destructive ? { destructive: true } : {}),
1766 ...(options?.isDisabled?.(member) ? { disabled: true } : {}),
1767 }),
1768 ),
1769 };
1770 };
1771
1772 actionHandlers.set("project-settings", () => {
1773 if (isMobile) setOpenMobile(false);
1774 void router.navigate({
1775 to: "/projects/$projectKey",
1776 params: { projectKey: project.projectKey },
1777 });
1778 });
1779
1780 const clicked = await api.contextMenu.show(
1781 [
1782 buildTargetedItem("rename", "Rename", {
1783 isDisabled: (member) =>
1784 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
1785 }),
1786 buildTargetedItem("grouping", "Group into..."),
1787 buildTargetedItem("copy-path", "Copy Path"),
1788 { id: "project-settings", label: "Project settings", icon: "settings" },
1789 buildTargetedItem("delete", "Remove", {
1790 destructive: true,
1791 isDisabled: (member) =>
1792 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
1793 }),
1794 ],
1795 {
1796 x: event.clientX,
1797 y: event.clientY,
1798 },
1799 );
1800
1801 if (!clicked) {
1802 return;
1803 }
1804
1805 await actionHandlers.get(clicked)?.();
1806 })();
1807 },
1808 [
1809 copyPathToClipboard,
1810 handleRemoveProject,
1811 isMobile,
1812 openProjectGroupingDialog,
1813 openProjectRenameDialog,
1814 project.groupedProjectCount,
1815 project.memberProjects,
1816 project.projectKey,
1817 router,
1818 setOpenMobile,
1819 suppressProjectClickForContextMenuRef,
1820 ],
1821 );
1822
1823 const navigateToThread = useCallback(
1824 (threadRef: ScopedThreadRef) => {
1825 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
1826 clearSelection();
1827 }
1828 setSelectionAnchor(scopedThreadKey(threadRef));
1829 if (isMobile) {
1830 setOpenMobile(false);
1831 }
1832 return router.navigate({
1833 to: "/$environmentId/$threadId",
1834 params: buildThreadRouteParams(threadRef),
1835 });
1836 },
1837 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
1838 );
1839 const handleThreadFileDrop = useCallback(
1840 async (threadRef: ScopedThreadRef, files: File[]) => {
1841 const dropId = queuePendingFileDrop({ threadRef, files });
1842 const targetPathname = router.buildLocation({
1843 to: "/$environmentId/$threadId",
1844 params: buildThreadRouteParams(threadRef),
1845 }).pathname;
1846 if (targetPathname === router.state.location.pathname) return;
1847 try {
1848 await navigateToThread(threadRef);
1849 if (targetPathname !== router.state.location.pathname) {
1850 clearPendingFileDrop(dropId);
1851 }
1852 } catch {
1853 clearPendingFileDrop(dropId);
1854 }
1855 },
1856 [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router],
1857 );
1858
1859 const handleThreadClick = useCallback(
1860 (
1861 event: React.MouseEvent,
1862 threadRef: ScopedThreadRef,
1863 orderedProjectThreadKeys: readonly string[],
1864 ) => {
1865 if (isSidebarNestedLinkClick(event.target)) return;
1866 const isMac = isMacPlatform(navigator.platform);
1867 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1868 const isShiftClick = event.shiftKey;
1869 const threadKey = scopedThreadKey(threadRef);
1870 const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
1871
1872 if (isModClick) {
1873 event.preventDefault();
1874 toggleThreadSelection(threadKey);
1875 return;
1876 }
1877
1878 if (isShiftClick) {
1879 event.preventDefault();
1880 rangeSelectTo(threadKey, orderedProjectThreadKeys);
1881 return;
1882 }
1883
1884 // Ignore the trailing click of a plain double-click so it doesn't navigate
1885 // while a double-click is starting an inline rename. Placed after the
1886 // modifier branches so cmd/shift selection still processes every click.
1887 if (isTrailingDoubleClick(event.detail)) {
1888 return;
1889 }
1890
1891 if (currentSelectionCount > 0) {
1892 clearSelection();
1893 }
1894 setSelectionAnchor(threadKey);
1895 if (isMobile) {
1896 setOpenMobile(false);
1897 }
1898 void router.navigate({
1899 to: "/$environmentId/$threadId",
1900 params: buildThreadRouteParams(threadRef),
1901 });
1902 },
1903 [
1904 clearSelection,
1905 isMobile,
1906 rangeSelectTo,
1907 router,
1908 setOpenMobile,
1909 setSelectionAnchor,
1910 toggleThreadSelection,
1911 ],
1912 );
1913
1914 const handleMultiSelectContextMenu = useCallback(
1915 async (position: { x: number; y: number }) => {
1916 const api = readLocalApi();
1917 if (!api) return;
1918 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
1919 if (threadKeys.length === 0) return;
1920 const count = threadKeys.length;
1921 const selectedThreadEntries = threadKeys.flatMap((threadKey) => {
1922 const threadRef = parseScopedThreadKey(threadKey);
1923 const thread = threadRef ? readThreadShell(threadRef) : null;
1924 return threadRef && thread ? [{ threadKey, threadRef, thread }] : [];
1925 });
1926 const hasRunningThread = selectedThreadEntries.some(
1927 ({ thread }) => !threadRuntimeCanArchive(thread.runtime),
1928 );
1929 const canOperateSelection = selectedThreadEntries.every(({ threadRef }) =>
1930 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
1931 );
1932
1933 const clicked = await api.contextMenu.show(
1934 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }).map((item) =>
1935 item.id === "archive" || item.id === "delete"
1936 ? { ...item, disabled: item.disabled || !canOperateSelection }
1937 : item,
1938 ),
1939 position,
1940 );
1941
1942 if (clicked === "mark-unread") {
1943 for (const { threadRef } of selectedThreadEntries) {
1944 markThreadUnread(threadRef);
1945 }
1946 clearSelection();
1947 return;
1948 }
1949
1950 if (
1951 (clicked === "archive" || clicked === "delete") &&
1952 !selectedThreadEntries.every(({ threadRef }) =>
1953 checkTaskPermission(threadRef.environmentId),
1954 )
1955 ) {
1956 return;
1957 }
1958
1959 if (clicked === "archive") {
1960 if (appSettingsConfirmThreadArchive) {
1961 const confirmed = await api.dialogs.confirm(
1962 `Archive ${count} thread${count === 1 ? "" : "s"}?`,
1963 );
1964 if (!confirmed) return;
1965 }
1966 if (
1967 !selectedThreadEntries.every(({ threadRef }) =>
1968 checkTaskPermission(threadRef.environmentId),
1969 )
1970 ) {
1971 return;
1972 }
1973
1974 const archiveOutcome = await archiveSelectedThreadEntries({
1975 entries: selectedThreadEntries,
1976 archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }),
1977 });
1978 for (const failure of archiveOutcome.followupFailures) {
1979 if (isAtomCommandInterrupted(failure)) continue;
1980 const error = squashAtomCommandFailure(failure);
1981 toastManager.add(
1982 stackedThreadToast({
1983 type: "error",
1984 title: "Thread archived, but navigation failed",
1985 description: error instanceof Error ? error.message : "An error occurred.",
1986 }),
1987 );
1988 }
1989 if (archiveOutcome.mutationFailure) {
1990 removeFromSelection(archiveOutcome.archivedThreadKeys);
1991 if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) {
1992 const error = squashAtomCommandFailure(archiveOutcome.mutationFailure);
1993 toastManager.add(
1994 stackedThreadToast({
1995 type: "error",
1996 title: "Failed to archive threads",
1997 description: error instanceof Error ? error.message : "An error occurred.",
1998 }),
1999 );
2000 }
2001 return;
2002 }
2003 removeFromSelection(threadKeys);
2004 return;
2005 }
2006
2007 if (clicked !== "delete") return;
2008
2009 if (appSettingsConfirmThreadDelete) {
2010 const confirmed = await api.dialogs.confirm(
2011 [
2012 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
2013 "This permanently clears conversation history for these threads.",
2014 ].join("\n"),
2015 { variant: "destructive" },
2016 );
2017 if (!confirmed) return;
2018 }
2019 if (
2020 !selectedThreadEntries.every(({ threadRef }) =>
2021 checkTaskPermission(threadRef.environmentId),
2022 )
2023 ) {
2024 return;
2025 }
2026
2027 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
2028 entries: selectedThreadEntries,
2029 delete: ({ threadRef }, deletedThreadKeys) =>
2030 deleteThread(threadRef, { deletedThreadKeys }),
2031 });
2032 if (firstFailure !== null) {
2033 const firstError = squashAtomCommandFailure(firstFailure);
2034 toastManager.add(
2035 stackedThreadToast({
2036 type: "error",
2037 title: "Failed to delete threads",
2038 description: firstError instanceof Error ? firstError.message : "An error occurred.",
2039 }),
2040 );
2041 }
2042 removeFromSelection(
2043 getThreadKeysToDeselectAfterDelete(threadKeys, deletedThreadKeys, (threadKey) => {
2044 const threadRef = parseScopedThreadKey(threadKey);
2045 return threadRef !== null && readThreadShell(threadRef) !== null;
2046 }),
2047 );
2048 },
2049 [
2050 appSettingsConfirmThreadArchive,
2051 appSettingsConfirmThreadDelete,
2052 archiveThread,
2053 clearSelection,
2054 deleteThread,
2055 markThreadUnread,
2056 removeFromSelection,
2057 ],
2058 );
2059
2060 const createThreadForProjectMember = useCallback(
2061 (member: SidebarProjectGroupMember) => {
2062 if (isMobile) {
2063 setOpenMobile(false);
2064 }
2065 void (async () => {
2066 // No options: branch, worktree, and env mode come from the user's
2067 // configured defaults, never from the currently viewed thread.
2068 const result = await settlePromise(() =>
2069 handleNewThread(scopeProjectRef(member.environmentId, member.id)),
2070 );
2071 if (result._tag === "Failure") {
2072 const error = squashAtomCommandFailure(result);
2073 toastManager.add(
2074 stackedThreadToast({
2075 type: "error",
2076 title: "Could not create thread",
2077 description: error instanceof Error ? error.message : "An error occurred.",
2078 }),
2079 );
2080 }
2081 })();
2082 },
2083 [handleNewThread, isMobile, setOpenMobile],
2084 );
2085
2086 const handleCreateThreadClick = useCallback(
2087 (event: React.MouseEvent<HTMLButtonElement>) => {
2088 event.preventDefault();
2089 event.stopPropagation();
2090
2091 if (project.memberProjects.length === 1) {
2092 createThreadForProjectMember(project.memberProjects[0]!);
2093 return;
2094 }
2095
2096 void (async () => {
2097 const api = readLocalApi();
2098 if (!api) {
2099 return;
2100 }
2101 const clickedResult = await settlePromise(() =>
2102 api.contextMenu.show(
2103 project.memberProjects.map((member) => ({
2104 id: member.physicalProjectKey,
2105 label: formatProjectMemberActionLabel(member, project.groupedProjectCount),
2106 })),
2107 {
2108 x: event.clientX,
2109 y: event.clientY,
2110 },
2111 ),
2112 );
2113 if (clickedResult._tag === "Failure") {
2114 const error = squashAtomCommandFailure(clickedResult);
2115 toastManager.add(
2116 stackedThreadToast({
2117 type: "error",
2118 title: "Could not choose environment",
2119 description: error instanceof Error ? error.message : "An error occurred.",
2120 }),
2121 );
2122 return;
2123 }
2124 const clicked = clickedResult.value;
2125 if (!clicked) {
2126 return;
2127 }
2128 const targetMember = project.memberProjects.find(
2129 (member) => member.physicalProjectKey === clicked,
2130 );
2131 if (!targetMember) {
2132 return;
2133 }
2134 createThreadForProjectMember(targetMember);
2135 })();
2136 },
2137 [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects],
2138 );
2139
2140 const attemptArchiveThread = useCallback(
2141 async (threadRef: ScopedThreadRef) => {
2142 if (!checkTaskPermission(threadRef.environmentId)) return;
2143 const result = await archiveThread(threadRef);
2144 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2145 const error = squashAtomCommandFailure(result);
2146 toastManager.add(
2147 stackedThreadToast({
2148 type: "error",
2149 title: "Failed to archive thread",
2150 description: error instanceof Error ? error.message : "An error occurred.",
2151 }),
2152 );
2153 }
2154 },
2155 [archiveThread],
2156 );
2157
2158 const cancelRename = useCallback(() => {
2159 setRenamingThreadKey(null);
2160 renamingInputRef.current = null;
2161 }, []);
2162
2163 const startThreadRename = useCallback((threadKey: string, title: string) => {
2164 const threadRef = parseScopedThreadKey(threadKey);
2165 if (!threadRef || !checkTaskPermission(threadRef.environmentId)) return;
2166 setRenamingThreadKey(threadKey);
2167 setRenamingTitle(title);
2168 renamingCommittedRef.current = false;
2169 }, []);
2170
2171 const commitRename = useCallback(
2172 async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => {
2173 const threadKey = scopedThreadKey(threadRef);
2174 const finishRename = () => {
2175 setRenamingThreadKey((current) => {
2176 if (current !== threadKey) return current;
2177 renamingInputRef.current = null;
2178 return null;
2179 });
2180 };
2181
2182 const trimmed = newTitle.trim();
2183 if (trimmed.length === 0) {
2184 toastManager.add({
2185 type: "warning",
2186 title: "Thread title cannot be empty",
2187 });
2188 finishRename();
2189 return;
2190 }
2191 if (trimmed === originalTitle) {
2192 finishRename();
2193 return;
2194 }
2195 const result = await updateThreadMetadata({
2196 environmentId: threadRef.environmentId,
2197 input: {
2198 threadId: threadRef.threadId,
2199 title: trimmed,
2200 },
2201 });
2202 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2203 const error = squashAtomCommandFailure(result);
2204 toastManager.add(
2205 stackedThreadToast({
2206 type: "error",
2207 title: "Failed to rename thread",
2208 description: error instanceof Error ? error.message : "An error occurred.",
2209 }),
2210 );
2211 }
2212 finishRename();
2213 },
2214 [updateThreadMetadata],
2215 );
2216
2217 const closeProjectRenameDialog = useCallback(() => {
2218 setProjectRenameTarget(null);
2219 setProjectRenameTitle("");
2220 }, []);
2221
2222 const submitProjectRename = useCallback(async () => {
2223 if (!projectRenameTarget) {
2224 return;
2225 }
2226
2227 const trimmed = projectRenameTitle.trim();
2228 if (trimmed.length === 0) {
2229 toastManager.add({
2230 type: "warning",
2231 title: "Project title cannot be empty",
2232 });
2233 return;
2234 }
2235
2236 if (trimmed === projectRenameTarget.title) {
2237 closeProjectRenameDialog();
2238 return;
2239 }
2240
2241 const result = await updateProject({
2242 environmentId: projectRenameTarget.environmentId,
2243 input: {
2244 projectId: projectRenameTarget.id,
2245 title: trimmed,
2246 },
2247 });
2248 if (result._tag === "Success") {
2249 closeProjectRenameDialog();
2250 } else if (!isAtomCommandInterrupted(result)) {
2251 const error = squashAtomCommandFailure(result);
2252 toastManager.add(
2253 stackedThreadToast({
2254 type: "error",
2255 title: "Failed to rename project",
2256 description: error instanceof Error ? error.message : "An error occurred.",
2257 }),
2258 );
2259 }
2260 }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]);
2261
2262 const closeProjectGroupingDialog = useCallback(() => {
2263 setProjectGroupingTarget(null);
2264 setProjectGroupingSelection("inherit");
2265 }, []);
2266
2267 const saveProjectGroupingPreference = useCallback(() => {
2268 if (!projectGroupingTarget) {
2269 return;
2270 }
2271
2272 const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget);
2273 const nextOverrides = {
2274 ...projectGroupingSettings.sidebarProjectGroupingOverrides,
2275 };
2276 if (projectGroupingSelection === "inherit") {
2277 delete nextOverrides[overrideKey];
2278 } else {
2279 nextOverrides[overrideKey] = projectGroupingSelection;
2280 }
2281 updateSettings({
2282 sidebarProjectGroupingOverrides: nextOverrides,
2283 });
2284 closeProjectGroupingDialog();
2285 }, [
2286 closeProjectGroupingDialog,
2287 projectGroupingSelection,
2288 projectGroupingSettings.sidebarProjectGroupingOverrides,
2289 projectGroupingTarget,
2290 updateSettings,
2291 ]);
2292
2293 const handleThreadContextMenu = useCallback(
2294 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
2295 const api = readLocalApi();
2296 if (!api) return;
2297 const threadKey = scopedThreadKey(threadRef);
2298 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
2299 if (!thread) return;
2300 const threadProject = memberProjectByScopedKey.get(
2301 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
2302 );
2303 const threadWorkspacePath =
2304 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2305 const canOperateThread = readEnvironmentScope(
2306 thread.environmentId,
2307 AuthOrchestrationOperateScope,
2308 );
2309 const clicked = await api.contextMenu.show(
2310 [
2311 ...(thread.branch
2312 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
2313 : []),
2314 { id: "rename", label: "Rename thread", disabled: !canOperateThread },
2315 { id: "mark-unread", label: "Mark unread" },
2316 { id: "copy-path", label: "Copy Path" },
2317 { id: "copy-thread-id", label: "Copy Thread ID" },
2318 { id: "project-settings", label: "Project settings" },
2319 {
2320 id: "delete",
2321 label: "Delete",
2322 destructive: true,
2323 icon: "trash",
2324 disabled: !canOperateThread,
2325 },
2326 ],
2327 position,
2328 );
2329
2330 if (clicked === "project-settings") {
2331 if (isMobile) setOpenMobile(false);
2332 void router.navigate({
2333 to: "/projects/$projectKey",
2334 params: { projectKey: project.projectKey },
2335 });
2336 return;
2337 }
2338
2339 if (clicked === "new-thread-on-branch") {
2340 // Explicit branch carry-over: reuse the thread's worktree when it
2341 // has one, otherwise its branch on the local checkout.
2342 const result = await settlePromise(() =>
2343 handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId), {
2344 branch: thread.branch,
2345 worktreePath: thread.worktreePath,
2346 envMode: thread.worktreePath ? "worktree" : "local",
2347 startFromOrigin: false,
2348 }),
2349 );
2350 if (result._tag === "Failure") {
2351 const error = squashAtomCommandFailure(result);
2352 toastManager.add(
2353 stackedThreadToast({
2354 type: "error",
2355 title: "Could not create thread",
2356 description: error instanceof Error ? error.message : "An error occurred.",
2357 }),
2358 );
2359 }
2360 return;
2361 }
2362
2363 if (clicked === "rename") {
2364 startThreadRename(threadKey, thread.title);
2365 return;
2366 }
2367
2368 if (clicked === "mark-unread") {
2369 markThreadUnread(threadRef);
2370 return;
2371 }
2372 if (clicked === "copy-path") {
2373 if (!threadWorkspacePath) {
2374 toastManager.add(
2375 stackedThreadToast({
2376 type: "error",
2377 title: "Path unavailable",
2378 description: "This thread does not have a workspace path to copy.",
2379 }),
2380 );
2381 return;
2382 }
2383 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2384 return;
2385 }
2386 if (clicked === "copy-thread-id") {
2387 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
2388 return;
2389 }
2390 if (clicked !== "delete") return;
2391 if (!checkTaskPermission(threadRef.environmentId)) return;
2392 if (appSettingsConfirmThreadDelete) {
2393 const confirmed = await api.dialogs.confirm(
2394 [
2395 `Delete thread "${thread.title}"?`,
2396 "This permanently clears conversation history for this thread.",
2397 ].join("\n"),
2398 { variant: "destructive" },
2399 );
2400 if (!confirmed) {
2401 return;
2402 }
2403 }
2404 if (!checkTaskPermission(threadRef.environmentId)) return;
2405 const result = await deleteThread(threadRef);
2406 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2407 const error = squashAtomCommandFailure(result);
2408 toastManager.add(
2409 stackedThreadToast({
2410 type: "error",
2411 title: "Failed to delete thread",
2412 description: error instanceof Error ? error.message : "An error occurred.",
2413 }),
2414 );
2415 }
2416 },
2417 [
2418 appSettingsConfirmThreadDelete,
2419 copyPathToClipboard,
2420 copyThreadIdToClipboard,
2421 deleteThread,
2422 handleNewThread,
2423 isMobile,
2424 markThreadUnread,
2425 memberProjectByScopedKey,
2426 project.projectKey,
2427 project.workspaceRoot,
2428 router,
2429 setOpenMobile,
2430 startThreadRename,
2431 ],
2432 );
2433
2434 return (
2435 <>
2436 <div className="group/project-header relative">
2437 <SidebarMenuButton
2438 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
2439 className={isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : undefined}
2440 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
2441 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
2442 onPointerDownCapture={handleProjectButtonPointerDownCapture}
2443 onClick={handleProjectButtonClick}
2444 onKeyDown={handleProjectButtonKeyDown}
2445 onContextMenu={handleProjectButtonContextMenu}
2446 >
2447 {!projectExpanded && projectStatus ? (
2448 <Tooltip>
2449 <TooltipTrigger
2450 render={
2451 <span
2452 aria-label={projectStatus.label}
2453 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
2454 />
2455 }
2456 >
2457 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
2458 <span
2459 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
2460 projectStatus.pulse ? "animate-status-pulse" : ""
2461 }`}
2462 />
2463 </span>
2464 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-icon-muted opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
2465 </TooltipTrigger>
2466 <TooltipPopup side="top">{projectStatus.label}</TooltipPopup>
2467 </Tooltip>
2468 ) : (
2469 <ChevronRightIcon
2470 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
2471 projectExpanded ? "rotate-90" : ""
2472 }`}
2473 />
2474 )}
2475 <span className="flex shrink-0">
2476 <ProjectFavicon project={project} />
2477 </span>
2478 <span className="flex min-w-0 flex-1 items-center gap-2">
2479 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2480 {project.displayName}
2481 </span>
2482 {project.groupedProjectCount > 1 ? (
2483 <span className="shrink-0 text-secondary-label text-3xs">
2484 {project.groupedProjectCount} projects
2485 </span>
2486 ) : null}
2487 </span>
2488 {/* Keeps the name clear of the environment badge and new-thread button overlaid on
2489 the row's end (two slots on touch, where both stay visible). */}
2490 <span aria-hidden className="w-4 shrink-0 max-sm:w-10" />
2491 </SidebarMenuButton>
2492 {/* Environment badge – visible by default, crossfades with the
2493 "new thread" button on hover using the same pointer-events +
2494 opacity pattern as the thread row archive/timestamp swap. */}
2495 {project.environmentPresence === "remote-only" && (
2496 <Tooltip>
2497 <TooltipTrigger
2498 render={
2499 <span
2500 aria-label={
2501 project.allRemoteMembersAreDesktopLocal
2502 ? "Local sandbox project"
2503 : "Remote project"
2504 }
2505 className="pointer-events-none absolute top-1/2 right-1.5 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100"
2506 />
2507 }
2508 >
2509 <EnvironmentMachineIcon kind={environmentMachine} className="size-3" />
2510 </TooltipTrigger>
2511 <TooltipPopup side="top">
2512 {project.allRemoteMembersAreDesktopLocal
2513 ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}`
2514 : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`}
2515 </TooltipPopup>
2516 </Tooltip>
2517 )}
2518 <Tooltip>
2519 <TooltipTrigger
2520 render={
2521 <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
2522 <button
2523 type="button"
2524 aria-label={`Create new thread in ${project.displayName}`}
2525 data-testid="new-thread-button"
2526 className={SIDEBAR_ICON_ACTION_BUTTON_CLASS}
2527 onClick={handleCreateThreadClick}
2528 >
2529 <SquarePenIcon className="size-3.5" />
2530 </button>
2531 </div>
2532 }
2533 />
2534 <TooltipPopup side="top">
2535 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
2536 </TooltipPopup>
2537 </Tooltip>
2538 </div>
2539
2540 <SidebarProjectThreadList
2541 projectKey={project.projectKey}
2542 projectExpanded={projectExpanded}
2543 hasOverflowingThreads={hasOverflowingThreads}
2544 hiddenThreadStatus={hiddenThreadStatus}
2545 orderedProjectThreadKeys={orderedProjectThreadKeys}
2546 renderedThreads={renderedThreads}
2547 showEmptyThreadState={showEmptyThreadState}
2548 shouldShowThreadPanel={shouldShowThreadPanel}
2549 isThreadListExpanded={isThreadListExpanded}
2550 activeRouteThreadKey={activeRouteThreadKey}
2551 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
2552 threadJumpLabelByKey={threadJumpLabelByKey}
2553 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
2554 renamingThreadKey={renamingThreadKey}
2555 renamingTitle={renamingTitle}
2556 setRenamingTitle={setRenamingTitle}
2557 startThreadRename={startThreadRename}
2558 renamingInputRef={renamingInputRef}
2559 renamingCommittedRef={renamingCommittedRef}
2560 confirmingArchiveThreadKey={confirmingArchiveThreadKey}
2561 setConfirmingArchiveThreadKey={setConfirmingArchiveThreadKey}
2562 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
2563 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
2564 handleThreadClick={handleThreadClick}
2565 navigateToThread={navigateToThread}
2566 onFileDropThreads={handleThreadFileDrop}
2567 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
2568 handleThreadContextMenu={handleThreadContextMenu}
2569 clearSelection={clearSelection}
2570 commitRename={commitRename}
2571 cancelRename={cancelRename}
2572 attemptArchiveThread={attemptArchiveThread}
2573 openPrLink={openPrLink}
2574 expandThreadListForProject={expandThreadListForProject}
2575 collapseThreadListForProject={collapseThreadListForProject}
2576 />
2577
2578 <Dialog
2579 open={projectRenameTarget !== null}
2580 onOpenChange={(open) => {
2581 if (!open) {
2582 closeProjectRenameDialog();
2583 }
2584 }}
2585 >
2586 <DialogPopup className="max-w-lg">
2587 <DialogHeader>
2588 <DialogTitle>Rename project</DialogTitle>
2589 <DialogDescription>
2590 {projectRenameTarget
2591 ? `Update the title for ${projectRenameTarget.workspaceRoot}.`
2592 : "Update the project title."}
2593 </DialogDescription>
2594 </DialogHeader>
2595 <DialogPanel>
2596 <div className="grid gap-1.5">
2597 <span className="text-xs font-medium text-foreground">Project title</span>
2598 <Input
2599 aria-label="Project title"
2600 disabled={!canRenameProject}
2601 value={projectRenameTitle}
2602 onChange={(event) => setProjectRenameTitle(event.target.value)}
2603 onKeyDown={(event) => {
2604 if (event.key === "Enter") {
2605 event.preventDefault();
2606 void submitProjectRename();
2607 }
2608 }}
2609 />
2610 </div>
2611 {projectRenameTarget?.environmentLabel ? (
2612 <p className="text-xs text-muted-foreground">
2613 Environment: {projectRenameTarget.environmentLabel}
2614 </p>
2615 ) : null}
2616 {!canRenameProject ? (
2617 <p className="text-xs text-muted-foreground">
2618 This connection cannot change projects.
2619 </p>
2620 ) : null}
2621 </DialogPanel>
2622 <DialogFooter>
2623 <Button variant="outline" onClick={closeProjectRenameDialog}>
2624 Cancel
2625 </Button>
2626 <Button disabled={!canRenameProject} onClick={() => void submitProjectRename()}>
2627 Save
2628 </Button>
2629 </DialogFooter>
2630 </DialogPopup>
2631 </Dialog>
2632
2633 <Dialog
2634 open={projectGroupingTarget !== null}
2635 onOpenChange={(open) => {
2636 if (!open) {
2637 closeProjectGroupingDialog();
2638 }
2639 }}
2640 >
2641 <DialogPopup className="max-w-lg">
2642 <DialogHeader>
2643 <DialogTitle>Project grouping</DialogTitle>
2644 <DialogDescription>
2645 {projectGroupingTarget
2646 ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.`
2647 : "Choose how this project should be grouped in the sidebar."}
2648 </DialogDescription>
2649 </DialogHeader>
2650 <DialogPanel>
2651 <div className="grid gap-1.5">
2652 <span className="text-xs font-medium text-foreground">Grouping rule</span>
2653 <Select
2654 value={projectGroupingSelection}
2655 onValueChange={(value) => {
2656 if (
2657 value === "inherit" ||
2658 value === "repository" ||
2659 value === "repository_path" ||
2660 value === "separate"
2661 ) {
2662 setProjectGroupingSelection(value);
2663 }
2664 }}
2665 >
2666 <SelectTrigger className="w-full" aria-label="Project grouping rule">
2667 <SelectValue>
2668 {projectGroupingSelection === "inherit"
2669 ? `Use global default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
2670 : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]}
2671 </SelectValue>
2672 </SelectTrigger>
2673 <SelectPopup align="end" alignItemWithTrigger={false}>
2674 <SelectItem hideIndicator value="inherit">
2675 Use global default
2676 </SelectItem>
2677 <SelectItem hideIndicator value="repository">
2678 {PROJECT_GROUPING_MODE_LABELS.repository}
2679 </SelectItem>
2680 <SelectItem hideIndicator value="repository_path">
2681 {PROJECT_GROUPING_MODE_LABELS.repository_path}
2682 </SelectItem>
2683 <SelectItem hideIndicator value="separate">
2684 {PROJECT_GROUPING_MODE_LABELS.separate}
2685 </SelectItem>
2686 </SelectPopup>
2687 </Select>
2688 </div>
2689 <p className="text-xs text-muted-foreground">
2690 {projectGroupingSelection === "inherit"
2691 ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode)
2692 : projectGroupingModeDescription(projectGroupingSelection)}
2693 </p>
2694 </DialogPanel>
2695 <DialogFooter>
2696 <Button variant="outline" onClick={closeProjectGroupingDialog}>
2697 Cancel
2698 </Button>
2699 <Button onClick={saveProjectGroupingPreference}>Save</Button>
2700 </DialogFooter>
2701 </DialogPopup>
2702 </Dialog>
2703 </>
2704 );
2705});
2706
2707const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) {
2708 return (
2709 <SidebarMenuItem>
2710 <SidebarProjectItem {...props} />
2711 </SidebarMenuItem>
2712 );
2713});
2714
2715function LocalSecondaryStatus() {
2716 const { environments } = useEnvironments();
2717 // The desktop reports which local secondary backends (e.g. the WSL backend)
2718 // exist; the hook polls because the bridge has no change event. A backend that
2719 // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we
2720 // surface "Connecting" straight from the bootstrap list and clear it once the
2721 // matching environment reports a connected phase.
2722 const secondaries = useDesktopLocalBootstraps();
2723
2724 // Connected desktop-local environments keyed by their backend URL so we can
2725 // match a bootstrap (which only knows the URL) to its connection phase.
2726 const localEnvByUrl = useMemo(() => {
2727 const map = new Map<string, { phase: string; error: string | null }>();
2728 for (const environment of environments) {
2729 if (
2730 isDesktopLocalConnectionTarget(environment.entry.target) &&
2731 environment.displayUrl !== null
2732 ) {
2733 map.set(environment.displayUrl, {
2734 phase: environment.connection.phase,
2735 error: environment.connection.error,
2736 });
2737 }
2738 }
2739 return map;
2740 }, [environments]);
2741
2742 const connecting: string[] = [];
2743 const failed: Array<{ label: string; error: string | null }> = [];
2744 for (const bootstrap of secondaries) {
2745 const env =
2746 bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined;
2747 if (env?.phase === "connected") {
2748 continue;
2749 }
2750 if (env?.phase === "error") {
2751 failed.push({ label: bootstrap.label, error: env.error });
2752 continue;
2753 }
2754 connecting.push(bootstrap.label);
2755 }
2756
2757 if (connecting.length === 0 && failed.length === 0) {
2758 return null;
2759 }
2760
2761 return (
2762 <SidebarGroup>
2763 {connecting.length > 0 ? (
2764 <Alert variant="sidebar">
2765 <Spinner />
2766 <AlertTitle>Connecting {connecting.join(", ")}</AlertTitle>
2767 </Alert>
2768 ) : null}
2769 {failed.length > 0 ? (
2770 <Alert variant="warning">
2771 <TriangleAlertIcon />
2772 <AlertTitle>Couldn't connect {failed.map((entry) => entry.label).join(", ")}</AlertTitle>
2773 <AlertDescription>
2774 {failed
2775 .map((entry) => entry.error)
2776 .filter(Boolean)
2777 .join("; ") || "The backend didn't respond."}
2778 </AlertDescription>
2779 </Alert>
2780 ) : null}
2781 </SidebarGroup>
2782 );
2783}
2784
2785type SortableProjectHandleProps = Pick<
2786 ReturnType<typeof useSortable>,
2787 "attributes" | "listeners" | "setActivatorNodeRef"
2788>;
2789
2790function ProjectSortMenu({
2791 projectSortOrder,
2792 threadSortOrder,
2793 threadPreviewCount,
2794 onProjectSortOrderChange,
2795 onThreadSortOrderChange,
2796 onThreadPreviewCountChange,
2797}: {
2798 projectSortOrder: SidebarProjectSortOrder;
2799 threadSortOrder: SidebarThreadSortOrder;
2800 threadPreviewCount: SidebarThreadPreviewCount;
2801 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
2802 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2803 onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void;
2804}) {
2805 const handleThreadPreviewCountChange = useCallback(
2806 (nextValue: number | null) => {
2807 if (nextValue === null) {
2808 return;
2809 }
2810
2811 const clampedValue = clampSidebarThreadPreviewCount(nextValue);
2812 if (clampedValue !== threadPreviewCount) {
2813 onThreadPreviewCountChange(clampedValue);
2814 }
2815 },
2816 [onThreadPreviewCountChange, threadPreviewCount],
2817 );
2818
2819 return (
2820 <Menu>
2821 <Tooltip>
2822 <TooltipTrigger
2823 render={
2824 <MenuTrigger
2825 render={<Button size="icon-xs" variant="ghost-muted" aria-label="Sidebar options" />}
2826 />
2827 }
2828 >
2829 <ArrowUpDownIcon className="size-3.5" />
2830 </TooltipTrigger>
2831 <TooltipPopup side="right">Sidebar options</TooltipPopup>
2832 </Tooltip>
2833 <MenuPopup align="end" side="bottom">
2834 <MenuGroup>
2835 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
2836 Sort projects
2837 </div>
2838 <MenuRadioGroup
2839 value={projectSortOrder}
2840 onValueChange={(value) => {
2841 onProjectSortOrderChange(value as SidebarProjectSortOrder);
2842 }}
2843 >
2844 {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map(
2845 ([value, label]) => (
2846 <MenuRadioItem key={value} value={value}>
2847 {label}
2848 </MenuRadioItem>
2849 ),
2850 )}
2851 </MenuRadioGroup>
2852 </MenuGroup>
2853 <MenuGroup>
2854 <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground">
2855 Sort threads
2856 </div>
2857 <MenuRadioGroup
2858 value={threadSortOrder}
2859 onValueChange={(value) => {
2860 onThreadSortOrderChange(value as SidebarThreadSortOrder);
2861 }}
2862 >
2863 {(
2864 Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]>
2865 ).map(([value, label]) => (
2866 <MenuRadioItem key={value} value={value}>
2867 {label}
2868 </MenuRadioItem>
2869 ))}
2870 </MenuRadioGroup>
2871 </MenuGroup>
2872 <MenuGroup>
2873 <div className="px-2 pt-2 pb-1 text-muted-foreground sm:text-xs font-medium">
2874 Visible threads
2875 </div>
2876 <div className="px-2 py-1">
2877 <NumberField
2878 aria-label="Visible thread count"
2879 className="w-28"
2880 max={MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
2881 min={MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
2882 onValueChange={handleThreadPreviewCountChange}
2883 size="sm"
2884 step={1}
2885 value={threadPreviewCount}
2886 >
2887 <NumberFieldGroup>
2888 <NumberFieldDecrement
2889 aria-label="Decrease visible thread count"
2890 className="[&_svg]:size-3.5"
2891 />
2892 <NumberFieldInput
2893 aria-label="Visible thread count"
2894 inputMode="numeric"
2895 onKeyDownCapture={(event) => {
2896 event.stopPropagation();
2897 }}
2898 />
2899 <NumberFieldIncrement
2900 aria-label="Increase visible thread count"
2901 className="[&_svg]:size-3.5"
2902 />
2903 </NumberFieldGroup>
2904 </NumberField>
2905 </div>
2906 </MenuGroup>
2907 </MenuPopup>
2908 </Menu>
2909 );
2910}
2911
2912function SortableProjectItem({
2913 projectId,
2914 disabled = false,
2915 children,
2916}: {
2917 projectId: string;
2918 disabled?: boolean;
2919 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
2920}) {
2921 const {
2922 attributes,
2923 listeners,
2924 setActivatorNodeRef,
2925 setNodeRef,
2926 transform,
2927 transition,
2928 isDragging,
2929 isOver,
2930 } = useSortable({ id: projectId, disabled });
2931 return (
2932 <li
2933 ref={setNodeRef}
2934 style={{
2935 transform: CSS.Translate.toString(transform),
2936 transition,
2937 }}
2938 className={`group/menu-item relative rounded-md ${
2939 isDragging ? "z-20 opacity-80" : ""
2940 } ${isOver && !isDragging ? "ring-1 ring-inset ring-primary/40" : ""}`}
2941 data-sidebar="menu-item"
2942 data-slot="sidebar-menu-item"
2943 >
2944 {children({ attributes, listeners, setActivatorNodeRef })}
2945 </li>
2946 );
2947}
2948
2949interface SidebarProjectsContentProps {
2950 showArm64IntelBuildWarning: boolean;
2951 arm64IntelBuildWarningDescription: string | null;
2952 desktopUpdateButtonAction: "download" | "install" | "none";
2953 desktopUpdateButtonDisabled: boolean;
2954 desktopUpdateActionPending: boolean;
2955 handleDesktopUpdateButtonClick: () => void;
2956 projectSortOrder: SidebarProjectSortOrder;
2957 threadSortOrder: SidebarThreadSortOrder;
2958 threadPreviewCount: SidebarThreadPreviewCount;
2959 updateSettings: ReturnType<typeof useUpdateClientSettings>;
2960 openAddProject: () => void;
2961 isManualProjectSorting: boolean;
2962 projectDnDSensors: ReturnType<typeof useSensors>;
2963 projectCollisionDetection: CollisionDetection;
2964 handleProjectDragStart: (event: DragStartEvent) => void;
2965 handleProjectDragEnd: (event: DragEndEvent) => void;
2966 handleProjectDragCancel: (event: DragCancelEvent) => void;
2967 handleNewThread: ReturnType<typeof useNewThreadHandler>;
2968 archiveThread: ReturnType<typeof useThreadActions>["archiveThread"];
2969 deleteThread: ReturnType<typeof useThreadActions>["deleteThread"];
2970 markThreadUnread: ReturnType<typeof useThreadActions>["markThreadUnread"];
2971 sortedProjects: readonly SidebarProjectSnapshot[];
2972 expandedThreadListsByProject: ReadonlySet<string>;
2973 activeRouteProjectKey: string | null;
2974 routeThreadKey: string | null;
2975 openPullRequestsInRightPanel: boolean;
2976 newThreadShortcutLabel: string | null;
2977 commandPaletteShortcutLabel: string | null;
2978 threadJumpLabelByKey: ReadonlyMap<string, string>;
2979 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
2980 expandThreadListForProject: (projectKey: string) => void;
2981 collapseThreadListForProject: (projectKey: string) => void;
2982 dragInProgressRef: React.RefObject<boolean>;
2983 suppressProjectClickAfterDragRef: React.RefObject<boolean>;
2984 suppressProjectClickForContextMenuRef: React.RefObject<boolean>;
2985 attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
2986 projectsLength: number;
2987}
2988
2989const SidebarProjectsContent = memo(function SidebarProjectsContent(
2990 props: SidebarProjectsContentProps,
2991) {
2992 const {
2993 showArm64IntelBuildWarning,
2994 arm64IntelBuildWarningDescription,
2995 desktopUpdateButtonAction,
2996 desktopUpdateButtonDisabled,
2997 desktopUpdateActionPending,
2998 handleDesktopUpdateButtonClick,
2999 projectSortOrder,
3000 threadSortOrder,
3001 threadPreviewCount,
3002 updateSettings,
3003 openAddProject,
3004 isManualProjectSorting,
3005 projectDnDSensors,
3006 projectCollisionDetection,
3007 handleProjectDragStart,
3008 handleProjectDragEnd,
3009 handleProjectDragCancel,
3010 handleNewThread,
3011 archiveThread,
3012 deleteThread,
3013 markThreadUnread,
3014 sortedProjects,
3015 expandedThreadListsByProject,
3016 activeRouteProjectKey,
3017 routeThreadKey,
3018 openPullRequestsInRightPanel,
3019 newThreadShortcutLabel,
3020 commandPaletteShortcutLabel,
3021 threadJumpLabelByKey,
3022 attachThreadListAutoAnimateRef,
3023 expandThreadListForProject,
3024 collapseThreadListForProject,
3025 dragInProgressRef,
3026 suppressProjectClickAfterDragRef,
3027 suppressProjectClickForContextMenuRef,
3028 attachProjectListAutoAnimateRef,
3029 projectsLength,
3030 } = props;
3031
3032 const handleProjectSortOrderChange = useCallback(
3033 (sortOrder: SidebarProjectSortOrder) => {
3034 updateSettings({ sidebarProjectSortOrder: sortOrder });
3035 },
3036 [updateSettings],
3037 );
3038 const handleThreadSortOrderChange = useCallback(
3039 (sortOrder: SidebarThreadSortOrder) => {
3040 updateSettings({ sidebarThreadSortOrder: sortOrder });
3041 },
3042 [updateSettings],
3043 );
3044 const handleThreadPreviewCountChange = useCallback(
3045 (count: SidebarThreadPreviewCount) => {
3046 updateSettings({ sidebarThreadPreviewCount: count });
3047 },
3048 [updateSettings],
3049 );
3050
3051 return (
3052 <SidebarContent
3053 fixedHeader={
3054 // Lifted above the stage backdrop, whose fade bleeds below the
3055 // header and would otherwise paint across the search row's outline.
3056 <SidebarGroup className="z-[1]">
3057 <SidebarMenu>
3058 <SidebarMenuItem>
3059 <CommandDialogTrigger
3060 render={<SidebarMenuButton data-testid="command-palette-trigger" />}
3061 >
3062 <SearchIcon />
3063 <span className="flex-1 truncate">Search</span>
3064 {commandPaletteShortcutLabel ? <Kbd>{commandPaletteShortcutLabel}</Kbd> : null}
3065 </CommandDialogTrigger>
3066 </SidebarMenuItem>
3067 </SidebarMenu>
3068 </SidebarGroup>
3069 }
3070 >
3071 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
3072 <SidebarGroup>
3073 <Alert variant="warning">
3074 <TriangleAlertIcon />
3075 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
3076 <AlertDescription>{arm64IntelBuildWarningDescription}</AlertDescription>
3077 {desktopUpdateButtonAction !== "none" ? (
3078 <AlertAction>
3079 <Button
3080 size="xs"
3081 variant="outline"
3082 disabled={desktopUpdateButtonDisabled || desktopUpdateActionPending}
3083 onClick={handleDesktopUpdateButtonClick}
3084 >
3085 {desktopUpdateButtonAction === "download"
3086 ? "Download ARM build"
3087 : "Install ARM build"}
3088 </Button>
3089 </AlertAction>
3090 ) : null}
3091 </Alert>
3092 </SidebarGroup>
3093 ) : null}
3094 <LocalSecondaryStatus />
3095 <SidebarGroup>
3096 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
3097 <span className="text-xs font-medium text-sidebar-muted-foreground/80">Projects</span>
3098 <div className="flex items-center gap-1">
3099 <ProjectSortMenu
3100 projectSortOrder={projectSortOrder}
3101 threadSortOrder={threadSortOrder}
3102 threadPreviewCount={threadPreviewCount}
3103 onProjectSortOrderChange={handleProjectSortOrderChange}
3104 onThreadSortOrderChange={handleThreadSortOrderChange}
3105 onThreadPreviewCountChange={handleThreadPreviewCountChange}
3106 />
3107 <Tooltip>
3108 <TooltipTrigger
3109 render={
3110 <Button
3111 size="icon-xs"
3112 variant="ghost-muted"
3113 aria-label="Add project"
3114 data-testid="sidebar-add-project-trigger"
3115 onClick={openAddProject}
3116 />
3117 }
3118 >
3119 <FolderPlusIcon className="size-3.5" />
3120 </TooltipTrigger>
3121 <TooltipPopup side="right">Add project</TooltipPopup>
3122 </Tooltip>
3123 </div>
3124 </div>
3125
3126 {isManualProjectSorting ? (
3127 <DndContext
3128 sensors={projectDnDSensors}
3129 collisionDetection={projectCollisionDetection}
3130 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
3131 onDragStart={handleProjectDragStart}
3132 onDragEnd={handleProjectDragEnd}
3133 onDragCancel={handleProjectDragCancel}
3134 >
3135 <SidebarMenu>
3136 <SortableContext
3137 items={sortedProjects.map((project) => project.projectKey)}
3138 strategy={verticalListSortingStrategy}
3139 >
3140 {sortedProjects.map((project) => (
3141 <SortableProjectItem key={project.projectKey} projectId={project.projectKey}>
3142 {(dragHandleProps) => (
3143 <SidebarProjectItem
3144 project={project}
3145 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
3146 activeRouteThreadKey={
3147 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
3148 }
3149 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
3150 newThreadShortcutLabel={newThreadShortcutLabel}
3151 handleNewThread={handleNewThread}
3152 archiveThread={archiveThread}
3153 deleteThread={deleteThread}
3154 markThreadUnread={markThreadUnread}
3155 threadJumpLabelByKey={threadJumpLabelByKey}
3156 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
3157 expandThreadListForProject={expandThreadListForProject}
3158 collapseThreadListForProject={collapseThreadListForProject}
3159 dragInProgressRef={dragInProgressRef}
3160 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
3161 suppressProjectClickForContextMenuRef={
3162 suppressProjectClickForContextMenuRef
3163 }
3164 isManualProjectSorting={isManualProjectSorting}
3165 dragHandleProps={dragHandleProps}
3166 />
3167 )}
3168 </SortableProjectItem>
3169 ))}
3170 </SortableContext>
3171 </SidebarMenu>
3172 </DndContext>
3173 ) : (
3174 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
3175 {sortedProjects.map((project) => (
3176 <SidebarProjectListRow
3177 key={project.projectKey}
3178 project={project}
3179 isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)}
3180 activeRouteThreadKey={
3181 activeRouteProjectKey === project.projectKey ? routeThreadKey : null
3182 }
3183 openPullRequestsInRightPanel={openPullRequestsInRightPanel}
3184 newThreadShortcutLabel={newThreadShortcutLabel}
3185 handleNewThread={handleNewThread}
3186 archiveThread={archiveThread}
3187 deleteThread={deleteThread}
3188 markThreadUnread={markThreadUnread}
3189 threadJumpLabelByKey={threadJumpLabelByKey}
3190 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
3191 expandThreadListForProject={expandThreadListForProject}
3192 collapseThreadListForProject={collapseThreadListForProject}
3193 dragInProgressRef={dragInProgressRef}
3194 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
3195 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
3196 isManualProjectSorting={isManualProjectSorting}
3197 dragHandleProps={null}
3198 />
3199 ))}
3200 </SidebarMenu>
3201 )}
3202
3203 {projectsLength === 0 && (
3204 <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div>
3205 )}
3206 </SidebarGroup>
3207 </SidebarContent>
3208 );
3209});
3210
3211export default function LegacySidebar() {
3212 const projects = useProjects();
3213 const sidebarThreads = useThreadShells();
3214 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
3215 const projectOrder = useUiStateStore((store) => store.projectOrder);
3216 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
3217 const navigate = useNavigate();
3218 const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
3219 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
3220 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3221 const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount);
3222 const updateSettings = useUpdateClientSettings();
3223 const handleNewThread = useNewThreadHandler();
3224 const { archiveThread, deleteThread, markThreadUnread } = useThreadActions();
3225 const { isMobile, setOpenMobile } = useSidebar();
3226 const routeTarget = useParams({
3227 strict: false,
3228 select: (params) => resolveThreadRouteTarget(params),
3229 });
3230 const routeDraftThread = useComposerDraftStore((store) =>
3231 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
3232 );
3233 const routeThreadRef = useMemo(
3234 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
3235 [routeDraftThread, routeTarget],
3236 );
3237 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
3238 const routeTerminalOpen = useTerminalUiStateStore((state) =>
3239 routeThreadRef
3240 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
3241 : false,
3242 );
3243 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3244 const openAddProjectCommandPalette = useCallback(
3245 () => openCommandPalette({ open: "add-project" }),
3246 [],
3247 );
3248 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
3249 ReadonlySet<string>
3250 >(() => new Set());
3251 const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
3252 const dragInProgressRef = useRef(false);
3253 const suppressProjectClickAfterDragRef = useRef(false);
3254 const suppressProjectClickForContextMenuRef = useRef(false);
3255 const desktopUpdateState = useDesktopUpdateState();
3256 const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false);
3257 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
3258 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
3259 const platform = navigator.platform;
3260 const shortcutModifiers = useShortcutModifierState();
3261 const terminalFocused = useTerminalFocus();
3262 const { environments } = useEnvironments();
3263 const primaryEnvironmentId = usePrimaryEnvironmentId();
3264 const environmentLabelById = useMemo(
3265 () =>
3266 new Map(
3267 environments.map((environment) => [environment.environmentId, environment.label] as const),
3268 ),
3269 [environments],
3270 );
3271 const desktopLocalEnvironmentIds = useMemo(
3272 () =>
3273 new Set(
3274 environments
3275 .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target))
3276 .map((environment) => environment.environmentId),
3277 ),
3278 [environments],
3279 );
3280 const wslEnvironmentIds = useMemo(
3281 () =>
3282 new Set(
3283 environments
3284 .filter((environment) => isWslConnectionTarget(environment.entry.target))
3285 .map((environment) => environment.environmentId),
3286 ),
3287 [environments],
3288 );
3289 const orderedProjects = useMemo(() => {
3290 return orderItemsByPreferredIds({
3291 items: projects,
3292 preferredIds: projectOrder,
3293 getId: getProjectOrderKey,
3294 getPreferenceIds: (project) => [
3295 getProjectOrderKey(project),
3296 legacyProjectCwdPreferenceKey(project.workspaceRoot),
3297 ],
3298 });
3299 }, [projectOrder, projects]);
3300
3301 // Build a mapping from physical project key → logical project key for
3302 // cross-environment grouping. Projects that share a repositoryIdentity
3303 // canonicalKey are treated as one logical project in the sidebar.
3304 const physicalToLogicalKey = useMemo(() => {
3305 return buildPhysicalToLogicalProjectKeyMap({
3306 projects: orderedProjects,
3307 settings: projectGroupingSettings,
3308 primaryEnvironmentId,
3309 });
3310 }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
3311 const projectPhysicalKeyByScopedRef = useMemo(
3312 () =>
3313 new Map(
3314 orderedProjects.map((project) => [
3315 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
3316 derivePhysicalProjectKey(project),
3317 ]),
3318 ),
3319 [orderedProjects],
3320 );
3321
3322 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
3323 return buildSidebarProjectSnapshots({
3324 projects: orderedProjects,
3325 settings: projectGroupingSettings,
3326 primaryEnvironmentId,
3327 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
3328 isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId),
3329 isWslEnvironment: (environmentId) => wslEnvironmentIds.has(environmentId),
3330 });
3331 }, [
3332 environmentLabelById,
3333 desktopLocalEnvironmentIds,
3334 wslEnvironmentIds,
3335 orderedProjects,
3336 projectGroupingSettings,
3337 primaryEnvironmentId,
3338 ]);
3339
3340 const sidebarProjectByKey = useMemo(
3341 () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
3342 [sidebarProjects],
3343 );
3344 const sidebarThreadByKey = useMemo(
3345 () =>
3346 new Map(
3347 sidebarThreads.map(
3348 (thread) =>
3349 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
3350 ),
3351 ),
3352 [sidebarThreads],
3353 );
3354 // Resolve the active route's project key to a logical key so it matches the
3355 // sidebar's grouped project entries.
3356 const activeRouteProjectKey = useMemo(() => {
3357 if (!routeThreadKey) {
3358 return null;
3359 }
3360 const activeThread = sidebarThreadByKey.get(routeThreadKey);
3361 if (!activeThread) return null;
3362 const physicalKey =
3363 projectPhysicalKeyByScopedRef.get(
3364 scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
3365 ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId));
3366 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3367 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3368
3369 // Group threads by logical project key so all threads from grouped projects
3370 // are displayed together.
3371 const threadsByProjectKey = useMemo(() => {
3372 const next = new Map<string, SidebarThreadSummary[]>();
3373 for (const thread of sidebarThreads) {
3374 const physicalKey =
3375 projectPhysicalKeyByScopedRef.get(
3376 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3377 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3378 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
3379 const existing = next.get(logicalKey);
3380 if (existing) {
3381 existing.push(thread);
3382 } else {
3383 next.set(logicalKey, [thread]);
3384 }
3385 }
3386 return next;
3387 }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]);
3388 const getCurrentSidebarShortcutContext = useCallback(
3389 () => ({
3390 terminalFocus: isTerminalFocused(),
3391 terminalOpen: routeTerminalOpen,
3392 modelPickerOpen: isModelPickerOpen(),
3393 }),
3394 [routeTerminalOpen],
3395 );
3396 const newThreadShortcutLabelOptions = useMemo(
3397 () => ({
3398 platform,
3399 context: {
3400 terminalFocus: false,
3401 terminalOpen: false,
3402 },
3403 }),
3404 [platform],
3405 );
3406 const newThreadShortcutLabel =
3407 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
3408 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
3409
3410 const navigateToThread = useCallback(
3411 (threadRef: ScopedThreadRef) => {
3412 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
3413 clearSelection();
3414 }
3415 setSelectionAnchor(scopedThreadKey(threadRef));
3416 if (isMobile) {
3417 setOpenMobile(false);
3418 }
3419 void navigate({
3420 to: "/$environmentId/$threadId",
3421 params: buildThreadRouteParams(threadRef),
3422 });
3423 },
3424 [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor],
3425 );
3426
3427 const projectDnDSensors = useSensors(
3428 useSensor(PointerSensor, {
3429 activationConstraint: { distance: 6 },
3430 }),
3431 );
3432 const projectCollisionDetection = useCallback<CollisionDetection>((args) => {
3433 const pointerCollisions = pointerWithin(args);
3434 if (pointerCollisions.length > 0) {
3435 return pointerCollisions;
3436 }
3437
3438 return closestCorners(args);
3439 }, []);
3440
3441 const handleProjectDragEnd = useCallback(
3442 (event: DragEndEvent) => {
3443 if (sidebarProjectSortOrder !== "manual") {
3444 dragInProgressRef.current = false;
3445 return;
3446 }
3447 dragInProgressRef.current = false;
3448 const { active, over } = event;
3449 if (!over || active.id === over.id) return;
3450 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
3451 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
3452 if (!activeProject || !overProject) return;
3453 const activeMemberKeys = activeProject.memberProjects.map(
3454 (member) => member.physicalProjectKey,
3455 );
3456 const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey);
3457 reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys);
3458 },
3459 [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects],
3460 );
3461
3462 const handleProjectDragStart = useCallback(
3463 (_event: DragStartEvent) => {
3464 if (sidebarProjectSortOrder !== "manual") {
3465 return;
3466 }
3467 dragInProgressRef.current = true;
3468 suppressProjectClickAfterDragRef.current = true;
3469 },
3470 [sidebarProjectSortOrder],
3471 );
3472
3473 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
3474 dragInProgressRef.current = false;
3475 }, []);
3476
3477 const animatedProjectListsRef = useRef(new WeakSet<HTMLElement>());
3478 const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3479 if (!node || animatedProjectListsRef.current.has(node)) {
3480 return;
3481 }
3482 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3483 animatedProjectListsRef.current.add(node);
3484 }, []);
3485
3486 const animatedThreadListsRef = useRef(new WeakSet<HTMLElement>());
3487 const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => {
3488 if (!node || animatedThreadListsRef.current.has(node)) {
3489 return;
3490 }
3491 autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS);
3492 animatedThreadListsRef.current.add(node);
3493 }, []);
3494
3495 const visibleThreads = useMemo(
3496 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
3497 [sidebarThreads],
3498 );
3499 const sortedProjects = useMemo(() => {
3500 const sortableProjects = sidebarProjects.map((project) => ({
3501 ...project,
3502 id: project.projectKey,
3503 }));
3504 const sortableThreads = visibleThreads.map((thread) => {
3505 const physicalKey =
3506 projectPhysicalKeyByScopedRef.get(
3507 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
3508 ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
3509 return {
3510 ...thread,
3511 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
3512 };
3513 });
3514 return sortProjectsForSidebar(
3515 sortableProjects,
3516 sortableThreads,
3517 sidebarProjectSortOrder,
3518 ).flatMap((project) => {
3519 const resolvedProject = sidebarProjectByKey.get(project.id);
3520 return resolvedProject ? [resolvedProject] : [];
3521 });
3522 }, [
3523 sidebarProjectSortOrder,
3524 physicalToLogicalKey,
3525 projectPhysicalKeyByScopedRef,
3526 sidebarProjectByKey,
3527 sidebarProjects,
3528 visibleThreads,
3529 ]);
3530 const isManualProjectSorting = sidebarProjectSortOrder === "manual";
3531 const visibleSidebarThreadKeys = useMemo(
3532 () =>
3533 sortedProjects.flatMap((project) => {
3534 const projectThreads = sortThreads(
3535 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
3536 (thread) => thread.archivedAt === null,
3537 ),
3538 sidebarThreadSortOrder,
3539 );
3540 const projectExpanded = resolveProjectExpanded(
3541 projectExpandedById,
3542 projectExpansionPreferenceKeys(project),
3543 );
3544 const activeThreadKey = routeThreadKey ?? undefined;
3545 const pinnedCollapsedThread =
3546 !projectExpanded && activeThreadKey
3547 ? (projectThreads.find(
3548 (thread) =>
3549 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
3550 activeThreadKey,
3551 ) ?? null)
3552 : null;
3553 const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
3554 if (!shouldShowThreadPanel) {
3555 return [];
3556 }
3557 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
3558 const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
3559 const previewThreads =
3560 isThreadListExpanded || !hasOverflowingThreads
3561 ? projectThreads
3562 : projectThreads.slice(0, sidebarThreadPreviewCount);
3563 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
3564 return renderedThreads.map((thread) =>
3565 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3566 );
3567 }),
3568 [
3569 sidebarThreadSortOrder,
3570 sidebarThreadPreviewCount,
3571 expandedThreadListsByProject,
3572 projectExpandedById,
3573 routeThreadKey,
3574 sortedProjects,
3575 threadsByProjectKey,
3576 ],
3577 );
3578 const threadJumpCommandByKey = useMemo(() => {
3579 const mapping = new Map<string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
3580 for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
3581 const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
3582 if (!jumpCommand) {
3583 return mapping;
3584 }
3585 mapping.set(threadKey, jumpCommand);
3586 }
3587
3588 return mapping;
3589 }, [visibleSidebarThreadKeys]);
3590 const threadJumpThreadKeys = useMemo(
3591 () => [...threadJumpCommandByKey.keys()],
3592 [threadJumpCommandByKey],
3593 );
3594 const sidebarShortcutContext = {
3595 terminalFocus: terminalFocused,
3596 terminalOpen: routeTerminalOpen,
3597 modelPickerOpen: isModelPickerOpen(),
3598 };
3599 const threadJumpLabelByKey = useMemo(
3600 () =>
3601 buildThreadJumpLabelMap({
3602 keybindings,
3603 platform,
3604 terminalOpen: sidebarShortcutContext.terminalOpen,
3605 threadJumpCommandByKey,
3606 }),
3607 [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey],
3608 );
3609 const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
3610 shortcutModifiers,
3611 keybindings,
3612 {
3613 platform,
3614 context: sidebarShortcutContext,
3615 },
3616 );
3617 const visibleThreadJumpLabelByKey = showThreadJumpHints
3618 ? threadJumpLabelByKey
3619 : EMPTY_THREAD_JUMP_LABELS;
3620 const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
3621 const prewarmedSidebarThreadKeys = useMemo(
3622 () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys),
3623 [visibleSidebarThreadKeys],
3624 );
3625 const prewarmedSidebarThreadRefs = useMemo(
3626 () =>
3627 prewarmedSidebarThreadKeys.flatMap((threadKey) => {
3628 const ref = parseScopedThreadKey(threadKey);
3629 return ref ? [ref] : [];
3630 }),
3631 [prewarmedSidebarThreadKeys],
3632 );
3633
3634 useEffect(() => {
3635 updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow);
3636 }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]);
3637
3638 useEffect(() => {
3639 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
3640 const shortcutContext = getCurrentSidebarShortcutContext();
3641
3642 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
3643 return;
3644 }
3645
3646 const command = resolveShortcutCommand(event, keybindings, {
3647 platform,
3648 context: shortcutContext,
3649 });
3650 const traversalDirection = threadTraversalDirectionFromCommand(command);
3651 if (traversalDirection !== null) {
3652 const targetThreadKey = resolveAdjacentThreadId({
3653 threadIds: orderedSidebarThreadKeys,
3654 currentThreadId: routeThreadKey,
3655 direction: traversalDirection,
3656 });
3657 if (!targetThreadKey) {
3658 return;
3659 }
3660 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3661 if (!targetThread) {
3662 return;
3663 }
3664
3665 event.preventDefault();
3666 event.stopPropagation();
3667 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
3668 return;
3669 }
3670
3671 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
3672 if (jumpIndex === null) {
3673 return;
3674 }
3675
3676 const targetThreadKey = threadJumpThreadKeys[jumpIndex];
3677 if (!targetThreadKey) {
3678 return;
3679 }
3680 const targetThread = sidebarThreadByKey.get(targetThreadKey);
3681 if (!targetThread) {
3682 return;
3683 }
3684
3685 event.preventDefault();
3686 event.stopPropagation();
3687 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
3688 };
3689
3690 window.addEventListener("keydown", onWindowKeyDown);
3691
3692 return () => {
3693 window.removeEventListener("keydown", onWindowKeyDown);
3694 };
3695 }, [
3696 getCurrentSidebarShortcutContext,
3697 keybindings,
3698 navigateToThread,
3699 orderedSidebarThreadKeys,
3700 platform,
3701 routeThreadKey,
3702 sidebarThreadByKey,
3703 threadJumpThreadKeys,
3704 ]);
3705
3706 useEffect(() => {
3707 const onMouseDown = (event: globalThis.MouseEvent) => {
3708 if (!useThreadSelectionStore.getState().hasSelection()) return;
3709 const target = event.target instanceof HTMLElement ? event.target : null;
3710 if (!shouldClearThreadSelectionOnMouseDown(target)) return;
3711 clearSelection();
3712 };
3713
3714 window.addEventListener("mousedown", onMouseDown);
3715 return () => {
3716 window.removeEventListener("mousedown", onMouseDown);
3717 };
3718 }, [clearSelection]);
3719
3720 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
3721 const desktopUpdateButtonAction = desktopUpdateState
3722 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
3723 : "none";
3724 const showArm64IntelBuildWarning =
3725 isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState);
3726 const arm64IntelBuildWarningDescription =
3727 desktopUpdateState && showArm64IntelBuildWarning
3728 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
3729 : null;
3730 const commandPaletteShortcutLabel = isMobile
3731 ? null
3732 : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions);
3733 const handleDesktopUpdateButtonClick = useCallback(async () => {
3734 const bridge = window.desktopBridge;
3735 if (!bridge || !desktopUpdateState) return;
3736 if (
3737 desktopUpdateButtonDisabled ||
3738 desktopUpdateButtonAction === "none" ||
3739 desktopUpdateActionPending
3740 ) {
3741 return;
3742 }
3743
3744 setDesktopUpdateActionPending(true);
3745
3746 void performDesktopUpdate(bridge, desktopUpdateState).finally(() =>
3747 setDesktopUpdateActionPending(false),
3748 );
3749 }, [
3750 desktopUpdateActionPending,
3751 desktopUpdateButtonAction,
3752 desktopUpdateButtonDisabled,
3753 desktopUpdateState,
3754 ]);
3755
3756 const expandThreadListForProject = useCallback((projectKey: string) => {
3757 setExpandedThreadListsByProject((current) => {
3758 if (current.has(projectKey)) return current;
3759 const next = new Set(current);
3760 next.add(projectKey);
3761 return next;
3762 });
3763 }, []);
3764
3765 const collapseThreadListForProject = useCallback((projectKey: string) => {
3766 setExpandedThreadListsByProject((current) => {
3767 if (!current.has(projectKey)) return current;
3768 const next = new Set(current);
3769 next.delete(projectKey);
3770 return next;
3771 });
3772 }, []);
3773
3774 return (
3775 <>
3776 {prewarmedSidebarThreadRefs.map((threadRef) => (
3777 <SidebarThreadDetailPrewarmer key={scopedThreadKey(threadRef)} threadRef={threadRef} />
3778 ))}
3779 <SidebarChromeHeader isElectron={isElectron} />
3780
3781 <SidebarProjectsContent
3782 showArm64IntelBuildWarning={showArm64IntelBuildWarning}
3783 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
3784 desktopUpdateButtonAction={desktopUpdateButtonAction}
3785 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
3786 desktopUpdateActionPending={desktopUpdateActionPending}
3787 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
3788 projectSortOrder={sidebarProjectSortOrder}
3789 threadSortOrder={sidebarThreadSortOrder}
3790 threadPreviewCount={sidebarThreadPreviewCount}
3791 updateSettings={updateSettings}
3792 openAddProject={openAddProjectCommandPalette}
3793 isManualProjectSorting={isManualProjectSorting}
3794 projectDnDSensors={projectDnDSensors}
3795 projectCollisionDetection={projectCollisionDetection}
3796 handleProjectDragStart={handleProjectDragStart}
3797 handleProjectDragEnd={handleProjectDragEnd}
3798 handleProjectDragCancel={handleProjectDragCancel}
3799 handleNewThread={handleNewThread}
3800 archiveThread={archiveThread}
3801 deleteThread={deleteThread}
3802 markThreadUnread={markThreadUnread}
3803 sortedProjects={sortedProjects}
3804 expandedThreadListsByProject={expandedThreadListsByProject}
3805 activeRouteProjectKey={activeRouteProjectKey}
3806 routeThreadKey={routeThreadKey}
3807 openPullRequestsInRightPanel={routeThreadRef !== null}
3808 newThreadShortcutLabel={newThreadShortcutLabel}
3809 commandPaletteShortcutLabel={commandPaletteShortcutLabel}
3810 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
3811 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
3812 expandThreadListForProject={expandThreadListForProject}
3813 collapseThreadListForProject={collapseThreadListForProject}
3814 dragInProgressRef={dragInProgressRef}
3815 suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef}
3816 suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
3817 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
3818 projectsLength={projects.length}
3819 />
3820 <SidebarChromeFooter />
3821 </>
3822 );
3823}