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 = { 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 ( {props.label} ); } // 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 {formatWorkingDurationLabel(Date.now() - startedMs)}; } 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 (
{thread.title}
{projectTitle ? (
{projectTitle}
) : null} {environmentLabel ? (
{environmentLabel}
) : null} {thread.branch ? (
{thread.branch}
) : null} {branchMismatch ? (
You're currently checked out on another branch.
) : null} {driverKind ? (
{modelLabel}
) : null} {thread.session?.lastError ? (
{thread.session.lastError}
) : null}
); } /** * 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 ( 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" /> } > {presets.map((preset) => ( ))} ); } 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; 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, }); const pr = resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; // 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 = ( ); 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) => { 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], ); const handleUnsettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onUnsettle(threadRef); }, [onUnsettle, threadRef], ); const handleUnsnoozeClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onUnsnooze(threadRef); }, [onUnsnooze, threadRef], ); const handleSnoozePreset = useCallback( (preset: SnoozePreset) => { onSnooze(threadRef, preset); }, [onSnooze, threadRef], ); // While the snooze popover is open the pointer leaves the row, which // would fade the hover actions out from under the open menu; pin them. const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); // Snooze is offered only where it can succeed: capability-gated and never // on blocked-on-you work or queued turns (the server rejects both). const showSnoozeButton = props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); // If the thread becomes blocked while the popover is open, the button // unmounts without firing onOpenChange(false). Deriving the flag keeps a // stale true from permanently hiding the status label / pinning the // hover actions, and the effect clears the raw state so the popover // doesn't resurrect if the button later remounts. const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; useEffect(() => { if (!showSnoozeButton) setSnoozeMenuOpen(false); }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); }, [openPrLink, pr], ); // All Sidebar V2 rows share one surface model. Live threads used to look // like elevated cards while settled threads were plain rows, leaving neither // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", isInFlight && !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", ); const title = isRenaming ? ( onRenameTitleChange(event.target.value)} onFocus={(event) => event.currentTarget.select()} onKeyDown={handleRenameKeyDown} onBlur={handleRenameBlur} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" /> ) : ( {thread.title} ); const prBadge = prStatus && pr ? ( ) : null; if (variant === "slim") { return (
  • } > {/* Settled history recedes: dimmed favicon at rest, restored on hover so the tail stays scannable when you're hunting. */} {title} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. {props.snoozeWakeLabelText} ) : isWoke ? ( // A wake can land straight in the settled tail (e.g. PR // merged while snoozed); the signal must survive the trip. Woke ) : ( {variantAction === "unsettle" ? settledTimeLabel(thread) : threadTimeLabel(thread)} )} {variantAction === "unsnooze" ? ( !props.snoozeSupported ? null : ( ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( ) : ( )} {props.jumpLabel ? : null} {detailsTooltip}
  • ); } const diff = latestTurnDiff(thread); return (
  • } >
    {props.projectTitle ? ( {props.projectTitle} ) : ( )} {topStatus ? ( {topStatus.icon === "working" ? ( ) : topStatus.icon === "done" ? ( ) : topStatus.icon === "woke" ? ( ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} {topStatus.label} {status === "working" ? ( ) : null} ) : ( threadTimeLabel(thread) )} {props.settlementSupported || showSnoozeButton ? ( {showSnoozeButton ? ( ) : null} {props.settlementSupported ? ( ) : null} ) : null}
    {title}
    {thread.branch ? ( {thread.branch} ) : ( )} {prBadge} {diff ? ( +{diff.insertions}{" "} −{diff.deletions} ) : null} {isRemote ? ( ) : null} {driverKind ? ( ) : null}
    {props.jumpLabel ? : null}
    {detailsTooltip}
  • ); }); function latestTurnDiff( thread: SidebarThreadSummary, ): { insertions: number; deletions: number } | null { // Shells don't carry checkpoint summaries; diff stats render only when the // shell projection grows them. Kept as a seam so the row layout is ready. void thread; return null; } export default function SidebarV2() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); const updateSettings = useUpdateClientSettings(); const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path, }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); const [projectActionsTarget, setProjectActionsTarget] = useState( null, ); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), [], ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; // Post-settle navigation validates against the CURRENT route, not the one // captured when the settle started: if the user navigated elsewhere while // the command was in flight, completing it must not yank them away. const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ getProjectOrderKey(project), legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), [projectOrder, projects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ environmentLabelById, orderedProjects, primaryEnvironmentId, projectGroupingSettings, projects, sidebarProjectSortOrder, ], ); const projectGroups = useMemo( () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( () => new Map( deriveProviderInstanceEntries(serverProviders).map( (entry) => [entry.instanceId as string, entry] as const, ), ), [serverProviders], ); const projectCwdByKey = useMemo( () => new Map( projects.map((project) => [ `${project.environmentId}:${project.id}`, project.workspaceRoot, ]), ), [projects], ); const projectDisplayNameByKey = useMemo( () => new Map( projectGroups.flatMap((group) => group.memberProjects.map( (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, ), ), ), [projectGroups], ); // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The // tick is a plain counter bumped exactly at the next wake boundary (armed // below, after the partition knows the boundary); the partition reads a // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( (threadKey: string, state: "open" | "closed" | "merged" | null) => { setChangeRequestStateByKey((current) => { if ((current.get(threadKey) ?? null) === state) return current; const next = new Map(current); if (state === null) { next.delete(threadKey); } else { next.set(threadKey, state); } return next; }); }, [], ); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProjectGroup = useMemo( () => projectScopeKey === null ? null : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), [projectGroups, projectScopeKey], ); const scopedProjectKeys = useMemo( () => scopedProjectGroup === null ? null : new Set( scopedProjectGroup.memberProjectRefs.map( (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, ), ), [scopedProjectGroup], ); useEffect(() => { if (projectScopeKey !== null && scopedProjectGroup === null) { setProjectScopeKey(null); } }, [projectScopeKey, scopedProjectGroup]); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { clearSelection(); }, [clearSelection, projectScopeKey]); const handleRemoveProjectMembers = useCallback( async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { const api = readLocalApi(); if (!api) return; const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === projectGroup.memberProjects.length; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? projectGroup.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( projectThreads.length > 0 ? [ `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, ...(singleMember.environmentLabel ? [`Environment: ${singleMember.environmentLabel}`] : []), ] : [`This removes ${members.length} grouped project entries.`]), "This permanently clears conversation history for those threads.", isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", ].join("\n") : [ `Remove project "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, ...(singleMember.environmentLabel ? [`Environment: ${singleMember.environmentLabel}`] : []), ] : [`This removes ${members.length} grouped project entries.`]), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; const draftStore = useComposerDraftStore.getState(); let shouldNavigate = false; for (const project of members) { const memberThreads = projectThreads.filter( (thread) => thread.environmentId === project.environmentId && thread.projectId === project.id, ); const projectRef = scopeProjectRef(project.environmentId, project.id); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ routeTarget: routeTargetRef.current, projectThreads: memberThreads, projectDraftId: projectDraftThread?.draftId ?? null, }); const result = await deleteProject({ environmentId: project.environmentId, input: { projectId: project.id, ...(memberThreads.length > 0 ? { force: true } : {}), }, }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: `Failed to remove "${project.title}"`, description: error instanceof Error ? error.message : "An error occurred.", }), ); } if (shouldNavigate) { void router.navigate({ to: "/" }); } return; } shouldNavigate ||= memberRemovalNeedsNavigation; if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); } if (shouldNavigate) { void router.navigate({ to: "/" }); } }, [deleteProject, router, threads], ); const renameProjectMember = useCallback( async (member: SidebarProjectGroupMember, nextTitle: string) => { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } if (title === member.title) return; const result = await updateProject({ environmentId: member.environmentId, input: { projectId: member.id, title }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to rename project", description: error instanceof Error ? error.message : "An error occurred.", }), ); } }, [updateProject], ); const updateProjectGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; if (selection === "inherit") { delete nextOverrides[overrideKey]; } else { nextOverrides[overrideKey] = selection; } updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); }, [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], ); const handleProjectActions = useCallback( (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); setProjectScopeMenuOpen(false); window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); }, [], ); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on // the shelf for the rest of the minute. snoozeWakeTick re-runs this // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); const visible = threads.filter( (thread) => thread.archivedAt === null && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); } else if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { settled.push(thread); } else { active.push(thread); } } return { activeThreads: sortThreadsForSidebarV2(active), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), settledThreads: sortSettledThreadsForSidebarV2(settled), snoozeNow: preciseNow, }; }, [ autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, ]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted // soonest-first, so entry 0 is the boundary. useEffect(() => { const nextWakeAtMs = snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null ? Date.parse(snoozedThreads[0].snoozedUntil) : Number.NaN; if (Number.isNaN(nextWakeAtMs)) return; // setTimeout delays are signed 32-bit: anything larger overflows and // fires immediately, turning a far-future wake (event-condition snoozes // synced from elsewhere) into a tight re-arm loop. Clamped, the timer // just re-arms every ~24.8 days until the wake is in range. const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); return () => window.clearTimeout(id); }, [snoozedThreads]); // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } const visibleSettledThreads = useMemo(() => { if (settledThreads.length <= settledVisibleCount) return settledThreads; const visible = settledThreads.slice(0, settledVisibleCount); // The open thread must never hide under "Show more": navigating into a // deep settled thread (search, deep link) pulls its row into the visible // tail so the highlight and the un-settle affordance stay reachable. if (routeThreadKey !== null) { const routeThread = settledThreads .slice(settledVisibleCount) .find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); if (routeThread !== undefined) visible.push(routeThread); } return visible; }, [routeThreadKey, settledThreads, settledVisibleCount]); const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return []; const routeThread = visibleSettledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); const visibleSnoozedThreads = useMemo(() => { if (snoozedShelfExpanded) return snoozedThreads; // The open thread must never vanish behind the collapsed shelf: a // snoozed thread reached by route (deep link, open before snoozing // elsewhere) keeps its row — with highlight and wake affordance — same // exception the settled tail's "Show more" makes. if (routeThreadKey === null) return []; const routeThread = snoozedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => orderedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), [orderedThreads], ); // Rows call back into the click handler without carrying the ordered list as // a prop — a fresh array identity per shell update would defeat every row's // memoization. The ref keeps shift-range-select working against the list as // rendered at click time. const orderedThreadKeysRef = useRef(orderedThreadKeys); orderedThreadKeysRef.current = orderedThreadKeys; const threadByKey = useMemo( () => new Map( orderedThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), [orderedThreads], ); // Handlers read these through refs: depending on per-update Map/Set // identities would give every row a fresh callback prop on each shell // event and defeat row memoization during streaming. const threadByKeyRef = useRef(threadByKey); threadByKeyRef.current = threadByKey; // handleNewThread is inherently unstable (depends on the projects list); // a ref keeps it out of attemptSettle's dependency array. const handleNewThreadRef = useRef(newThreadContext.handleNewThread); handleNewThreadRef.current = newThreadContext.handleNewThread; const settledThreadKeys = useMemo( () => new Set( settledThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ), [settledThreads], ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; const snoozedThreadKeys = useMemo( () => new Set( snoozedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ), [snoozedThreads], ); const snoozedThreadKeysRef = useRef(snoozedThreadKeys); snoozedThreadKeysRef.current = snoozedThreadKeys; const jumpLabelByKey = useMemo(() => { const mapping = new Map(); for (const [index, threadKey] of orderedThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(index); if (!jumpCommand) break; const label = shortcutLabelForCommand(keybindings, jumpCommand); if (label) mapping.set(threadKey, label); } return mapping; }, [keybindings, orderedThreadKeys]); const [showJumpHints, setShowJumpHints] = useState(false); // Settled threads are live shells, so opening one is plain navigation: // history stays readable without un-settling, and sending a message or // starting a session un-settles server-side. const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } setSelectionAnchor(scopedThreadKey(threadRef)); if (isMobile) { setOpenMobile(false); } void router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { setRenamingThreadKey(scopedThreadKey(threadRef)); setRenamingTitle(title); }, []); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { void (async () => { const trimmed = title.trim(); setRenamingThreadKey(null); if (trimmed.length === 0) { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; } if (trimmed === originalTitle) return; const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, title: trimmed }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to rename thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [updateThreadMetadata], ); const handleThreadClick = useCallback( (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { const isMac = isMacPlatform(navigator.platform); const isModClick = isMac ? event.metaKey : event.ctrlKey; const threadKey = scopedThreadKey(threadRef); if (isModClick) { event.preventDefault(); toggleThreadSelection(threadKey); return; } if (event.shiftKey) { event.preventDefault(); rangeSelectTo(threadKey, orderedThreadKeysRef.current); return; } if (isTrailingDoubleClick(event.detail)) { return; } navigateToThread(threadRef); }, [navigateToThread, rangeSelectTo, toggleThreadSelection], ); // A settle per thread at a time: double clicks and repeated menu picks // must not dispatch a second settle that fails and toasts a false error. const settlingThreadKeysRef = useRef(new Set()); // Parking the thread you're looking at (settle or snooze) moves you // forward: the next remaining card (never a settled or snoozed row, never // one leaving in the same batch), or a fresh draft in this project when it // was the last active one. Callers snapshot the plan BEFORE the command // mutates the partition; background parks never navigate (null plan). const planForwardNavigation = useCallback( (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { if (routeThreadKeyRef.current !== threadKey) return null; const shell = threadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; const snoozedKeys = snoozedThreadKeysRef.current; const currentIndex = orderedKeys.indexOf(threadKey); const nextCardKey = currentIndex === -1 ? null : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), ) ?? null); const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; return nextThread ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) : shell ? () => void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) : () => void router.navigate({ to: "/" }); }, [navigateToThread, router], ); const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); const result = await settleThread(threadRef); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to settle thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } // Only move forward if the user is still on the settled thread — // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSettle?.(); } } finally { settlingThreadKeysRef.current.delete(threadKey); } })(); }, [planForwardNavigation, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { const result = await unsettleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to un-settle thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [unsettleThread], ); const attemptUnsnooze = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { const result = await unsnoozeThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to wake thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [unsnoozeThread], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, preset: SnoozePreset, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { const threadKey = scopedThreadKey(threadRef); if (snoozingThreadKeysRef.current.has(threadKey)) return; snoozingThreadKeysRef.current.add(threadKey); try { // Snoozing the open thread moves you forward, same as settle — // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); const result = await snoozeThread(threadRef, preset.snoozedUntil); if (result._tag === "Failure") { // Never navigate away from a thread that did not snooze. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to snooze thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } // Snooze hides the row, so the toast is the only confirmation — // and the Undo is the escape hatch for a mis-click. toastManager.add( stackedThreadToast({ type: "success", title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, timeout: 5_000, actionProps: { children: "Undo", onClick: () => attemptUnsnooze(threadRef), }, }), ); // Only move forward if the user is still on the snoozed thread — // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSnooze?.(); } } finally { snoozingThreadKeysRef.current.delete(threadKey); } })(); }, [attemptUnsnooze, planForwardNavigation, snoozeThread], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; // One exact actionable set: keys whose rows are actually rendered // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what // the actions will touch. const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( (threadKey) => threadByKeyRef.current.has(threadKey), ); if (threadKeys.length === 0) return; const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date().toISOString(); const snoozableThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); const canSnoozeSelection = snoozableThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow }), ); const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ { id: "settle", label: `Settle (${count})` }, ...(canSnoozeSelection ? [ { id: "snooze", label: `Snooze (${count})`, children: snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, label: `${preset.label} (${preset.whenLabel})`, })), }, ] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], position, ), ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { const preset = snoozePresets.find( (candidate) => `snooze:${candidate.id}` === clicked.value, ); if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); for (const thread of snoozableThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, }); } clearSelection(); } return; } if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that // are already explicitly settled are skipped: nothing to do on a // valid mixed selection. const coSettlingKeys = new Set(threadKeys); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); if (!thread || thread.settledOverride === "settled") continue; attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); } clearSelection(); return; } if (clicked.value === "mark-unread") { for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); markThreadUnread(threadKey, thread?.latestTurn?.completedAt); } clearSelection(); return; } if (clicked.value !== "delete") return; if (confirmThreadDelete) { const confirmed = await settlePromise(() => api.dialogs.confirm( [ `Delete ${count} thread${count === 1 ? "" : "s"}?`, "This permanently clears conversation history for these threads.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } // Grown as deletions actually land, never seeded with the whole batch: // orphaned-worktree detection must only discount threads that are // really gone, or the first delete would treat still-alive batch mates // as deleted and remove a worktree they still point at. const deletedThreadKeys = new Set(); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); if (!thread) continue; const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to delete threads", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } deletedThreadKeys.add(threadKey); } removeFromSelection(threadKeys); }, [ attemptSettle, attemptSnooze, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, removeFromSelection, serverConfigs, ], ); const handleThreadContextMenu = useCallback( (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { void (async () => { const api = readLocalApi(); if (!api) return; const threadKey = scopedThreadKey(threadRef); const selectionState = useThreadSelectionStore.getState(); if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { await handleMultiSelectContextMenu(position); return; } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; // Un-settle works on every settled row: for explicit settles it // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => api.contextMenu.show( [ ...(thread.branch ? [ { id: "new-thread-on-branch", label: `New thread on ${thread.branch}`, }, ] : []), ...(supportsSettlement ? [ isSettled ? { id: "unsettle", label: "Un-settle thread" } : { id: "settle", label: "Settle thread" }, ] : []), ...(supportsSnooze ? [ isSnoozed ? { id: "unsnooze", label: "Wake thread" } : { id: "snooze", label: "Snooze", disabled: !canSnooze(thread, { now: new Date().toISOString() }), children: snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, label: `${preset.label} (${preset.whenLabel})`, })), }, ] : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ), ); if (clicked._tag === "Failure") return; if (clicked.value?.startsWith("snooze:")) { const preset = snoozePresets.find( (candidate) => `snooze:${candidate.id}` === clicked.value, ); if (preset) attemptSnooze(threadRef, preset); return; } switch (clicked.value) { case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. const result = await settlePromise(() => handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), { branch: thread.branch, worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", startFromOrigin: false, }), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Could not create thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } case "settle": attemptSettle(threadRef); return; case "unsettle": attemptUnsettle(threadRef); return; case "unsnooze": attemptUnsnooze(threadRef); return; case "rename": startThreadRename(threadRef, thread.title); return; case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => api.dialogs.confirm( [ `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } const result = await deleteThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to delete thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); return; } return; } default: return; } })(); }, [ attemptSettle, attemptSnooze, attemptUnsettle, attemptUnsnooze, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, markThreadUnread, serverConfigs, startThreadRename, ], ); // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as // v1 — the keybinding layer is shared, only the ordered list differs. const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { terminalFocus: isTerminalFocused(), terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }, }); const navigateToThreadKey = (targetThreadKey: string | null) => { if (!targetThreadKey) return false; const targetThread = threadByKey.get(targetThreadKey); if (!targetThread) return false; event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return true; }; const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { navigateToThreadKey( resolveAdjacentThreadId({ threadIds: orderedThreadKeys, currentThreadId: routeThreadKey, direction: traversalDirection, }), ); return; } const jumpIndex = threadJumpIndexFromCommand(command ?? ""); if (jumpIndex === null) return; navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); }; window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ keybindings, navigateToThread, orderedThreadKeys, routeTerminalOpen, routeThreadKey, threadByKey, ]); // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. const shortcutModifiers = useShortcutModifierState(); const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, keybindings, { platform: navigator.platform }, ); useEffect(() => { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { if (!node) return; autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. const handleNewThreadClick = useCallback(() => { // One project: nothing to pick, create immediately. if (projectGroups.length <= 1) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, handleNewThread: newThreadContext.handleNewThread, }); return; } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to // chat.new, no platform gating — web users have working shortcuts too. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); return ( <>
    } >
    Search
    {commandPaletteShortcutLabel ? ( {commandPaletteShortcutLabel} ) : null}
    } > {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
    {projectGroups.length > 0 ? (
    {scopedProjectGroup ? ( ) : ( )} {scopedProjectGroup?.displayName ?? "All projects"} setProjectScopeKey(value === "all" ? null : (value as string)) } > All projects {projectGroups.map((project) => { const scopeKey = project.projectKey; return ( {project.displayName} ); })} } > New project
    ) : null}
      {(() => { const renderThreadRow = ( thread: EnvironmentThreadShell, section: "active" | "snoozed" | "settled", ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); // Settled and snoozed are the ONLY things that collapse a // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, // not from the sidebar second-guessing what still matters. const isCard = section === "active"; const rowVariant = isCard ? "card" : "slim"; return ( ); }; const items: ReactNode[] = activeThreads.map((thread) => renderThreadRow(thread, "active"), ); // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when // collapsed); rows only when expanded. Vanishes entirely at // count 0. if (snoozedThreads.length > 0) { items.push(
    • , ); for (const thread of visibleSnoozedThreads) { items.push(renderThreadRow(thread, "snoozed")); } } if (settledThreads.length > 0) { items.push(
    • , ); } for (const thread of renderedSettledThreads) { items.push(renderThreadRow(thread, "settled")); } return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? (
    • ) : null}
    {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
    {projects.length === 0 ? ( <> No projects yet ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" )}
    ) : null}
    { if (!open) setProjectActionsTarget(null); }} > Project settings {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`}
    {projectActionsTarget?.memberProjects.map((member) => (

    {member.environmentLabel ?? "Current environment"}

    {member.workspaceRoot}

    ))}
    {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? (

    Remove this project everywhere

    Deletes all grouped entries and their conversation history.

    ) : null}
    ); }