import { autoAnimate } from "@formkit/auto-animate";import { useAtomValue } from "@effect/atom-react";import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";import { scopeProjectRef, scopeThreadRef, scopedThreadKey,} from "@t3tools/client-runtime/environment";import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";import { CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, ServerIcon, SquarePenIcon, Trash2Icon, Undo2Icon,} from "lucide-react";import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent,} from "react";import { useParams, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { isElectron } from "../env";import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { useShortcutModifierState } from "../shortcutModifierState";import { isTerminalFocused } from "../lib/terminalFocus";import { isModelPickerOpen } from "../modelPickerVisibility";import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";import { isMacPlatform } from "~/lib/utils";import { useOpenPrLink } from "../lib/openPullRequestLink";import { readLocalApi } from "../localApi";import { deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings,} from "../logicalProject";import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";import { useThreadSelectionStore } from "../threadSelectionStore";import { useThreadActions } from "../hooks/useThreadActions";import { useHandleNewThread } from "../hooks/useHandleNewThread";import { openCommandPalette } from "../commandPaletteBus";import { startNewThreadFromContext } from "../lib/chatThreadActions";import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";import { useCopyToClipboard } from "../hooks/useCopyToClipboard";import { useNowMinute } from "../hooks/useNowMinute";import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";import { useProjects, useThreadShells } from "../state/entities";import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";import { vcsEnvironment } from "../state/vcs";import { threadEnvironment } from "../state/threads";import { projectEnvironment } from "../state/projects";import { useEnvironmentQuery } from "../state/query";import { useAtomCommand } from "../state/use-atom-command";import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";import { formatRelativeTimeLabel } from "../timestampFormat";import type { SidebarThreadSummary } from "../types";import { cn } from "~/lib/utils";import { formatWorkingDurationLabel, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2,} from "./Sidebar.logic";import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";import { ProjectFavicon } from "./ProjectFavicon";import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";import { primaryServerProvidersAtom } from "../state/server";import { stackedThreadToast, toastManager } from "./ui/toast";import { CommandDialogTrigger } from "./ui/command";import { Button } from "./ui/button";import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle,} from "./ui/dialog";import { Input } from "./ui/input";import { Kbd } from "./ui/kbd";import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail// stays behind an explicit Show more.const SETTLED_TAIL_INITIAL_COUNT = 10;const SETTLED_TAIL_PAGE_COUNT = 25;const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate",}; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label;} function threadTimeLabel(thread: SidebarThreadSummary): string { const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));} // Settled rows read "how long ago did this wrap up", matching their sort// key: both go through resolveSettledTimestamp so label and order can't// disagree.function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledTimestamp(thread); return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));} // Floats at the row's right edge, vertically centered, while the jump// modifier is held. An overlay pill instead of an inline slot: the hint// must neither displace the status/time label (holding ⌘ used to blank// out "Working") nor shift any layout when it appears. pointer-events-none// so it never swallows clicks meant for the settle/un-settle buttons it// can overlap.function JumpHintBadge(props: { label: string }) { return ( <span aria-hidden className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" > {props.label} </span> );} // Self-ticking so only this span re-renders each second, not the whole row.function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; const [, setTick] = useState(0); useEffect(() => { if (Number.isNaN(startedMs)) return; const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>;} function SidebarV2ThreadTooltip({ thread, projectTitle, projectCwd, environmentLabel, driverKind, modelInstanceId, modelLabel, branchMismatch,}: { thread: SidebarThreadSummary; projectTitle: string | null; projectCwd: string | null; environmentLabel: string | null; driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; modelLabel: string; branchMismatch: { threadBranch: string; currentBranch: string; } | null;}) { return ( <TooltipPopup side="right" align="start" sideOffset={8} className="dropdown-glass max-w-80 border-0! bg-[color-mix(in_srgb,var(--background)_var(--glass-opacity),transparent)] text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" > <div className="flex max-w-80 flex-col gap-2 p-2"> <div className="whitespace-nowrap text-sm font-medium text-foreground">{thread.title}</div> <div className="grid gap-1.5 text-xs text-muted-foreground"> {projectTitle ? ( <div className="flex min-w-0 items-center gap-2"> <ProjectFavicon environmentId={thread.environmentId} cwd={projectCwd ?? ""} className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{projectTitle}</div> </div> ) : null} {environmentLabel ? ( <div className="flex min-w-0 items-center gap-2"> <ServerIcon className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{environmentLabel}</div> </div> ) : null} {thread.branch ? ( <div className="flex min-w-0 items-center gap-2"> <GitBranchIcon className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{thread.branch}</div> </div> ) : null} {branchMismatch ? ( <div className="flex min-w-0 items-start gap-2 text-warning"> <CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 stroke-current" /> <div className="min-w-0 flex-1 wrap-break-word leading-5"> You're currently checked out on another branch. </div> </div> ) : null} {driverKind ? ( <div className="flex min-w-0 items-center gap-2"> <ProviderInstanceIcon driverKind={driverKind} displayName={thread.session?.providerName ?? modelInstanceId} iconClassName="size-4 shrink-0" /> <div className="min-w-0 wrap-break-word text-foreground/90">{modelLabel}</div> </div> ) : null} {thread.session?.lastError ? ( <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400"> <CircleAlertIcon className="size-4 shrink-0 stroke-current" /> <div className="min-w-0 wrap-break-word">{thread.session.lastError}</div> </div> ) : null} </div> </div> </TooltipPopup> );} const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; onRenameTitleChange: (title: string) => void; onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; onCancelRename: () => void; isRenaming: boolean; renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;}) { const { isRenaming, onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, onRenameTitleChange, onSettle, onStartRename, onThreadActivate, onThreadClick, onUnsettle, renamingTitle, thread, variant, variantAction, } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, and failed. // The status label keeps its hue, so waiting rows stay findable. In-flight // rows recede the same as read-ready ones (inbox-zero: working threads // aren't your problem yet) — only the colored status label stands out. const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. const topStatus = status === "working" ? { label: "Working", icon: "working" as const, className: "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", } : status === "approval" ? { label: "Approval", icon: null, className: "text-amber-700 dark:text-amber-300", } : status === "input" ? { label: "Input", icon: null, className: "text-indigo-600 dark:text-indigo-300", } : status === "failed" ? { label: "Failed", icon: null, className: "text-red-700 dark:text-red-300", } : isUnread ? { label: "Done", icon: "done" as const, className: "text-emerald-700 dark:text-emerald-300", } : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( <SidebarV2ThreadTooltip thread={thread} projectTitle={props.projectTitle} projectCwd={props.projectCwd} environmentLabel={props.environmentLabel} driverKind={driverKind} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} /> ); const handleClick = useCallback( (event: ReactMouseEvent) => { onThreadClick(event, threadRef); }, [onThreadClick, threadRef], ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); }, [onContextMenu, threadRef], ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.target !== event.currentTarget) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onThreadActivate(threadRef); }, [onThreadActivate, threadRef], ); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } if ((event.target as HTMLElement).closest("button, a, input")) return; event.preventDefault(); onStartRename(threadRef, thread.title); }, [isRenaming, onStartRename, thread.title, threadRef], ); const renameCommittedRef = useRef(false); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; }, [isRenaming]); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLInputElement>) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); renameCommittedRef.current = true; onCommitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); renameCommittedRef.current = true; onCancelRename(); } }, [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], ); const handleRenameBlur = useCallback(() => { if (!renameCommittedRef.current) { onCommitRename(threadRef, renamingTitle, thread.title); } }, [onCommitRename, renamingTitle, thread.title, threadRef]); const handleSettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onSettle(threadRef); }, [onSettle, threadRef],Lines 1–500 of 2236. Load more
import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, ServerIcon, SquarePenIcon, Trash2Icon, Undo2Icon, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; import { deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; import { Button } from "./ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Kbd } from "./ui/kbd"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; } function threadTimeLabel(thread: SidebarThreadSummary): string { const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } // Settled rows read "how long ago did this wrap up", matching their sort // key: both go through resolveSettledTimestamp so label and order can't // disagree. function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledTimestamp(thread); return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } // Floats at the row's right edge, vertically centered, while the jump // modifier is held. An overlay pill instead of an inline slot: the hint // must neither displace the status/time label (holding ⌘ used to blank // out "Working") nor shift any layout when it appears. pointer-events-none // so it never swallows clicks meant for the settle/un-settle buttons it // can overlap. function JumpHintBadge(props: { label: string }) { return ( <span aria-hidden className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" > {props.label} </span> ); } // Self-ticking so only this span re-renders each second, not the whole row. function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; const [, setTick] = useState(0); useEffect(() => { if (Number.isNaN(startedMs)) return; const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>; } function SidebarV2ThreadTooltip({ thread, projectTitle, projectCwd, environmentLabel, driverKind, modelInstanceId, modelLabel, branchMismatch, }: { thread: SidebarThreadSummary; projectTitle: string | null; projectCwd: string | null; environmentLabel: string | null; driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; modelLabel: string; branchMismatch: { threadBranch: string; currentBranch: string; } | null; }) { return ( <TooltipPopup side="right" align="start" sideOffset={8} className="dropdown-glass max-w-80 border-0! bg-[color-mix(in_srgb,var(--background)_var(--glass-opacity),transparent)] text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" > <div className="flex max-w-80 flex-col gap-2 p-2"> <div className="whitespace-nowrap text-sm font-medium text-foreground">{thread.title}</div> <div className="grid gap-1.5 text-xs text-muted-foreground"> {projectTitle ? ( <div className="flex min-w-0 items-center gap-2"> <ProjectFavicon environmentId={thread.environmentId} cwd={projectCwd ?? ""} className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{projectTitle}</div> </div> ) : null} {environmentLabel ? ( <div className="flex min-w-0 items-center gap-2"> <ServerIcon className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{environmentLabel}</div> </div> ) : null} {thread.branch ? ( <div className="flex min-w-0 items-center gap-2"> <GitBranchIcon className="size-4 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 wrap-break-word text-foreground/90">{thread.branch}</div> </div> ) : null} {branchMismatch ? ( <div className="flex min-w-0 items-start gap-2 text-warning"> <CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 stroke-current" /> <div className="min-w-0 flex-1 wrap-break-word leading-5"> You're currently checked out on another branch. </div> </div> ) : null} {driverKind ? ( <div className="flex min-w-0 items-center gap-2"> <ProviderInstanceIcon driverKind={driverKind} displayName={thread.session?.providerName ?? modelInstanceId} iconClassName="size-4 shrink-0" /> <div className="min-w-0 wrap-break-word text-foreground/90">{modelLabel}</div> </div> ) : null} {thread.session?.lastError ? ( <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400"> <CircleAlertIcon className="size-4 shrink-0 stroke-current" /> <div className="min-w-0 wrap-break-word">{thread.session.lastError}</div> </div> ) : null} </div> </div> </TooltipPopup> ); } const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; onRenameTitleChange: (title: string) => void; onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; onCancelRename: () => void; isRenaming: boolean; renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { isRenaming, onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, onRenameTitleChange, onSettle, onStartRename, onThreadActivate, onThreadClick, onUnsettle, renamingTitle, thread, variant, variantAction, } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, and failed. // The status label keeps its hue, so waiting rows stay findable. In-flight // rows recede the same as read-ready ones (inbox-zero: working threads // aren't your problem yet) — only the colored status label stands out. const isInFlight = status === "working" || status === "approval" || status === "input"; const shouldRecede = (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. const topStatus = status === "working" ? { label: "Working", icon: "working" as const, className: "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", } : status === "approval" ? { label: "Approval", icon: null, className: "text-amber-700 dark:text-amber-300", } : status === "input" ? { label: "Input", icon: null, className: "text-indigo-600 dark:text-indigo-300", } : status === "failed" ? { label: "Failed", icon: null, className: "text-red-700 dark:text-red-300", } : isUnread ? { label: "Done", icon: "done" as const, className: "text-emerald-700 dark:text-emerald-300", } : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); // Report the PR state up: the parent partitions rows with effectiveSettled, // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; useEffect(() => { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( <SidebarV2ThreadTooltip thread={thread} projectTitle={props.projectTitle} projectCwd={props.projectCwd} environmentLabel={props.environmentLabel} driverKind={driverKind} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} /> ); const handleClick = useCallback( (event: ReactMouseEvent) => { onThreadClick(event, threadRef); }, [onThreadClick, threadRef], ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); }, [onContextMenu, threadRef], ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.target !== event.currentTarget) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onThreadActivate(threadRef); }, [onThreadActivate, threadRef], ); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } if ((event.target as HTMLElement).closest("button, a, input")) return; event.preventDefault(); onStartRename(threadRef, thread.title); }, [isRenaming, onStartRename, thread.title, threadRef], ); const renameCommittedRef = useRef(false); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; }, [isRenaming]); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLInputElement>) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); renameCommittedRef.current = true; onCommitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); renameCommittedRef.current = true; onCancelRename(); } }, [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], ); const handleRenameBlur = useCallback(() => { if (!renameCommittedRef.current) { onCommitRename(threadRef, renamingTitle, thread.title); } }, [onCommitRename, renamingTitle, thread.title, threadRef]); const handleSettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onSettle(threadRef); }, [onSettle, threadRef],