import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";import { useAtomValue } from "@effect/atom-react";import * as Schema from "effect/Schema";import { DndContext, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent, type Modifier,} from "@dnd-kit/core";import { SortableContext, useSortable } from "@dnd-kit/sortable";import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";import { CSS } from "@dnd-kit/utilities";import { canSnooze, effectiveSnoozed, threadWokeAt,} from "@t3tools/client-runtime/state/thread-settled";import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey,} from "@t3tools/client-runtime/environment";import { resolveEnvironmentMachineKind, type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, type ThreadId,} from "@t3tools/contracts";import type { TimestampFormat } from "@t3tools/contracts/settings";import { AlarmClockIcon, AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, XIcon,} from "lucide-react";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode,} from "react";import { useParams, useRouter } from "@tanstack/react-router"; import { useRightPanelStore } from "../rightPanelStore";import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { isElectron } from "../env";import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { useShortcutModifierState } from "../shortcutModifierState";import { useTerminalFocus } from "../hooks/useTerminalFocus";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 { releaseComposerDraftUploads } from "../lib/composerDraftUploads";import { readLocalApi } from "../localApi";import { isSameSidebarThreadRef, useSidebarPendingFileDropStore,} from "../sidebarPendingFileDropStore";import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore,} from "../threadSelectionStore";import { useThreadActions } from "../hooks/useThreadActions";import { useHandleNewThread } from "../hooks/useHandleNewThread";import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";import { startNewThreadFromContext } from "../lib/chatThreadActions";import { useClientSettings } from "../hooks/useSettings";import { useCopyToClipboard } from "../hooks/useCopyToClipboard";import { useLocalStorage } from "../hooks/useLocalStorage";import { useNowMinute } from "../hooks/useNowMinute";import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells,} from "../state/entities";import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";import { vcsEnvironment } from "../state/vcs";import { threadEnvironment } from "../state/threads";import { useEnvironmentQuery } from "../state/query";import { useAtomCommand } from "../state/use-atom-command";import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget,} from "../threadRoutes";import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";import type { SidebarThreadSummary } from "../types";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { cn } from "~/lib/utils";import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";import { buildThreadActionMenuItems } from "./threadActionMenu.logic";import { animateSidebarLayoutChanges, applySidebarThreadDrop, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSidebarDropTarget, resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, type SidebarListItem, type SidebarListMarker, type SidebarSection,} from "./Sidebar.logic";import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";import { createSidebarCollisionDetection, createSidebarSortingStrategy, restrictBelowSidebarLabel,} from "./Sidebar.drag";import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";import { createSidebarListMotion } from "./Sidebar.motion";import { ThreadPullRequestBadgeControl, ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, resolveThreadPullRequestBadge, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest,} from "./ThreadStatusIndicators";import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset,} from "./Sidebar.snooze";import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";import { deriveProviderEntriesByEnvironment, shouldShowInstanceBadge, type ProviderInstanceEntry,} from "../providerInstances";import { useThreadRunningTerminalIds } from "../state/terminalSessions";import { stackedThreadToast, toastManager } from "./ui/toast";import { Button } from "./ui/button";import { Input } from "./ui/input";import { Combobox, ComboboxEmpty, ComboboxSearchInput, ComboboxItem, ComboboxList, ComboboxPopup, ComboboxTrigger, useComboboxFilter,} from "./ui/combobox";import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";import { composerDraftHasUserContent, DraftId, useComposerDraftStore, useThreadHasUnsentDraft, type ComposerThreadDraftState, type DraftSessionState,} 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;// Fresh keys deliberately reset both shelves to collapsed for existing users.const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; 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 resolveSettledThreadTimestamp so label and order can't// disagree.function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledThreadTimestamp(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>;} const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();// Collapsed shelves share one empty list so a route change alone does not// give the sidebar list a new identity.const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`;} function SidebarThreadTooltip({ thread, project, projectDisplayName, environmentLabel, environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, modelLabel, branchMismatch, terminalStatus, terminalProcessCount,}: { thread: SidebarThreadSummary; project: ProjectFaviconProject | null; projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; modelLabel: string; branchMismatch: { threadBranch: string; currentBranch: string; } | null; terminalStatus: TerminalStatusIndicator | null; terminalProcessCount: number;}) { const driverKind = providerEntry?.driverKind ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return ( <TooltipPopup side="right" align="start" sideOffset={4} variant="glass" className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" > <div className="flex min-w-0 max-w-80 flex-col gap-2 p-[var(--floating-content-inset)]"> <div className="min-w-0 truncate text-xs leading-tight font-medium text-foreground"> {thread.title} </div> <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground"> {projectDisplayName ? ( <div className="flex min-w-0 items-center gap-2"> {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null} <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div> </div> ) : null} {environmentLabel ? ( <div className="flex min-w-0 items-center gap-2"> <EnvironmentMachineIcon kind={environmentMachine} className="size-3 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div> </div> ) : null} {thread.branch ? ( <div className="flex min-w-0 items-center gap-2"> <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 truncate text-foreground/75">{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-3 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={ providerEntry?.displayName ?? thread.session?.providerName ?? modelInstanceId } accentColor={providerEntry?.accentColor} // Initials would swallow a size-3 glyph: accent dot, name in label. showBadge={showInstanceBadge && providerEntry?.accentColor !== undefined} badgeContent="none" badgeClassName="h-2 min-w-2 px-0" iconClassName="size-3 shrink-0 grayscale opacity-60" /> <div className="min-w-0 truncate text-foreground/75"> {showInstanceBadge && providerEntry ? `${modelLabel} · ${providerEntry.displayName}` : modelLabel} </div> </div> ) : null} {terminalStatus ? ( <div className="flex min-w-0 items-center gap-2"> <TerminalIcon aria-hidden className={cn("size-3 shrink-0", terminalStatus.colorClass)} /> <div className="min-w-0 truncate text-foreground/75"> {terminalProcessLabel(terminalProcessCount)} </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-3 shrink-0 stroke-current" /> <div className="min-w-0 truncate">Error occurred</div> </div> ) : null} </div> {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground"> <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} /> </div> ) : null} </div> </TooltipPopup> );} /** * Hover entry point for snooze: a clock button opening the preset menu. * Controlled by the row (which also uses the open state to pin its hover * actions while the menu is up). */function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; onSnooze: (preset: SnoozePreset) => void; timestampFormat: TimestampFormat;}) { const { open, onOpenChange, onSnooze, timestampFormat } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), [open, timestampFormat], ); return ( <Popover open={open} onOpenChange={onOpenChange}> <Tooltip> <TooltipTrigger render={ <PopoverTrigger render={ <button type="button" aria-label="Snooze thread" onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } /> } > <ClockIcon className="size-3" /> </TooltipTrigger> <TooltipPopup>Snooze thread</TooltipPopup> </Tooltip> <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> {presets.map((preset) => ( <button key={preset.id} type="button" onClick={(event) => { event.stopPropagation(); onOpenChange(false); onSnooze(preset); }} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground" > <span className="flex-1">{preset.label}</span> <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums"> {preset.whenLabel} </span> </button> ))} </PopoverPopup> </Popover> );} // Subset of useSortable applied to a thread row's root <li>. Listeners go// on the whole row (no dedicated handle): the pointer sensor's distance// constraint keeps plain clicks working, and we skip dnd-kit's aria// attributes since there is no keyboard sensor and the row body already// carries its own button semantics.type SortableThreadRowBag = Pick< ReturnType<typeof useSortable>, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"Lines 1–500 of 4931. Load more
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent, type Modifier, } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { resolveEnvironmentMachineKind, type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, XIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; 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 { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells, } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSidebarDropTarget, resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, type SidebarListItem, type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { createSidebarCollisionDetection, createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, resolveThreadPullRequestBadge, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderEntriesByEnvironment, shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Combobox, ComboboxEmpty, ComboboxSearchInput, ComboboxItem, ComboboxList, ComboboxPopup, ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { composerDraftHasUserContent, DraftId, useComposerDraftStore, useThreadHasUnsentDraft, type ComposerThreadDraftState, type DraftSessionState, } 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; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; 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 resolveSettledThreadTimestamp so label and order can't // disagree. function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledThreadTimestamp(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>; } const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } function SidebarThreadTooltip({ thread, project, projectDisplayName, environmentLabel, environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, modelLabel, branchMismatch, terminalStatus, terminalProcessCount, }: { thread: SidebarThreadSummary; project: ProjectFaviconProject | null; projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; modelLabel: string; branchMismatch: { threadBranch: string; currentBranch: string; } | null; terminalStatus: TerminalStatusIndicator | null; terminalProcessCount: number; }) { const driverKind = providerEntry?.driverKind ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return ( <TooltipPopup side="right" align="start" sideOffset={4} variant="glass" className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" > <div className="flex min-w-0 max-w-80 flex-col gap-2 p-[var(--floating-content-inset)]"> <div className="min-w-0 truncate text-xs leading-tight font-medium text-foreground"> {thread.title} </div> <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground"> {projectDisplayName ? ( <div className="flex min-w-0 items-center gap-2"> {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null} <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div> </div> ) : null} {environmentLabel ? ( <div className="flex min-w-0 items-center gap-2"> <EnvironmentMachineIcon kind={environmentMachine} className="size-3 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div> </div> ) : null} {thread.branch ? ( <div className="flex min-w-0 items-center gap-2"> <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" /> <div className="min-w-0 truncate text-foreground/75">{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-3 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={ providerEntry?.displayName ?? thread.session?.providerName ?? modelInstanceId } accentColor={providerEntry?.accentColor} // Initials would swallow a size-3 glyph: accent dot, name in label. showBadge={showInstanceBadge && providerEntry?.accentColor !== undefined} badgeContent="none" badgeClassName="h-2 min-w-2 px-0" iconClassName="size-3 shrink-0 grayscale opacity-60" /> <div className="min-w-0 truncate text-foreground/75"> {showInstanceBadge && providerEntry ? `${modelLabel} · ${providerEntry.displayName}` : modelLabel} </div> </div> ) : null} {terminalStatus ? ( <div className="flex min-w-0 items-center gap-2"> <TerminalIcon aria-hidden className={cn("size-3 shrink-0", terminalStatus.colorClass)} /> <div className="min-w-0 truncate text-foreground/75"> {terminalProcessLabel(terminalProcessCount)} </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-3 shrink-0 stroke-current" /> <div className="min-w-0 truncate">Error occurred</div> </div> ) : null} </div> {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground"> <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} /> </div> ) : null} </div> </TooltipPopup> ); } /** * Hover entry point for snooze: a clock button opening the preset menu. * Controlled by the row (which also uses the open state to pin its hover * actions while the menu is up). */ function SnoozePopoverButton(props: { open: boolean; onOpenChange: (open: boolean) => void; onSnooze: (preset: SnoozePreset) => void; timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), [open, timestampFormat], ); return ( <Popover open={open} onOpenChange={onOpenChange}> <Tooltip> <TooltipTrigger render={ <PopoverTrigger render={ <button type="button" aria-label="Snooze thread" onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } /> } > <ClockIcon className="size-3" /> </TooltipTrigger> <TooltipPopup>Snooze thread</TooltipPopup> </Tooltip> <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1"> {presets.map((preset) => ( <button key={preset.id} type="button" onClick={(event) => { event.stopPropagation(); onOpenChange(false); onSnooze(preset); }} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground" > <span className="flex-1">{preset.label}</span> <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums"> {preset.whenLabel} </span> </button> ))} </PopoverPopup> </Popover> ); } // Subset of useSortable applied to a thread row's root <li>. Listeners go // on the whole row (no dedicated handle): the pointer sensor's distance // constraint keeps plain clicks working, and we skip dnd-kit's aria // attributes since there is no keyboard sensor and the row body already // carries its own button semantics. type SortableThreadRowBag = Pick< ReturnType<typeof useSortable>, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"