import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, GitPullRequestIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon,} from "lucide-react";import { prStatusIndicator, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel,} from "./ThreadStatusIndicators";import { ProjectFavicon } from "./ProjectFavicon";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, type DesktopUpdateState, ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId,} from "@t3tools/contracts";import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef,} from "@t3tools/client-runtime";import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";import { type SidebarProjectSortOrder, type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";import { usePrimaryEnvironmentId } from "../environments/primary";import { isElectron } from "../env";import { APP_STAGE_LABEL, APP_VERSION } from "../branding";import { isTerminalFocused } from "../lib/terminalFocus";import { isMacPlatform, newCommandId } from "../lib/utils";import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore,} from "../store";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { useUiStateStore } from "../uiStateStore";import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { useModelPickerOpen } from "../modelPickerOpenState";import { useShortcutModifierState } from "../shortcutModifierState";import { useGitStatus } from "../lib/gitStatusState";import { readLocalApi } from "../localApi";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "../hooks/useHandleNewThread";import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions";import { buildThreadRouteParams, resolveThreadRouteRef, 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, MenuSeparator, MenuTrigger,} from "./ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger,} from "./ui/sidebar";import { useThreadSelectionStore } from "../threadSelectionStore";import { useCommandPaletteStore } from "../commandPaletteStore";import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill,} from "./Sidebar.logic";import { sortThreads } from "../lib/threadSort";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { CommandDialogTrigger } from "./ui/command";import { readEnvironmentApi } from "../environmentApi";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { useServerKeybindings } from "../rpc/serverState";import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey,} from "../logicalProject";import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore,} from "../environments/runtime";import type { SidebarThreadSummary } from "../types";import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";const THREAD_PREVIEW_LIMIT = 6;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",}; function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number,): string { if (groupedProjectCount <= 1) { return member.name; } return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd;} 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: ReturnType<typeof useServerKeybindings>; 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; 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;} const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, 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 hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = useSavedEnvironmentRuntimeStore( (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, ); const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( (s) => s.byId[thread.environmentId]?.label ?? null, ); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "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 threadProjectCwd = useStore( useMemo( () => (state: import("../store").AppState) => selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? null, [thread.environmentId, thread.projectId], ), ); const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); 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 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 handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); navigateToThread(threadRef); }, [navigateToThread, threadRef], ); const handleRowContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); if (hasSelection && isSelected) { void handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); return; } if (hasSelection) { clearSelection(); } void handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }); }, [ clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, hasSelection, isSelected, threadRef, ], ); const handlePrClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { if (!prStatus) return; openPrLink(event, prStatus.url); }, [openPrLink, prStatus], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { if (element && renamingInputRef.current !== element) { renamingInputRef.current = element; element.focus(); element.select(); } }, [renamingInputRef], ); const handleRenameInputChange = useCallback( (event: React.ChangeEvent<HTMLInputElement>) => { setRenamingTitle(event.target.value); }, [setRenamingTitle], ); const handleRenameInputKeyDown = useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); renamingCommittedRef.current = true; void commitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => { event.stopPropagation(); }, []); const handleConfirmArchiveRef = useCallback( (element: HTMLButtonElement | null) => { if (element) { confirmArchiveButtonRefs.current.set(threadKey, element); } else { confirmArchiveButtonRefs.current.delete(threadKey); } }, [confirmArchiveButtonRefs, threadKey], ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent<HTMLButtonElement>) => { event.stopPropagation(); }, [], ); const handleConfirmArchiveClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => {Lines 1–500 of 3362. Load more
import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, GitPullRequestIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { prStatusIndicator, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; 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, type DesktopUpdateState, ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; import { buildThreadRouteParams, resolveThreadRouteRef, 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, MenuSeparator, MenuTrigger, } from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; const THREAD_PREVIEW_LIMIT = 6; 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", }; function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { return member.name; } return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; } 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: ReturnType<typeof useServerKeybindings>; 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; 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; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, 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 hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = useSavedEnvironmentRuntimeStore( (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, ); const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( (s) => s.byId[thread.environmentId]?.label ?? null, ); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "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 threadProjectCwd = useStore( useMemo( () => (state: import("../store").AppState) => selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? null, [thread.environmentId, thread.projectId], ), ); const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); 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 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 handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); navigateToThread(threadRef); }, [navigateToThread, threadRef], ); const handleRowContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); if (hasSelection && isSelected) { void handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); return; } if (hasSelection) { clearSelection(); } void handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }); }, [ clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, hasSelection, isSelected, threadRef, ], ); const handlePrClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { if (!prStatus) return; openPrLink(event, prStatus.url); }, [openPrLink, prStatus], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { if (element && renamingInputRef.current !== element) { renamingInputRef.current = element; element.focus(); element.select(); } }, [renamingInputRef], ); const handleRenameInputChange = useCallback( (event: React.ChangeEvent<HTMLInputElement>) => { setRenamingTitle(event.target.value); }, [setRenamingTitle], ); const handleRenameInputKeyDown = useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); renamingCommittedRef.current = true; void commitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => { event.stopPropagation(); }, []); const handleConfirmArchiveRef = useCallback( (element: HTMLButtonElement | null) => { if (element) { confirmArchiveButtonRefs.current.set(threadKey, element); } else { confirmArchiveButtonRefs.current.delete(threadKey); } }, [confirmArchiveButtonRefs, threadKey], ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent<HTMLButtonElement>) => { event.stopPropagation(); }, [], ); const handleConfirmArchiveClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => {