import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent, type Modifier, } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { resolveEnvironmentMachineKind, type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, XIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells, } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSidebarDropTarget, resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, type SidebarListItem, type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { createSidebarCollisionDetection, createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, resolveThreadPullRequestBadge, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderEntriesByEnvironment, shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Combobox, ComboboxEmpty, ComboboxSearchInput, ComboboxItem, ComboboxList, ComboboxPopup, ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { composerDraftHasUserContent, DraftId, useComposerDraftStore, useThreadHasUnsentDraft, type ComposerThreadDraftState, type DraftSessionState, } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; } function threadTimeLabel(thread: SidebarThreadSummary): string { const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } // Settled rows read "how long ago did this wrap up", matching their sort // key: both go through resolveSettledThreadTimestamp so label and order can't // disagree. function settledTimeLabel(thread: SidebarThreadSummary): string { const timestamp = resolveSettledThreadTimestamp(thread); return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } // Floats at the row's right edge, vertically centered, while the jump // modifier is held. An overlay pill instead of an inline slot: the hint // must neither displace the status/time label (holding ⌘ used to blank // out "Working") nor shift any layout when it appears. pointer-events-none // so it never swallows clicks meant for the settle/un-settle buttons it // can overlap. function JumpHintBadge(props: { label: string }) { return ( {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)}; } const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } function SidebarThreadTooltip({ thread, project, projectDisplayName, environmentLabel, environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, modelLabel, branchMismatch, terminalStatus, terminalProcessCount, }: { thread: SidebarThreadSummary; project: ProjectFaviconProject | null; projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntry: ProviderInstanceEntry | null; showInstanceBadge: boolean; modelInstanceId: string; modelLabel: string; branchMismatch: { threadBranch: string; currentBranch: string; } | null; terminalStatus: TerminalStatusIndicator | null; terminalProcessCount: number; }) { const driverKind = providerEntry?.driverKind ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return (
{thread.title}
{projectDisplayName ? (
{project ? : null}
{projectDisplayName}
) : null} {environmentLabel ? (
{environmentLabel}
) : null} {thread.branch ? (
{thread.branch}
) : null} {branchMismatch ? (
You're currently checked out on another branch.
) : null} {driverKind ? (
{showInstanceBadge && providerEntry ? `${modelLabel} · ${providerEntry.displayName}` : modelLabel}
) : null} {terminalStatus ? (
{terminalProcessLabel(terminalProcessCount)}
) : null} {thread.session?.lastError ? (
Error occurred
) : null}
{supportsMultiplePullRequests && thread.pullRequests.length > 0 ? (
) : 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; timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), [open, timestampFormat], ); return ( 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" /> } /> } > Snooze thread {presets.map((preset) => ( ))} ); } // Subset of useSortable applied to a thread row's root
  • . Listeners go // on the whole row (no dedicated handle): the pointer sensor's distance // constraint keeps plain clicks working, and we skip dnd-kit's aria // attributes since there is no keyboard sensor and the row body already // carries its own button semantics. type SortableThreadRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; function SortableThreadRow(props: { id: string; disabled: boolean; children: (bag: SortableThreadRowBag) => ReactNode; }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, disabled: { draggable: props.disabled }, animateLayoutChanges: animateSidebarLayoutChanges, }); // dnd-kit memoizes each field but not the bag, so the memoized row would // rerender on every shell update without this. const bag = useMemo( () => ({ listeners, setNodeRef, transform, transition, isDragging }), [listeners, setNodeRef, transform, transition, isDragging], ); return props.children(bag); } // Unsent work shares one look: the new-thread draft rows and thread rows // with unsent composer text both use this tint and pen so they read alike. const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; // Structural list items — the section headers and the // empty-section placeholders — take part in the sortable list so they shift // with the rows and the gap can open on either side of them. They can't be // picked up, and a marker is the sortable `over` when the pointer is on it, // which resolveSidebarDropTarget turns into the section the gap sits in. function SortableSidebarMarker(props: { marker: SidebarListMarker; className?: string; children?: ReactNode; "data-testid"?: string; }) { const { setNodeRef, transform, transition } = useSortable({ id: sidebarMarkerId(props.marker), disabled: { draggable: true }, animateLayoutChanges: animateSidebarLayoutChanges, }); return (
  • ); } // Empty targets stay measurable without reserving space at rest. The sorting // strategy opens their hint space during a drag. function SidebarSectionPlaceholder(props: { marker: "active-placeholder" | "settled-placeholder"; label: string; showHint: boolean; isDropTarget: boolean; }) { return ( {props.showHint ? (
    {props.label}
    ) : null}
    ); } // Zero-height markers reserve no label space at rest. During a drag the // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; visible: boolean; isDropTarget: boolean; }) { return ( {props.visible ? (
    {props.label}
    ) : null}
    ); } // Shelf headers stay visible and keep their measured height while dragging. function SidebarSectionHeader(props: { marker: "snoozed-header" | "settled-header"; label: string; // While dragging, the settled header reads at full strength and takes the // accent while the lifted row is over it. dragging?: boolean; isDropTarget?: boolean; toggle: { expanded: boolean; onToggle: () => void }; }) { const snoozed = props.marker === "snoozed-header"; const className = cn( "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium", snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60", props.dragging && "text-sidebar-foreground/80", props.isDropTarget && "text-primary", ); const content = ( <> {props.label} ); return ( ); } // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it — // clicking is a plain navigation to /draft/$draftId, which touches nothing. // While the draft is open the row renders a frozen snapshot (see // SidebarDraftBlock); memoized so per-keystroke block re-renders skip it // entirely. const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; session: DraftSessionState; composer: ComposerThreadDraftState; project: ProjectFaviconProject | null; projectDisplayName: string | null; isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? ""; // images mirrors persistedAttachments once rehydration finishes; before // that only the persisted list is populated, hence max not sum. const attachmentCount = Math.max(composer.images.length, composer.persistedAttachments.length) + composer.files.length + composer.terminalContexts.length + composer.elementContexts.length + composer.previewAnnotations.length + composer.reviewComments.length; const preview = promptPreview.length > 0 ? promptPreview : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { // Keys targeting the nested discard button belong to the button: // preventDefault here would swallow Space's synthesized click and // navigate instead of discarding. if ((event.target as HTMLElement).closest("button")) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onNavigate(draftId); } }, [draftId, onNavigate], ); const handleDiscard = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onDiscard(draftId); }, [draftId, onDiscard], ); return (
  • {props.project ? ( ) : null} {props.projectDisplayName} } /> Discard draft
    {preview}
  • ); }); interface SidebarDraftRowData { draftId: DraftId; session: DraftSessionState; composer: ComposerThreadDraftState; } // Draft sessions with user content, surfaced above the pinned block so an // interrupted "new thread" stays one click away. Self-contained (own store // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); // The open draft's row is FROZEN at the moment the draft became the route: // it stays visible (like a thread row) but never repaints while the user // types. A draft that was never navigated away from has no snapshot to // freeze, so a fresh typing session shows no row at all. Captured // synchronously on route change (setState-during-render derived state) so // the row never flickers out for a frame between route change and capture. const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; }>({ routeDraftId: null, row: null }); if (frozenActive.routeDraftId !== props.routeDraftId) { let row: SidebarDraftRowData | null = null; if (props.routeDraftId !== null) { const draftId = DraftId.make(props.routeDraftId); const store = useComposerDraftStore.getState(); const session = store.getDraftSession(draftId); const composer = store.getComposerDraft(draftId); row = session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) ? { draftId, session, composer } : null; } setFrozenActive({ routeDraftId: props.routeDraftId, row }); } const drafts = useMemo(() => { const rows: SidebarDraftRowData[] = []; // Every non-promoted session with content gets a row, mapped or not: // new-thread surfaces mint fresh drafts and leave invested ones behind // unmapped, so the mapping only knows about the latest per project. for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { if (session.promotedTo != null) { continue; } if ( props.scopedProjectKeys !== null && !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) ) { continue; } if (draftKey === props.routeDraftId) { // Open draft: render the frozen entry snapshot, or nothing for a // draft that has never been left. Gated on the LIVE session above so // send/discard still removes the row immediately. if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { rows.push(frozenActive.row); } continue; } const composer = draftsByThreadKey[draftKey]; if (!composer || !composerDraftHasUserContent(composer)) { continue; } rows.push({ draftId: DraftId.make(draftKey), session, composer }); } rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); return rows; }, [ draftThreadsByThreadKey, draftsByThreadKey, frozenActive, props.routeDraftId, props.scopedProjectKeys, ]); const handleDiscard = useCallback( (draftId: DraftId) => { // The /draft/$draftId route redirects home on its own when the draft // it renders disappears, so discarding the open draft needs no // special-casing here. releaseComposerDraftUploads(draftId); clearDraftThread(draftId); }, [clearDraftThread], ); if (drafts.length === 0) { return null; } return ( <> {drafts.map(({ composer, draftId, session }) => { const projectKey = `${session.environmentId}:${session.projectId}`; return ( ); })}
  • ); }); // Verb and icon on the lifted row while it hovers over another section. Uses // the same icons as the row actions and context menu so the drop reads as the // action it performs. const dropVerbBadge: Record = { pin: ( <> Pin ), unpin: ( <> Unpin ), settle: ( <> Settle ), unsettle: ( <> Un-settle ), wake: ( <> Wake ), }; const SidebarThreadRow = memo(function SidebarThreadRow(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; // Pinned threads show the same pin marker in active, settled, and snoozed // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present on rows whose server supports every drop outcome: dnd-kit // sortable bag applied to the row root so the whole row drags (the // pointer sensor's distance constraint keeps plain clicks working). sortable?: SortableThreadRowBag | undefined; dropVerb: SidebarDropVerb | null; // While dragging, the pin marker stays only for a pinned thread still over // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: 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; openPullRequestsInRightPanel: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; project: EnvironmentProject | null; projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; 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; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; /** * External files dropped onto this row. The row highlights while the drag * is over it; the callback opens the thread and hands the files to its * composer. Absent when the sidebar cannot open server threads. */ onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; }) { const { isRenaming, onCancelRename, onCommitRename, onContextMenu, onAcknowledgeWoke, onFileDropThreads, onRenameTitleChange, onSettle, onSnooze, onStartRename, onThreadActivate, onThreadClick, onUnsettle, onUnsnooze, onUnpin, openPullRequestsInRightPanel, renamingTitle, thread, variant, variantAction, } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); const handleDiscardDraftClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); releaseComposerDraftUploads(threadRef); clearComposerContent(threadRef); }, [clearComposerContent, threadRef], ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, leaseLiveStatus, thread.pullRequests, thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const visibleGitStatus = useRetainedValue( JSON.stringify([thread.environmentId, gitCwd]), gitStatus.data, ); const pr = linkedPullRequestStatus?.pr ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const currentLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); const isInFlight = status === "working" || status === "monitoring" || status === "approval" || status === "input"; // 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 the pill clears only when the user re-engages: // reading a completion-triggered wake, clicking the pill, sending a // message, settling, archiving, or a change request state that settles the // thread. Timer wakes survive a mere visit. An unparseable visit timestamp // counts as never-visited, so corrupt local data cannot 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) && thread.settledOverride !== "settled"; // Background work always recedes when it is not selected: an unread parent // completion must not pull a still-working thread back into the foreground. // Ready and action-required rows keep their unread and wake prominence. const shouldRecede = shouldRecedeSidebarThread({ status, isUnread, isWoke, isActive: 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, // No shimmer: a label that animates forever is noise in a sidebar // full of them (and repaints every vsync on high-refresh displays). // Working is a background state, so it rests at the dim end of what // the old pulse cycled through; only the thread you have open gets // the label at full strength. className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"), } : status === "monitoring" ? { // Monitoring is calm background presence, not active progress // (monitoring-pill D6), so it keeps the label at full strength. label: "Monitoring", icon: null, className: "text-sky-600 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 isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: visibleGitStatus?.refName ?? null, }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; const showInstanceBadge = providerEntry !== null && shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; // The local environment is "this machine" and needs no marker; every other // one gets its machine glyph. With no local environment (the hosted app) // that is every thread, which is the point: the glyph is what tells rows on // different machines apart. const isRemote = thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( ); const handleClick = useCallback( (event: ReactMouseEvent) => { onThreadClick(event, threadRef); }, [onThreadClick, threadRef], ); const handleAcknowledgeWokeClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); if (props.wokeAt === null) return; onAcknowledgeWoke(threadRef, props.wokeAt); }, [onAcknowledgeWoke, props.wokeAt, 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 [isFileDragOver, setIsFileDragOver] = useState(false); const fileDropHandlers = useMemo( () => onFileDropThreads ? makeWorkspaceFileDropHandlers({ setDragActive: setIsFileDragOver, addFiles: (files) => { onFileDropThreads(threadRef, files); }, }) : null, [onFileDropThreads, threadRef], ); // A drop lands on a child or outside the window entirely, so dragend is // the reset of last resort for the row's highlight. useEffect(() => { if (!isFileDragOver) return; const clearFileDrag = () => setIsFileDragOver(false); window.addEventListener("dragend", clearFileDrag); return () => window.removeEventListener("dragend", clearFileDrag); }, [isFileDragOver]); const renameCommittedRef = useRef(false); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; }, [isRenaming]); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { event.stopPropagation(); if (event.nativeEvent.isComposing || event.keyCode === 229) return; 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 handleUnpinClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); onUnpin(threadRef); }, [onUnpin, 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 and // suppress the row tooltip so its portal cannot overlap the popover. 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) => { const url = pr?.url ?? currentLinkedPr?.url; if (!url) return; const openedInRightPanel = openPrLink( event, url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { onThreadActivate(threadRef); } }, [ onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, currentLinkedPr, props.isActive, threadRef, ], ); // All sidebar 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/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" : hasUnsentDraft ? cn(draftSurfaceClassName, "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", isFileDragOver && "ring-1 ring-inset ring-primary/70", // The hover tint must not clobber an active/selected row's own surface. isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", // The lifted row is an opaque card so the rows beneath it never show // through. The row tint is translucent in dark themes and the pointer // keeps the hover color applied, so both the tint and the solid sidebar // color are stacked as background images. props.sortable?.isDragging && "bg-[linear-gradient(var(--sidebar-row-active),var(--sidebar-row-active)),linear-gradient(var(--sidebar),var(--sidebar))] text-sidebar-foreground opacity-100 shadow-lg", ); // dnd-kit props for the row root. Same bag on both variants: every row in // the list translates around the gap as the drag passes it. const sortable = props.sortable; const sortableRootProps = sortable ? { ref: sortable.setNodeRef, style: { transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition, // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: !sortable.isDragging && sortable.transform?.scaleY === 0 ? ("hidden" as const) : undefined, }, ...sortable.listeners, } : {}; const dragDestination = sortable?.isDragging && props.dropVerb !== null ? ( {dropVerbBadge[props.dropVerb]} ) : null; 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} ); // Stacks show their layer count; multiple unrelated links show their total count. // Plain clicks open T3; individual PR links also support opening the host in a new tab. const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) : null; const handlePrStackClick = useCallback(() => { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; const terminalStatusIcon = terminalStatus ? ( ) : null; // Same pen the new-thread draft rows lead with, so both kinds of unsent // work read the same way in the list. const draftIndicator = hasUnsentDraft ? ( } > Unsent draft ) : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); const pinIndicator = showPin ? ( props.pinningSupported && !sortable?.isDragging ? ( } > Unpin thread ) : ( ) ) : null; if (variant === "slim") { return (
  • } > {/* Settled history recedes: dimmed favicon at rest, restored on hover so the tail stays scannable when you're hunting. */} {props.project ? : null} {draftIndicator} {title} {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( Regenerating title ) : null} {/* 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} {sortable?.isDragging ? ( dragDestination ) : ( {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 } /> Dismiss Woke notification ) : ( {variantAction === "unsettle" ? settledTimeLabel(thread) : threadTimeLabel(thread)} )} {variantAction === "unsnooze" ? ( !props.snoozeSupported ? null : ( ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( } > Un-settle thread ) : ( )} )} {props.jumpLabel ? : null} {detailsTooltip}
  • ); } const diff = latestTurnDiff(thread); return (
  • } >
    {draftIndicator} {props.project ? ( ) : null} {props.projectDisplayName ? ( {props.projectDisplayName} ) : ( )} {pinIndicator} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim space without either state overlapping it. */} {sortable?.isDragging ? ( dragDestination ) : ( {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} {topStatus ? ( isWokeStatus ? ( {topStatus.label} } /> Dismiss Woke notification ) : ( {topStatus.icon === "working" ? ( ) : topStatus.icon === "done" ? ( ) : 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 || hasUnsentDraft ? ( {hasUnsentDraft ? ( } > Discard draft ) : null} {showSnoozeButton ? ( ) : null} {props.settlementSupported ? ( } > Settle Settle thread ) : null} ) : null} )}
    {title} {isRegeneratingTitle ? ( Regenerating title ) : null}
    {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} {thread.branch ? ( <> {thread.branch} ) : ( )} {terminalStatusIcon} {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; } const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; project: EnvironmentProject | null; projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; isHighlighted: boolean; isRouteActive: boolean; resultId: string; onHighlight: () => void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], ); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( props.isHighlighted || props.isRouteActive, ); // Same details tooltip as the regular rows: a search hit is still a thread, // and the hover card is how you disambiguate identically-titled results. const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); const visibleGitStatus = useRetainedValue( JSON.stringify([thread.environmentId, gitCwd]), gitStatus.data, ); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, currentGitBranch: visibleGitStatus?.refName ?? null, }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const showInstanceBadge = providerEntry !== null && shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); const modelLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const [isFileDragOver, setIsFileDragOver] = useState(false); const fileDropHandlers = useMemo( () => makeWorkspaceFileDropHandlers({ setDragActive: setIsFileDragOver, addFiles: (files) => { props.onFileDropThreads(threadRef, files); }, }), [props.onFileDropThreads, threadRef], ); useEffect(() => { if (!isFileDragOver) return; const clearFileDrag = () => setIsFileDragOver(false); window.addEventListener("dragend", clearFileDrag); return () => window.removeEventListener("dragend", clearFileDrag); }, [isFileDragOver]); return (
  • } > {props.project ? ( ) : null} {thread.title} {threadTimeLabel(thread)}
  • ); }); export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, pinThread, unpinThread, confirmAndUnpinThread, reorderPinnedThread, reorderActiveThread, archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const { copyToClipboard: copyPathToClipboard } = 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 { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ target: "branch name", onCopy: ({ branch }) => { toastManager.add({ type: "success", title: "Branch copied", description: branch, }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy branch", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: ({ threadId }) => { toastManager.add({ type: "success", title: "Thread ID copied", description: threadId, }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy thread ID", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); 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 markThreadVisited = useUiStateStore((s) => s.markThreadVisited); const acknowledgeWoke = useCallback( (threadRef: ScopedThreadRef, visitedAt: string) => { markThreadVisited(scopedThreadKey(threadRef), visitedAt); }, [markThreadVisited], ); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), }); const routeDraftThread = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); const routeThreadRef = useMemo( () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), [routeDraftThread, routeTarget], ); 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 environmentMachineById = useMemo( () => new Map( environments.map( (environment) => [ environment.environmentId, resolveEnvironmentMachineKind(environment.serverConfig), ] 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 projectGroupsRef = useRef(projectGroups); projectGroupsRef.current = projectGroups; const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids // are driver slugs, so a flat map would collide across environments. const providerEntriesByEnvironment = useMemo( () => deriveProviderEntriesByEnvironment( [...serverConfigs].map( ([environmentId, config]) => [environmentId, config.providers] as const, ), ), [serverConfigs], ); // Rows read the project record for its icon and cwd. Group labels can include // a repository owner or a different title, so they travel separately. const projectByKey = useMemo( () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); const projectDisplayNameByKey = useMemo( () => new Map( projectGroups.flatMap((group) => group.memberProjects.map( (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, ), ), ), [projectGroups], ); 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); // 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. // The selection lives in the persisted UI store next to the other sidebar // project preferences, so routes that unmount the sidebar (Settings) and // app restarts keep it. const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( () => [ { value: "all", label: "All projects" }, ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName, })), ], [projectGroups], ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), [projectGroups], ); const selectedProjectScopeItem = useMemo( () => projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ?? projectScopeItems[0]!, [projectScopeItems, projectScopeKey], ); const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( reduceSidebarProjectScopeMenuState, { open: false, query: "" }, ); const projectScopeFilter = useComboboxFilter(); // Filtering derives from the same React state that controls the input, so // the visible query and the visible list can never desync — the peer wiring // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope // reset, not a searchable entry: it only shows while a project scope is // active (there is something to reset) and the query is empty, so it can't // outrank a project match under autoHighlight and no-hit queries reach the // empty state. const filteredProjectScopeItems = useMemo( () => filterSidebarProjectScopeItems({ items: projectScopeItems, activeScopeKey: projectScopeKey, query: projectScopeMenuState.query, matches: (item, query) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), }), [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], ); 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], ); // A persisted scope whose project is gone falls back to all projects, but // only after every catalog environment has a live project snapshot. Cached // or disconnected environments cannot establish that the project is gone. const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); useEffect(() => { if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { setProjectScopeKey(null); } }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from // re-rendering the whole sidebar. Approximates the block's row filter // (every non-promoted session with content); it can overcount by one for // an open never-left draft, which only softens the empty state. const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; const visibleDraftSessionCount = useComposerDraftStore((store) => { let count = 0; for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { if (session.promotedTo != null) { continue; } if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { continue; } if ( scopedProjectKeys !== null && !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) ) { continue; } count += 1; } return count; }); // 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 openProjectSettings = useCallback( (projectGroup: SidebarProjectSnapshot) => { if (isMobile) { setOpenMobile(false); } void router.navigate({ to: "/projects/$projectKey", params: { projectKey: projectGroup.projectKey }, }); }, [isMobile, router, setOpenMobile], ); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); const handleProjectSettings = useCallback( ( event: ReactMouseEvent | ReactKeyboardEvent, projectGroup: SidebarProjectSnapshot, ) => { event.preventDefault(); event.stopPropagation(); suppressNextScopeChangeRef.current = true; dispatchProjectScopeMenu({ type: "project-settings-opened" }); openProjectSettings(projectGroup); }, [openProjectSettings], ); // Keep a dropped row at its destination while its server applies the // lifecycle command and any order-key writes. The next pickup waits for // this hold so a second drop cannot replace an unconfirmed placement. const [optimisticDrop, setOptimisticDrop] = useState<{ readonly key: string; readonly sourceSection: SidebarSection; readonly section: "pinned" | "active" | "settled"; readonly occurredAt: string; readonly clearsSnooze: boolean; /** Full destination order for pinned and active drops. */ readonly order: readonly string[] | null; /** Destination order keys before the drop, to recognize concurrent writes. */ readonly keysAtDrop: ReadonlyMap; /** The keys this drop writes (one per planned assignment). The override holds until all of them appear in canonical state. */ readonly assignedKeys: ReadonlyMap; } | null>(null); const { pinnedThreads, draggableThreadKeys, activeReorderableThreadKeys, activeThreads, snoozedThreads, settledThreads, snoozeNow, } = useMemo(() => { // 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 pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); const activeReorderable = new Set(); for (const thread of visible) { const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // 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 = capabilities?.threadSettlement === true; const supportsSnooze = capabilities?.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); // Older servers retain their existing drag actions. Active placement // additionally requires its own ordering capability at the drop target. if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { draggable.add(threadKey); } if (optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, optimisticDrop.section, optimisticDrop.occurredAt, optimisticDrop.assignedKeys.get(threadKey), ); (optimisticDrop.section === "pinned" ? pinned : optimisticDrop.section === "settled" ? settled : active ).push( optimisticDrop.clearsSnooze ? projected : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, ); } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { // Snooze outranks settlement and pinning until the thread wakes. snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); } else { active.push(thread); } } // One shared rule on every platform (see sortPinnedThreadsByOrderKey): // user-arranged keys first, keyless threads in creation order below. // Server capability only gates DRAGGING — it must not influence the // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortThreadsForSidebar(active); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null ? sortedPinned : orderItemsByPreferredIds({ items: sortedPinned, preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), draggableThreadKeys: draggable, activeReorderableThreadKeys: activeReorderable, activeThreads: optimisticDrop?.section !== "active" || optimisticDrop.order === null ? sortedActive : orderItemsByPreferredIds({ items: sortedActive, preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), // 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: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) .join("\0"); useEffect(() => { setActiveSearchResultIndex(0); }, [threadSearchResultOrderKey]); useEffect(() => { if (!isSearchingThreads) return; document .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) ?.scrollIntoView({ block: "nearest" }); }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); // 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] = useLocalStorage( SETTLED_SHELF_EXPANDED_KEY, false, Schema.Boolean, ); const toggleSettledShelf = useCallback( () => setSettledShelfExpanded((value) => !value), [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [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] = useLocalStorage( SNOOZED_SHELF_EXPANDED_KEY, false, Schema.Boolean, ); const toggleSnoozedShelf = useCallback( () => setSnoozedShelfExpanded((value) => !value), [setSnoozedShelfExpanded], ); 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 EMPTY_THREADS; const routeThread = snoozedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], [pinnedThreads, 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 { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); // 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); } return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); // Dropping files on a row opens that thread and attaches the files there. // The composer only accepts drops for its OWN thread, so when the row is // not the open thread we stash the files and let ChatView hand them over // once the navigation actually lands; if the route bounced (thread gone), // nothing will consume them, so clear instead of surprising the user later. const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const handleThreadFileDrop = useCallback( async (threadRef: ScopedThreadRef, files: File[]) => { // Queued, not replaced: a second drop before the thread opens keeps // both files, and the id lets cleanup below touch only this drop. const dropId = queuePendingFileDrop({ threadRef, files }); // Key match alone is not "already there": during draft promotion the // resolved route key is the server thread while the URL is still the // draft route, and its composer would swallow the drop then discard it. const landedBefore = router.buildLocation({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }).pathname === router.state.location.pathname; if (landedBefore) return; try { await navigateToThread(threadRef); // A newer drop may have arrived while the navigation was in flight; // clearing by id leaves those files untouched. const landed = router.buildLocation({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }).pathname === router.state.location.pathname; if (!landed) { clearPendingFileDrop(dropId); } } catch { // Navigation failed outright; nothing will consume this drop, but a // newer drop for the same thread may still be deliverable. clearPendingFileDrop(dropId); } }, [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], ); const navigateToDraft = useCallback( (draftId: DraftId) => { // Unconditional: also drops a stale selection anchor left by // plain-click navigation, so a later shift-click starts fresh // instead of ranging from a row that is no longer the context. // (clearSelection no-ops when there is nothing to clear.) clearSelection(); if (isMobile) { setOpenMobile(false); } void router.navigate({ to: "/draft/$draftId", params: { draftId } }); }, [clearSelection, isMobile, router, setOpenMobile], ); const clearThreadSearch = useCallback(() => { setThreadSearchQuery(""); setActiveSearchResultIndex(0); }, []); const selectThreadSearchResult = useCallback( (thread: EnvironmentThreadShell) => { clearThreadSearch(); navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); }, [clearThreadSearch, navigateToThread], ); const handleThreadSearchKeyDown = useCallback( (event: ReactKeyboardEvent) => { // IME composition (Japanese/Chinese input) uses the same keys; committing // a candidate must not move the highlight or navigate away mid-compose. if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Escape" && isSearchingThreads) { event.preventDefault(); event.stopPropagation(); clearThreadSearch(); return; } if (threadSearchResults.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveSearchResultIndex( (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length, ); return; } if (event.key === "Enter") { event.preventDefault(); const result = threadSearchResults[activeSearchResultIndex]; if (result) selectThreadSearchResult(result); } }, [ activeSearchResultIndex, clearThreadSearch, isSearchingThreads, selectThreadSearchResult, threadSearchResults, ], ); 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) => { if (isSidebarNestedLinkClick(event.target)) return; 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], ); const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), [], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { threadListRef.current = node; listMotionRef.current?.dispose(); listMotionRef.current = node === null ? null : createSidebarListMotion(node); listMotionRef.current?.update(false); }, []); // Hold the chosen section and order until every key write arrives. This // also covers first-time ordering, which assigns keys to keyless neighbors. // A failed write, concurrent reorder, or membership change releases the hold. const [dragState, setDragState] = useState<{ readonly activeKey: string; readonly activeSection: SidebarSection; readonly occurredAt: string; readonly activationY: number | null; readonly targetSection: SidebarSection | null; } | null>(null); const dragTargetSection = dragState?.targetSection ?? null; const dragSensorRef = useRef(null); const finishThreadDrag = useCallback((started: boolean) => { dragSensorRef.current = null; if (started) { listMotionRef.current?.release(); setDragState(null); } }, []); const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => { dragSensorRef.current = sensor; }, []); const cancelThreadDrag = useCallback(() => { dragSensorRef.current?.cancel(); }, []); const dndSensors = useSensors( useSensor(SidebarPointerSensor, { distance: 6, onAttach: attachDragSensor, onFinish: finishThreadDrag, }), ); const sectionByThreadKey = useMemo(() => { const map = new Map(); const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { for (const thread of list) { map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section); } }; add(pinnedThreads, "pinned"); add(activeThreads, "active"); add(snoozedThreads, "snoozed"); add(settledThreads, "settled"); return map; }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); const pinnedKeys = useMemo( () => pinnedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), [pinnedThreads], ); const activeKeys = useMemo( () => activeThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), [activeThreads], ); useEffect(() => { if (optimisticDrop === null) return; const canonicalByKey = new Map( threads.map((thread) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread, ]), ); const thread = canonicalByKey.get(optimisticDrop.key); if (thread === undefined || thread.archivedAt !== null) { setOptimisticDrop(null); return; } const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) ? "snoozed" : thread.settledOverride === "settled" ? "settled" : thread.pinnedAt != null ? "pinned" : "active"; if ( canonicalSection !== optimisticDrop.sourceSection && canonicalSection !== optimisticDrop.section ) { setOptimisticDrop(null); return; } if (optimisticDrop.order === null) { // Settle also emits unpin/unsnooze events. Wait for the entire move // before releasing the projected fields and sort timestamps. if ( canonicalSection === optimisticDrop.section && thread.pinnedAt == null && (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) ) { setOptimisticDrop(null); } return; } if (canonicalSection !== optimisticDrop.section) return; if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); return canonical === undefined ? [] : [canonical]; }); const keyByThread = new Map( canonicalDestination.map((thread) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, ]), ); const heldOrder = optimisticDrop.order; const heldKeys = new Set(heldOrder); const membershipChanged = destinationKeys.length !== heldOrder.length || destinationKeys.some((key) => !heldKeys.has(key)); const foreignKeyLanded = destinationKeys.some((threadKey) => { const currentKey = keyByThread.get(threadKey) ?? null; if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; return currentKey !== optimisticDrop.assignedKeys.get(threadKey); }); const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every( ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey, ); if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { setOptimisticDrop(null); } }, [activeKeys, optimisticDrop, pinnedKeys, threads]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { // Fresh pins take the top of the arranged run: pinThread computes a // key before the smallest key across ALL pinned shells — including // snoozed pins hidden from this list, whose keys are still part of // the run — so the new pin can't land beneath a hidden head. const result = await pinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to pin thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [pinThread], ); const attemptUnpin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { const result = await confirmAndUnpinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to unpin thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [confirmAndUnpinThread], ); const handleThreadDragStart = useCallback( (event: DragStartEvent) => { const activeKey = String(event.active.id); const activeSection = sectionByThreadKey.get(activeKey); if (activeSection === undefined) return; // Stop normal section motion before dnd-kit measures the picked-up row. listMotionRef.current?.suspend(); const list = threadListRef.current; const header = list?.querySelector('[data-testid="sidebar-pinned-header"]'); if (list && header) { const listRect = list.getBoundingClientRect(); const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; dragLabelOffsetRef.current = header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; } else { dragLabelOffsetRef.current = 0; } setDragState({ activeKey, activeSection, targetSection: activeSection, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); }, [sectionByThreadKey], ); // Include every visible row in the measured order. Older servers disable // pickup on their rows without changing where those rows render. const sidebarListItems = useMemo((): readonly SidebarListItem[] => { const rowsOf = ( list: readonly EnvironmentThreadShell[], section: SidebarSection, ): SidebarListItem[] => list.map((thread) => { const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); if ( pinnedThreads.length + activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ) { return []; } const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; const pinnedRows = rowsOf(pinnedThreads, "pinned"); items.push(...pinnedRows); items.push({ kind: "marker", marker: "pinned-divider" }); const activeRows = rowsOf(activeThreads, "active"); items.push({ kind: "marker", marker: "active-placeholder" }); items.push(...activeRows); if (snoozedThreads.length > 0) { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } items.push({ kind: "marker", marker: "settled-header" }); const settledRows = rowsOf(renderedSettledThreads, "settled"); items.push({ kind: "marker", marker: "settled-placeholder" }); items.push(...settledRows); return items; }, [ activeThreads, pinnedThreads, renderedSettledThreads, settledThreads.length, snoozedThreads.length, visibleSnoozedThreads, ]); useEffect(() => { if ( dragState !== null && !sidebarListItems.some((item) => item.kind === "thread" && item.key === dragState.activeKey) ) { cancelThreadDrag(); } }, [cancelThreadDrag, dragState, sidebarListItems]); const listMotionPaused = dragState !== null; // Every shell event rebuilds sidebarListItems, but rows only move when the // rendered order or a row's section changes. Keying the motion pass on that // keeps ordinary updates from forcing a layout read and animating rows // whose position drifted for other reasons. const sidebarListOrderKey = useMemo( () => sidebarListItems .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker)) .join("\0"), [sidebarListItems], ); const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. void sidebarListOrderKey; listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows); }, [ listMotionPaused, routeDraftIdForRows, sidebarListHasRows, sidebarListOrderKey, visibleDraftSessionCount, ]); const handleThreadDragOver = useCallback( (event: DragOverEvent) => { const target = event.over ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) : null; setDragState((current) => current === null || current.activeKey !== String(event.active.id) ? current : { ...current, targetSection: target?.section ?? null }, ); }, [sidebarListItems], ); const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); const draggedSettledOrder = useMemo(() => { const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); if (dragState === null || thread === undefined) return []; const key = (candidate: EnvironmentThreadShell) => scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); return sortSettledThreadsForSidebar([ ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), applySidebarThreadDrop(thread, "settled", dragState.occurredAt), ]).map(key); }, [dragState, settledThreads, threadByKey]); const sidebarSortingStrategy = useMemo( () => createSidebarSortingStrategy({ items: sidebarListItems, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, settledVisibleCount, routeThreadKey, snoozedThreadCount: snoozedThreads.length, }), [ draggedSettledOrder, routeThreadKey, settledShelfExpanded, settledVisibleCount, sidebarListItems, snoozedThreads.length, ], ); // Hidden and filtered threads keep their keys. Reserve those slots without // including the rows in the visible drop order or writing to them. const { pinnedKeysById, activeKeysById } = useMemo( () => ({ pinnedKeysById: new Map( threads.map((thread) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread.pinOrderKey ?? null, ]), ), activeKeysById: new Map( threads.map((thread) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread.activeOrderKey ?? null, ]), ), }), [threads], ); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; const dragActivationY = dragState?.activationY; const dndCollisionDetection = useMemo(() => { if (draggedThreadKey === undefined || draggedFromSection === undefined) return createSidebarCollisionDetection(() => true); const source = threadByKey.get(draggedThreadKey); if (source === undefined) return createSidebarCollisionDetection(() => false); return createSidebarCollisionDetection( (id) => { const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); if (target === null) return false; return ( planSidebarThreadDrop({ activeKey: draggedThreadKey, activeSection: draggedFromSection, activePinned: source.pinnedAt != null, activeSettled: source.settledOverride === "settled", supportsSettlement: serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === true, target, pinnedOrder: pinnedKeys, pinnedKeysById, reorderableKeys: draggableThreadKeys, activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, }).kind !== "none" ); }, { items: sidebarListItems, activationY: dragActivationY ?? null, }, ); }, [ activeKeysById, pinnedKeysById, serverConfigs, activeKeys, activeReorderableThreadKeys, draggedThreadKey, draggedFromSection, dragActivationY, draggableThreadKeys, pinnedKeys, sidebarListItems, threadByKey, ]); const handleThreadDragEnd = useCallback( (event: DragEndEvent) => { const activeKey = String(event.active.id); const activeSection = sectionByThreadKey.get(activeKey); const target = event.over === null ? null : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); const activeThread = threadByKey.get(activeKey); if (activeSection === undefined || target === null || activeThread === undefined) return; const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); const plan = planSidebarThreadDrop({ activeKey, activeSection, activePinned: activeThread.pinnedAt != null, activeSettled: activeThread.settledOverride === "settled", supportsSettlement: serverConfigs.get(activeThread.environmentId)?.environment.capabilities .threadSettlement === true, target, pinnedOrder: pinnedKeys, pinnedKeysById, reorderableKeys: draggableThreadKeys, activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, }); if (plan.kind === "none") return; if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; const assignments = plan.kind === "pin" ? [ ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]), ...plan.extraAssignments, ] : plan.kind === "reorder-pinned" || plan.kind === "move-active" ? plan.assignments : []; const drop = { key: activeKey, sourceSection: activeSection, section: target.section, occurredAt: new Date().toISOString(), clearsSnooze: plan.kind === "pin" || plan.kind === "settle" || (plan.kind === "move-active" && plan.unsnooze), order: plan.kind === "settle" ? null : plan.order, keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById, assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])), }; setOptimisticDrop(drop); void (async () => { const run = async ( operation: Promise>, title: string, ) => { const result = await operation; if (result._tag === "Success") return true; // A late failure must not cancel a newer drag's preview. setOptimisticDrop((current) => (current === drop ? null : current)); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title, description: error instanceof Error ? error.message : "An error occurred.", }), ); } return false; }; switch (plan.kind) { case "settle": { settlingThreadKeysRef.current.add(activeKey); const navigateAfterSettle = planForwardNavigation(activeKey); const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( () => settlingThreadKeysRef.current.delete(activeKey), ); if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); return; } case "move-active": // The drag expresses unpin intent; button/menu confirmation is unchanged. if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) return; if ( plan.unsettle && !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) ) return; if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread"))) return; break; case "pin": if ( !(await run( pinThread( threadRef, plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, ), "Failed to pin thread", )) ) return; break; case "reorder-pinned": break; } // Stop on failure; each successful key write remains a valid placement. const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; for (const assignment of keyWrites) { const thread = threadByKey.get(assignment.id); if (thread === undefined) continue; if ( !(await run( (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( scopeThreadRef(thread.environmentId, thread.id), assignment.orderKey, ), plan.kind === "move-active" ? "Failed to reorder active threads" : "Failed to reorder pinned threads", )) ) return; } })(); }, [ activeKeysById, pinnedKeysById, serverConfigs, activeKeys, activeReorderableThreadKeys, draggableThreadKeys, pinThread, pinnedKeys, planForwardNavigation, reorderPinnedThread, reorderActiveThread, sectionByThreadKey, settleThread, sidebarListItems, threadByKey, unpinThread, unsettleThread, unsnoozeThread, ], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, preset: SnoozePreset, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { const threadKey = scopedThreadKey(threadRef); if (snoozingThreadKeysRef.current.has(threadKey)) { return { status: "skipped" } as const; } 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. return isAtomCommandInterrupted(result) ? ({ status: "interrupted" } as const) : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); } // 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?.(); } return { status: "success" } as const; } finally { snoozingThreadKeysRef.current.delete(threadKey); } }, [planForwardNavigation, snoozeThread], ); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, preset: SnoozePreset, opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { const outcome = await performSnooze(threadRef, preset, opts); if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to snooze thread", description: outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); return; } if (outcome.status !== "success") 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(), timestampFormat)}`, timeout: 5_000, actionProps: { children: "Undo", onClick: () => attemptUnsnooze(threadRef), }, }), ); })(); }, [attemptUnsnooze, performSnooze, timestampFormat], ); 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 selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; const threadKeys = 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(); const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); const titleRegenerationThreads = selectedThreads.filter( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true, ); const regeneratableTitleThreads = titleRegenerationThreads.filter( (thread) => thread.titleRegeneration == null, ); const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ supportedCount: titleRegenerationThreads.length, actionableCount: regeneratableTitleThreads.length, }); // Unpin (k) counts only the pinned rows in pin-capable environments — // on a mixed selection the unpinned rows are untouched, and the item // is omitted entirely when nothing selected is pinned. const pinnedSelectedThreads = selectedThreads.filter( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true && thread.pinnedAt != null, ); const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( [ ...(unpinMenuItem ? [unpinMenuItem] : []), { id: "settle", label: `Settle (${count})` }, ...(canSnoozeSelection ? [ { id: "snooze", label: `Snooze (${count})`, children: snoozePresets.map((preset) => ({ id: `snooze:${preset.id}`, label: `${preset.label} (${preset.whenLabel})`, })), }, ] : []), ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), { 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); clearSelection(); const outcomes = await Promise.all( selectedThreads.map(async (thread) => { const threadRef = scopeThreadRef(thread.environmentId, thread.id); const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); return { outcome, threadRef }; }), ); const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => outcome.status === "success" ? [threadRef] : [], ); const failures = outcomes.flatMap(({ outcome }) => outcome.status === "failure" ? [outcome.error] : [], ); if (snoozedThreadRefs.length > 0) { const snoozedCount = snoozedThreadRefs.length; const failedCount = failures.length; toastManager.add( stackedThreadToast({ type: failedCount > 0 ? "warning" : "success", title: failedCount > 0 ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, description: failedCount > 0 ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` : undefined, timeout: 5_000, actionProps: { children: "Undo", onClick: () => { for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); }, }, }), ); } else if (failures.length > 0) { const firstError = failures[0]; toastManager.add( stackedThreadToast({ type: "error", title: "Failed to snooze threads", description: firstError instanceof Error ? firstError.message : "An error occurred.", }), ); } } return; } if (clicked.value === "unpin") { // Each unpin reports its own failure, like the single-row action. for (const thread of pinnedSelectedThreads) { attemptUnpin(scopeThreadRef(thread.environmentId, thread.id)); } clearSelection(); return; } if (clicked.value === "regenerate-title") { for (const thread of regeneratableTitleThreads) { const result = await updateThreadMetadata({ environmentId: thread.environmentId, input: { threadId: thread.id, regenerateTitle: true }, }); if (result._tag === "Success") continue; if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to regenerate thread titles", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } 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. Pinned rows ARE included: the decider // clears the pin as part of settling, so they park like the rest. 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"), { variant: "destructive" }, ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ entries: threadKeys.map((threadKey) => ({ threadKey })), delete: async ({ threadKey }, deletedThreadKeys) => { const thread = threadByKeyRef.current.get(threadKey); if (!thread) return null; return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); }, }); if (firstFailure !== null) { const firstError = squashAtomCommandFailure(firstFailure); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to delete threads", description: firstError instanceof Error ? firstError.message : "An error occurred.", }), ); } removeFromSelection( getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef !== null && readThreadShell(threadRef) !== null; }), ); }, [ attemptSettle, attemptSnooze, attemptUnpin, clearSelection, confirmThreadDelete, deleteThread, markThreadUnread, performSnooze, removeFromSelection, serverConfigs, attemptUnsnooze, updateThreadMetadata, timestampFormat, ], ); 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; const threadWorkspacePath = thread.worktreePath ?? projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? null; // Un-settle 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 supportsPinning = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned, isSettled, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, pinning: supportsPinning, titleRegeneration: supportsTitleRegeneration, }, snoozePresets, }), 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 "project-settings": { const projectGroup = projectGroupsRef.current.find((group) => group.memberProjectRefs.some( (projectRef) => projectRef.environmentId === thread.environmentId && projectRef.projectId === thread.projectId, ), ); if (projectGroup) openProjectSettings(projectGroup); return; } 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 "pin": attemptPin(threadRef); return; case "unpin": attemptUnpin(threadRef); return; case "rename": startThreadRename(threadRef, thread.title); return; case "regenerate-title": { if (isRegeneratingTitle) return; const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, regenerateTitle: true }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to regenerate thread title", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; case "copy-path": if (!threadWorkspacePath) { toastManager.add( stackedThreadToast({ type: "error", title: "Path unavailable", description: "This thread does not have a workspace path to copy.", }), ); return; } copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); return; case "copy-branch": if (thread.branch) { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; case "archive": { if (confirmThreadArchive) { const confirmed = await settlePromise(() => api.dialogs.confirm(`Archive thread "${thread.title}"?`), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } let didArchive = false; const result = await archiveThread(threadRef, { onArchived: () => { didArchive = true; }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); return; } 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"), { variant: "destructive" }, ), ); 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; } })(); }, [ archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, openProjectSettings, projectByKey, serverConfigs, startThreadRename, updateThreadMetadata, timestampFormat, ], ); // 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 || isCommandPaletteOpen() || isModelPickerOpen()) { 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 terminalFocused = useTerminalFocus(); const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, keybindings, { platform: navigator.platform, context: { terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }, }, ); useEffect(() => { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); // 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( (event?: ReactMouseEvent) => { // One project: nothing to pick, create immediately. Shift+click creates // directly in the current project even with several projects, skipping // the palette picker. if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { 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], ); // The button mirrors chat.new: in multi-project setups both route through // the command palette's "New thread in..." picker, and in single-project // setups both create immediately. In multi-project setups the label is only // the picker's shortcut: falling back to chat.newLocal would advertise the // same shortcut for both the picker and direct create. In single-project // setups both commands create directly, so chat.newLocal is a valid // fallback. The second tooltip line (multi-project only) advertises // shift+click and its keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <>
    { setThreadSearchQuery(event.currentTarget.value); setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} placeholder="Search threads or PRs" aria-label="Search threads" role="combobox" aria-autocomplete="list" aria-expanded={isSearchingThreads && threadSearchResults.length > 0} aria-controls={ isSearchingThreads && threadSearchResults.length > 0 ? "sidebar-thread-search-results" : undefined } aria-activedescendant={ isSearchingThreads && threadSearchResults[activeSearchResultIndex] ? `sidebar-thread-search-result-${activeSearchResultIndex}` : undefined } className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" /> {isSearchingThreads ? ( ) : null}
    } > {projectGroups.length > 1 ? ( {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} New thread in current project: Shift+click {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""} ) : newThreadShortcutLabel ? ( `New thread (${newThreadShortcutLabel})` ) : ( "New thread" )}
    {projectGroups.length > 0 ? (
    item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={projectScopeMenuState.open} onOpenChange={(open) => { if (open) suppressNextScopeChangeRef.current = false; dispatchProjectScopeMenu({ type: "open-changed", open }); }} onItemHighlighted={(item) => { highlightedProjectScopeKeyRef.current = item?.value ?? null; }} value={selectedProjectScopeItem} onValueChange={(item) => { if (suppressNextScopeChangeRef.current) { suppressNextScopeChangeRef.current = false; return; } if (!item) return; setProjectScopeKey(item.value === "all" ? null : item.value); }} > } > {scopedProjectGroup ? ( ) : ( )} {scopedProjectGroup?.displayName ?? "All projects"} { if ( event.defaultPrevented || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey || (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) ) { return; } // Combobox items use virtual focus: keyboard events // stay on this input, not on the highlighted option. const scopeKey = highlightedProjectScopeKeyRef.current; const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; if (project) handleProjectSettings(event, project); }} onChange={(event) => dispatchProjectScopeMenu({ type: "query-changed", query: event.target.value, }) } /> No matching projects. {(item: (typeof projectScopeItems)[number]) => { const project = projectGroupByScopeKey.get(item.value) ?? null; return ( { if (project) handleProjectSettings(event, project); }} > {project ? ( ) : ( )} {item.label} {project ? ( ) : null} ); }} } > New project
    ) : null} } > {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( ) : (

    No threads found

    ) ) : null} {!isSearchingThreads ? (
      {(() => { const renderThreadRowInner = ( thread: EnvironmentThreadShell, section: SidebarSection, sortable?: SortableThreadRowBag, ) => { 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" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( ); }; const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); return ( {(bag) => renderThreadRowInner(thread, section, bag)} ); }; const from = dragState?.activeSection ?? null; const items: ReactNode[] = [ , ]; for (const item of sidebarListItems) { if (item.kind === "thread") { items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } switch (item.marker) { case "pinned-header": items.push( , ); break; case "pinned-divider": items.push( , ); break; case "active-placeholder": items.push( , ); break; case "snoozed-header": items.push( , ); break; case "settled-header": items.push( , ); break; case "settled-placeholder": items.push( , ); break; } } return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? (
    • ) : null}
    ) : null} {!isSearchingThreads && visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
    {projects.length === 0 ? ( <> No projects yet ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" )}
    ) : null}
    ); }