import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, PlusIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon,} from "lucide-react";import { ProjectFavicon } from "./ProjectFavicon";import { autoAnimate } from "@formkit/auto-animate";import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type KeyboardEvent, type MouseEvent, type MutableRefObject, type PointerEvent, type ReactNode, type SetStateAction,} 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 { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, ThreadId, type GitStatusResult,} from "@t3tools/contracts";import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";import { type SidebarProjectSortOrder, type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";import { isElectron } from "../env";import { APP_STAGE_LABEL, APP_VERSION } from "../branding";import { isTerminalFocused } from "../lib/terminalFocus";import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";import { useStore } from "../store";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { useUiStateStore } from "../uiStateStore";import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { useGitStatus } from "../lib/gitStatusState";import { readNativeApi } from "../nativeApi";import { useComposerDraftStore } from "../composerDraftStore";import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions";import { toastManager } from "./ui/toast";import { formatRelativeTimeLabel } from "../timestampFormat";import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";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 { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger,} from "./ui/sidebar";import { useThreadSelectionStore } from "../threadSelectionStore";import { isNonEmpty as isNonEmptyString } from "effect/String";import { getVisibleSidebarThreadIds, getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility,} from "./Sidebar.logic";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { useServerKeybindings } from "../rpc/serverState";import { useSidebarThreadSummaryById } from "../storeSelectors";import type { Project } from "../types";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; type SidebarProjectSnapshot = Project & { expanded: boolean;};interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean;} interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; tooltip: string; url: string;} type ThreadPr = GitStatusResult["pr"]; function ThreadStatusLabel({ status, compact = false,}: { status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>; compact?: boolean;}) { if (compact) { return ( <span title={status.label} className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`} > <span className={`size-[9px] rounded-full ${status.dotClass} ${ status.pulse ? "animate-pulse" : "" }`} /> <span className="sr-only">{status.label}</span> </span> ); } return ( <span title={status.label} className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`} > <span className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ status.pulse ? "animate-pulse" : "" }`} /> <span className="hidden md:inline">{status.label}</span> </span> );} function terminalStatusFromRunningIds( runningTerminalIds: string[],): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, };} function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; if (pr.state === "open") { return { label: "PR open", colorClass: "text-emerald-600 dark:text-emerald-300/90", tooltip: `#${pr.number} PR open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { label: "PR closed", colorClass: "text-zinc-500 dark:text-zinc-400/80", tooltip: `#${pr.number} PR closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { label: "PR merged", colorClass: "text-violet-600 dark:text-violet-300/90", tooltip: `#${pr.number} PR merged: ${pr.title}`, url: pr.url, }; } return null;} function resolveThreadPr( threadBranch: string | null, gitStatus: GitStatusResult | null,): ThreadPr | null { if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { return null; } return gitStatus.pr ?? null;} interface SidebarThreadRowProps { threadId: ThreadId; projectCwd: string | null; orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet<ThreadId>; showThreadJumpHints: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadId: ThreadId | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: MutableRefObject<HTMLInputElement | null>; renamingCommittedRef: MutableRefObject<boolean>; confirmingArchiveThreadId: ThreadId | null; setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; handleThreadClick: ( event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[], ) => void; navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; handleThreadContextMenu: ( threadId: ThreadId, position: { x: number; y: number }, ) => Promise<void>; clearSelection: () => void; commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>; cancelRename: () => void; attemptArchiveThread: (threadId: ThreadId) => Promise<void>; openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;} function SidebarThreadRow(props: SidebarThreadRowProps) { const thread = useSidebarThreadSummaryById(props.threadId); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); const runningTerminalIds = useTerminalStateStore( (state) => selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); if (!thread) { return null; } const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); 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 = props.confirmingArchiveThreadId === thread.id && !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"; return ( <SidebarMenuSubItem className="w-full" data-thread-item onMouseLeave={() => { props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }} onBlurCapture={(event) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }); }} > <SidebarMenuSubButton render={<div role="button" tabIndex={0} />} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} className={`${resolveThreadRowClassName({ isActive, isSelected, })} relative isolate`} onClick={(event) => { props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); props.navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { void props.handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); } else { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } void props.handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); } }} > <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> {prStatus && ( <Tooltip> <TooltipTrigger render={ <button type="button" aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onClick={(event) => { props.openPrLink(event, prStatus.url); }} > <GitPullRequestIcon className="size-3" /> </button> } /> <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup> </Tooltip> )} {threadStatus && <ThreadStatusLabel status={threadStatus} />} {props.renamingThreadId === thread.id ? ( <input ref={(element) => { if (element && props.renamingInputRef.current !== element) { props.renamingInputRef.current = element; element.focus(); element.select(); } }} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={props.renamingTitle} onChange={(event) => props.setRenamingTitle(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); props.renamingCommittedRef.current = true; void props.commitRename(thread.id, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); props.renamingCommittedRef.current = true; props.cancelRename(); } }} onBlur={() => { if (!props.renamingCommittedRef.current) { void props.commitRename(thread.id, props.renamingTitle, thread.title); } }} onClick={(event) => event.stopPropagation()} /> ) : ( <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> )} </div> <div className="ml-auto flex shrink-0 items-center gap-1.5"> {terminalStatus && ( <span role="img" aria-label={terminalStatus.label} title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> </span> )} <div className="flex min-w-12 justify-end"> {isConfirmingArchive ? ( <button ref={(element) => { if (element) { props.confirmArchiveButtonRefs.current.set(thread.id, element); } else { props.confirmArchiveButtonRefs.current.delete(thread.id); } }} type="button" data-thread-selection-safe data-testid={`thread-archive-confirm-${thread.id}`} aria-label={`Confirm archive ${thread.title}`} className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" onPointerDown={(event) => { event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); props.setConfirmingArchiveThreadId((current) => current === thread.id ? null : current, ); void props.attemptArchiveThread(thread.id); }} > Confirm </button> ) : !isThreadRunning ? ( props.appSettingsConfirmThreadArchive ? ( <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 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"> <button type="button" data-thread-selection-safe data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" onPointerDown={(event) => { event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); props.setConfirmingArchiveThreadId(thread.id); requestAnimationFrame(() => { props.confirmArchiveButtonRefs.current.get(thread.id)?.focus(); }); }} > <ArchiveIcon className="size-3.5" /> </button> </div> ) : (Lines 1–500 of 2158. Load more
import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, PlusIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type KeyboardEvent, type MouseEvent, type MutableRefObject, type PointerEvent, type ReactNode, type SetStateAction, } 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 { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; 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 { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleSidebarThreadIds, getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; 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; type SidebarProjectSnapshot = Project & { expanded: boolean; }; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean; } interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; tooltip: string; url: string; } type ThreadPr = GitStatusResult["pr"]; function ThreadStatusLabel({ status, compact = false, }: { status: NonNullable<ReturnType<typeof resolveThreadStatusPill>>; compact?: boolean; }) { if (compact) { return ( <span title={status.label} className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`} > <span className={`size-[9px] rounded-full ${status.dotClass} ${ status.pulse ? "animate-pulse" : "" }`} /> <span className="sr-only">{status.label}</span> </span> ); } return ( <span title={status.label} className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`} > <span className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ status.pulse ? "animate-pulse" : "" }`} /> <span className="hidden md:inline">{status.label}</span> </span> ); } function terminalStatusFromRunningIds( runningTerminalIds: string[], ): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, }; } function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; if (pr.state === "open") { return { label: "PR open", colorClass: "text-emerald-600 dark:text-emerald-300/90", tooltip: `#${pr.number} PR open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { label: "PR closed", colorClass: "text-zinc-500 dark:text-zinc-400/80", tooltip: `#${pr.number} PR closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { label: "PR merged", colorClass: "text-violet-600 dark:text-violet-300/90", tooltip: `#${pr.number} PR merged: ${pr.title}`, url: pr.url, }; } return null; } function resolveThreadPr( threadBranch: string | null, gitStatus: GitStatusResult | null, ): ThreadPr | null { if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { return null; } return gitStatus.pr ?? null; } interface SidebarThreadRowProps { threadId: ThreadId; projectCwd: string | null; orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet<ThreadId>; showThreadJumpHints: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadId: ThreadId | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: MutableRefObject<HTMLInputElement | null>; renamingCommittedRef: MutableRefObject<boolean>; confirmingArchiveThreadId: ThreadId | null; setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>; confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>; handleThreadClick: ( event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[], ) => void; navigateToThread: (threadId: ThreadId) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; handleThreadContextMenu: ( threadId: ThreadId, position: { x: number; y: number }, ) => Promise<void>; clearSelection: () => void; commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>; cancelRename: () => void; attemptArchiveThread: (threadId: ThreadId) => Promise<void>; openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void; } function SidebarThreadRow(props: SidebarThreadRowProps) { const thread = useSidebarThreadSummaryById(props.threadId); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); const runningTerminalIds = useTerminalStateStore( (state) => selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); const gitCwd = thread?.worktreePath ?? props.projectCwd; const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); if (!thread) { return null; } const isActive = props.routeThreadId === thread.id; const isSelected = props.selectedThreadIds.has(thread.id); 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 = props.confirmingArchiveThreadId === thread.id && !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"; return ( <SidebarMenuSubItem className="w-full" data-thread-item onMouseLeave={() => { props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }} onBlurCapture={(event) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }); }} > <SidebarMenuSubButton render={<div role="button" tabIndex={0} />} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} className={`${resolveThreadRowClassName({ isActive, isSelected, })} relative isolate`} onClick={(event) => { props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); props.navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { void props.handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); } else { if (props.selectedThreadIds.size > 0) { props.clearSelection(); } void props.handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); } }} > <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> {prStatus && ( <Tooltip> <TooltipTrigger render={ <button type="button" aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onClick={(event) => { props.openPrLink(event, prStatus.url); }} > <GitPullRequestIcon className="size-3" /> </button> } /> <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup> </Tooltip> )} {threadStatus && <ThreadStatusLabel status={threadStatus} />} {props.renamingThreadId === thread.id ? ( <input ref={(element) => { if (element && props.renamingInputRef.current !== element) { props.renamingInputRef.current = element; element.focus(); element.select(); } }} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={props.renamingTitle} onChange={(event) => props.setRenamingTitle(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); props.renamingCommittedRef.current = true; void props.commitRename(thread.id, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); props.renamingCommittedRef.current = true; props.cancelRename(); } }} onBlur={() => { if (!props.renamingCommittedRef.current) { void props.commitRename(thread.id, props.renamingTitle, thread.title); } }} onClick={(event) => event.stopPropagation()} /> ) : ( <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> )} </div> <div className="ml-auto flex shrink-0 items-center gap-1.5"> {terminalStatus && ( <span role="img" aria-label={terminalStatus.label} title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> </span> )} <div className="flex min-w-12 justify-end"> {isConfirmingArchive ? ( <button ref={(element) => { if (element) { props.confirmArchiveButtonRefs.current.set(thread.id, element); } else { props.confirmArchiveButtonRefs.current.delete(thread.id); } }} type="button" data-thread-selection-safe data-testid={`thread-archive-confirm-${thread.id}`} aria-label={`Confirm archive ${thread.title}`} className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" onPointerDown={(event) => { event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); props.setConfirmingArchiveThreadId((current) => current === thread.id ? null : current, ); void props.attemptArchiveThread(thread.id); }} > Confirm </button> ) : !isThreadRunning ? ( props.appSettingsConfirmThreadArchive ? ( <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 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"> <button type="button" data-thread-selection-safe data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" onPointerDown={(event) => { event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); props.setConfirmingArchiveThreadId(thread.id); requestAnimationFrame(() => { props.confirmArchiveButtonRefs.current.get(thread.id)?.focus(); }); }} > <ArchiveIcon className="size-3.5" /> </button> </div> ) : (