import { autoAnimate } from "@formkit/auto-animate";import { useAtomValue } from "@effect/atom-react";import { canSnooze, effectiveSettled, effectiveSnoozed, threadWokeAt,} 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 { AlarmClockIcon, AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, 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, type ReactNode,} 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, parseTimestampDate } from "../timestampFormat";import type { SidebarThreadSummary } from "../types";import { cn } from "~/lib/utils";import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2,} from "./Sidebar.logic";import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";import { prStatusIndicator, resolveThreadPr, settledPrHoverColorClass,} from "./ThreadStatusIndicators";import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset,} from "./Sidebar.snooze";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 { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";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! text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" style={{ background: "color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", }} > <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> );} /** * 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;}) { const { open, onOpenChange, onSnooze } = 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()) : []), [open]); return ( <Popover open={open} onOpenChange={onOpenChange}> <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" /> </PopoverTrigger> <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> );} 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" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; 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; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;}) { const { isRenaming, onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, onRenameTitleChange, onSettle, onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, onUnsnooze, 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); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so unlike Done, a never-visited woke thread still // shows the pill; visiting clears it. An unparseable visit timestamp // counts as never-visited — corrupt local data must not eat the wake // signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // 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, failed, and // freshly woken. 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 && !isWoke && !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", } : isWoke ? { label: "Woke", icon: "woke" as const, className: "text-amber-700 dark:text-amber-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,Lines 1–500 of 2732. Load more
import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { canSnooze, effectiveSettled, effectiveSnoozed, threadWokeAt, } 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 { AlarmClockIcon, AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, 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, type ReactNode, } 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, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr, settledPrHoverColorClass, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; 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 { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; 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! text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none" style={{ background: "color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", }} > <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> ); } /** * 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; }) { const { open, onOpenChange, onSnooze } = 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()) : []), [open]); return ( <Popover open={open} onOpenChange={onOpenChange}> <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" /> </PopoverTrigger> <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> ); } 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" | "unsnooze"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; 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; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { isRenaming, onChangeRequestState, onCancelRename, onCommitRename, onContextMenu, onRenameTitleChange, onSettle, onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, onUnsnooze, 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); // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so unlike Done, a never-visited woke thread still // shows the pill; visiting clears it. An unparseable visit timestamp // counts as never-visited — corrupt local data must not eat the wake // signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // 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, failed, and // freshly woken. 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 && !isWoke && !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", } : isWoke ? { label: "Woke", icon: "woke" as const, className: "text-amber-700 dark:text-amber-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,