import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, ContainerIcon, FolderPlusIcon, Globe2Icon, LoaderIcon, SearchIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon,} from "lucide-react";import { ChangeRequestStatusIcon, prStatusIndicator, PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator,} from "./ThreadStatusIndicators";import { ProjectFavicon } from "./ProjectFavicon";import { useAtomValue } from "@effect/atom-react";import { autoAnimate } from "@formkit/auto-animate";import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";import { useShallow } from "zustand/react/shallow";import { DndContext, type DragCancelEvent, type CollisionDetection, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, useSensor, useSensors, type DragEndEvent,} from "@dnd-kit/core";import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";import { CSS } from "@dnd-kit/utilities";import { type ContextMenuItem, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, ThreadId,} from "@t3tools/contracts";import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef,} from "@t3tools/client-runtime/environment";import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, type SidebarProjectSortOrder, type SidebarThreadPreviewCount, type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";import { isElectron } from "../env";import { useOpenPrLink } from "../lib/openPullRequestLink";import { isTerminalFocused } from "../lib/terminalFocus";import { isMacPlatform } from "../lib/utils";import { readThreadShell, useProject, useProjects, useThreadShells, useThreadShellsForProjectRefs,} from "../state/entities";import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";import { useThreadRunningTerminalIds } from "../state/terminalSessions";import { useThreadDiscoveredPorts } from "../portDiscoveryState";import { openDiscoveredPort } from "./preview/openDiscoveredPort";import { useAtomCommand } from "../state/use-atom-command";import { previewEnvironment } from "../state/preview";import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, useUiStateStore,} from "../uiStateStore";import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { isModelPickerOpen } from "../modelPickerVisibility";import { useShortcutModifierState } from "../shortcutModifierState";import { readLocalApi } from "../localApi";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "../hooks/useHandleNewThread";import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions";import { projectEnvironment } from "../state/projects";import { useEnvironmentQuery } from "../state/query";import { threadEnvironment, useEnvironmentThread } from "../state/threads";import { vcsEnvironment } from "../state/vcs";import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget,} from "../threadRoutes";import { stackedThreadToast, toastManager } from "./ui/toast";import { formatRelativeTimeLabel } from "../timestampFormat";import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";import { Kbd } from "./ui/kbd";import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult,} from "./desktopUpdate.logic";import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";import { Button } from "./ui/button";import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle,} from "./ui/dialog";import { Input } from "./ui/input";import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput,} from "./ui/number-field";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { SidebarContent, SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, useSidebar,} from "./ui/sidebar";import { useThreadSelectionStore } from "../threadSelectionStore";import { openCommandPalette } from "../commandPaletteBus";import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill,} from "./Sidebar.logic";import { sortThreads } from "../lib/threadSort";import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { useIsMobile } from "~/hooks/useMediaQuery";import { CommandDialogTrigger } from "./ui/command";import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";import { primaryServerKeybindingsAtom } from "../state/server";import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings,} from "../logicalProject";import type { SidebarThreadSummary } from "../types";import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { updated_at: "Last user message", created_at: "Created at", manual: "Manual",};const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = { updated_at: "Last user message", created_at: "Created at",};const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out",} as const;const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate",};const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); return null;} function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), ) as SidebarThreadPreviewCount;} function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number,): string { if (groupedProjectCount <= 1) { return member.title; } return member.environmentLabel ? `${member.environmentLabel} — ${member.workspaceRoot}` : member.workspaceRoot;} function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { return [ project.projectKey, ...project.memberProjects.map((member) => member.physicalProjectKey), ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), ];} function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { switch (mode) { case "repository": return "Projects from the same repository share one sidebar row."; case "repository_path": return "Projects group only when both the repository and repo-relative path match."; case "separate": return "Every project path gets its own sidebar row."; }} function buildThreadJumpLabelMap(input: { keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>> >;}): ReadonlyMap<string, string> { if (input.threadJumpCommandByKey.size === 0) { return EMPTY_THREAD_JUMP_LABELS; } const shortcutLabelOptions = { platform: input.platform, context: { terminalFocus: false, terminalOpen: input.terminalOpen, }, } as const; const mapping = new Map<string, string>(); for (const [threadKey, command] of input.threadJumpCommandByKey) { const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); if (label) { mapping.set(threadKey, label); } } return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;} interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject<HTMLInputElement | null>; renamingCommittedRef: React.RefObject<boolean>; confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; handleThreadClick: ( event: React.MouseEvent, threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise<void>; clearSelection: () => void; commitRename: ( threadRef: ScopedThreadRef, newTitle: string, originalTitle: string, ) => Promise<void>; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>; openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;} export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, handleThreadClick, navigateToThread, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, commitRename, cancelRename, attemptArchiveThread, openPrLink, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the // user's own machine, so the cloud icon is misleading — label it "Local" and // suppress the cloud icon (the project header already shows a container icon // for desktop-local projects, see sidebarProjectGrouping). const isDesktopLocalThread = environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. const threadProject = useProject( useMemo( () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useEnvironmentQuery( thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { const port = discoveredPorts[0]; if (!port) return; event.preventDefault(); event.stopPropagation(); navigateToThread(threadRef); void (async () => { const result = await openDiscoveredPort({ threadRef, port, openPreview }); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open preview", description: error instanceof Error ? error.message : "The preview could not be opened.", }), ); })(); }, [discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "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" : "pointer-events-none"; const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); }, [clearConfirmingArchive]); const handleBlurCapture = useCallback( (event: React.FocusEvent<HTMLLIElement>) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } clearConfirmingArchive(); }); }, [clearConfirmingArchive], ); const handleRowClick = useCallback( (event: React.MouseEvent) => { handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the // input) must not restart and discard the in-progress edit. if (renamingThreadKey === threadKey) return; // On mobile the first tap navigates and closes the sidebar sheet, so the // inline rename can't be shown. Renaming there stays on the context menu. if (isMobile) return; // cmd/ctrl/shift double-clicks are multi-select intent, not rename. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // Ignore double-clicks bubbling from nested controls (PR status, port, // archive buttons) — only the row body should enter inline rename.Lines 1–500 of 3643. Load more
import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, ContainerIcon, FolderPlusIcon, Globe2Icon, LoaderIcon, SearchIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { ChangeRequestStatusIcon, prStatusIndicator, PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, type DragCancelEvent, type CollisionDetection, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, type SidebarProjectSortOrder, type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, useProjects, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; import { previewEnvironment } from "../state/preview"; import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, useUiStateStore, } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, } from "./ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { updated_at: "Last user message", created_at: "Created at", manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = { updated_at: "Last user message", created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>(); const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); return null; } function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), ) as SidebarThreadPreviewCount; } function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { return member.title; } return member.environmentLabel ? `${member.environmentLabel} — ${member.workspaceRoot}` : member.workspaceRoot; } function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { return [ project.projectKey, ...project.memberProjects.map((member) => member.physicalProjectKey), ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), ]; } function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { switch (mode) { case "repository": return "Projects from the same repository share one sidebar row."; case "repository_path": return "Projects group only when both the repository and repo-relative path match."; case "separate": return "Every project path gets its own sidebar row."; } } function buildThreadJumpLabelMap(input: { keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< string, NonNullable<ReturnType<typeof threadJumpCommandForIndex>> >; }): ReadonlyMap<string, string> { if (input.threadJumpCommandByKey.size === 0) { return EMPTY_THREAD_JUMP_LABELS; } const shortcutLabelOptions = { platform: input.platform, context: { terminalFocus: false, terminalOpen: input.terminalOpen, }, } as const; const mapping = new Map<string, string>(); for (const [threadKey, command] of input.threadJumpCommandByKey) { const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); if (label) { mapping.set(threadKey, label); } } return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject<HTMLInputElement | null>; renamingCommittedRef: React.RefObject<boolean>; confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>; confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>; handleThreadClick: ( event: React.MouseEvent, threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise<void>; clearSelection: () => void; commitRename: ( threadRef: ScopedThreadRef, newTitle: string, originalTitle: string, ) => Promise<void>; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>; openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, handleThreadClick, navigateToThread, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, commitRename, cancelRename, attemptArchiveThread, openPrLink, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the // user's own machine, so the cloud icon is misleading — label it "Local" and // suppress the cloud icon (the project header already shows a container icon // for desktop-local projects, see sidebarProjectGrouping). const isDesktopLocalThread = environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. const threadProject = useProject( useMemo( () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useEnvironmentQuery( thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { const port = discoveredPorts[0]; if (!port) return; event.preventDefault(); event.stopPropagation(); navigateToThread(threadRef); void (async () => { const result = await openDiscoveredPort({ threadRef, port, openPreview }); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open preview", description: error instanceof Error ? error.message : "The preview could not be opened.", }), ); })(); }, [discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "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" : "pointer-events-none"; const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); }, [clearConfirmingArchive]); const handleBlurCapture = useCallback( (event: React.FocusEvent<HTMLLIElement>) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } clearConfirmingArchive(); }); }, [clearConfirmingArchive], ); const handleRowClick = useCallback( (event: React.MouseEvent) => { handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the // input) must not restart and discard the in-progress edit. if (renamingThreadKey === threadKey) return; // On mobile the first tap navigates and closes the sidebar sheet, so the // inline rename can't be shown. Renaming there stays on the context menu. if (isMobile) return; // cmd/ctrl/shift double-clicks are multi-select intent, not rename. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // Ignore double-clicks bubbling from nested controls (PR status, port, // archive buttons) — only the row body should enter inline rename.