import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } 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 { 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, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, XIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { 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 { 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 { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, planPinnedReorder, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, nextThreadChangeRequestSnapshot, prStatusIndicator, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { 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, ComboboxInput, 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 EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); 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)} ); } function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } function SidebarThreadTooltip({ thread, projectTitle, projectDisplayName, projectCwd, projectFaviconPath, projectIcon, environmentLabel, environmentMachine, providerEntry, showInstanceBadge, modelInstanceId, modelLabel, branchMismatch, terminalStatus, terminalProcessCount, }: { thread: SidebarThreadSummary; projectTitle: string | null; projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | 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; return (
{thread.title}
{projectDisplayName ? (
{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.runtime?.lastError ? (
Error occurred
) : 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 pinned card's root
  • . Listeners go // on the whole card (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 card body already // carries its own button semantics. type SortablePinnedRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; function SortablePinnedThreadRow(props: { id: string; children: (bag: SortablePinnedRowBag) => ReactNode; }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, }); return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } // 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"; // 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; projectTitle: string | null; projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | 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.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: { projectTitleByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; projectIconByKey: 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. clearDraftThread(draftId); }, [clearDraftThread], ); if (drafts.length === 0) { return null; } return ( <> {drafts.map(({ composer, draftId, session }) => { const projectKey = `${session.environmentId}:${session.projectId}`; return ( ); })}
  • ); }); 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; // Renders the pin glyph. Pinned cards keep the full settle/snooze quick // actions: settling clears the pin server-side, and snoozing hides the // card until wake with the pin intact underneath. The glyph is also the // in-row pin state cue (the pinned block has no header), so it always // shows while pinned; it only becomes a clickable unpin quick-action once // the pinning capability is confirmed, and stays a passive marker while // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit // sortable bag applied to the card root so the whole card drags (the // pointer sensor's distance constraint keeps plain clicks working). sortable?: SortablePinnedRowBag | undefined; // 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; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | 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; changeRequestSnapshot: ThreadChangeRequestSnapshot | null; onChangeRequestSnapshot: ( threadKey: string, snapshot: ThreadChangeRequestSnapshot | null, ) => void; }) { const { isRenaming, changeRequestSnapshot, onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, onAcknowledgeWoke, 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.projectCwd; 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 retainTerminalOnBranchMismatch = thread.worktreePath === null; const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, }); // 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 === "waiting" || 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 === "waiting" ? { // Waiting is calm background presence (post-settle background // roster), not active progress, so the label keeps full strength. label: "Waiting", icon: null, className: "text-muted-foreground", } : 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 prProvider = resolveDisplayedThreadPrProvider({ threadBranch: thread.branch, gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, }); const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, gitStatus: visibleGitStatus, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, }); if (nextSnapshot === undefined) return; onChangeRequestSnapshot(threadKey, nextSnapshot); }, [ changeRequestSnapshot, visibleGitStatus, onChangeRequestSnapshot, retainTerminalOnBranchMismatch, thread.branch, threadKey, ]); const modelInstanceId = thread.runtime?.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 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) => { if (!pr?.url) return; const openedInRightPanel = openPrLink( event, pr.url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { onThreadActivate(threadRef); } }, [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, 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", 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", ); 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} ); // A real link so cmd/ctrl+click and middle-click open the host in the // browser. A plain click still opens T3's pull request view. const prBadge = prStatus && pr ? ( event.stopPropagation()} onClick={handlePrClick} className={cn( // Sidebar chrome follows the interface font; tabular digits keep the // number from reflowing as PR states stream in. "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-secondary-label" : cn("text-secondary-label transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} > #{pr.number} ) : 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 pinIndicator = props.isPinned ? ( props.pinningSupported ? ( } > 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. */} {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} {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" ? ( ) : ( )} {props.jumpLabel ? : null} {detailsTooltip}
  • ); } const diff = latestRunDiff(thread); const sortable = props.sortable; return (
  • } >
    {draftIndicator} {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. */} {/* 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 latestRunDiff( 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; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; isHighlighted: boolean; isRouteActive: boolean; resultId: string; onHighlight: () => void; onSelect: () => void; }) { const { thread } = props; 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.projectCwd; 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.runtime?.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); return (
  • } > {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, confirmAndUnpinThread, reorderPinnedThread, 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], ); const projectCwdByKey = useMemo( () => new Map( projects.map((project) => [ `${project.environmentId}:${project.id}`, project.workspaceRoot, ]), ), [projects], ); const projectFaviconPathByKey = useMemo( () => new Map( projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]), ), [projects], ); const projectIconByKey = useMemo( () => new Map( projects.map((project) => [`${project.environmentId}:${project.id}`, project.projectIcon]), ), [projects], ); // Icons use saved titles. Group labels can include a repository owner or a different title. const projectTitleByKey = useMemo( () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), [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); const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // 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], ); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const { pinnedThreads, reorderablePinnedKeys, 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(); // Subagent child threads live in the parent's Agents surface, not the // sidebar roster (v2 models them as real threads with lineage). const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { settled.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. return { pinnedThreads: sortPinnedThreadsForSidebar(pinned), reorderablePinnedKeys: new Set( pinned .filter( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === true, ) .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ), activeThreads: sortThreadsForSidebar(active), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; }, [nowMinute, 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( () => searchSidebarThreadsByTitle(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 []; const routeThread = visibleSettledThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [routeThread]; }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. const [snoozedShelfExpanded, setSnoozedShelfExpanded] = 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 []; const routeThread = snoozedThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? [] : [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 [showJumpHints, setShowJumpHints] = useState(false); // Settled threads are live shells, so opening one is plain navigation: // history stays readable without un-settling, and sending a message or // starting a session un-settles server-side. const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } setSelectionAnchor(scopedThreadKey(threadRef)); if (isMobile) { setOpenMobile(false); } void router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); const 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], ); // Drag-to-reorder for the pinned block. A drop computes ONE fractional key // for the moved thread and sends it to that thread's own server (see // planPinnedReorder for the keyless-neighbor materialization case, which // instead rewrites every key in the section). The optimistic order keeps // the card where it was dropped until EVERY key the drop wrote is // reflected in canonical state — a section rewrite is several sequential // writes, and releasing on the first landed key would expose the // half-written canonical order, reshuffling the block once per write. // A failed write clears the override (the card snaps back) with a toast. // A key we did NOT write landing (a concurrent client's reorder that must // win) and ANY membership change (new pin, unpin, snooze/wake) also // release it: the override can't say where members it never saw belong, // and holding it would launder a stale order into later drags. const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; /** pinOrderKey per thread as of the drop — the baseline that tells a concurrent client's write apart from one of our own landing. */ 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 orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return pinnedThreads; return orderItemsByPreferredIds({ items: pinnedThreads, preferredIds: optimisticPinnedOrder.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); const canonicalKeys = canonical.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); // The override represents one drop against one snapshot of the world. // Release it when the world moves on: membership changed (pin/unpin/ // snooze/wake — the override can't say where members it never saw // belong), a key changed to something we did NOT write (a concurrent // client's reorder that must win), every key we wrote has landed, or // canonical already matches. Releasing on the FIRST landed key instead // of the last exposes the half-written order mid-materialization and // the block visibly reshuffles once per write. const membershipChanged = canonicalKeys.length !== optimisticPinnedOrder.order.length || canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); const foreignKeyLanded = canonical.some((thread, index) => { const threadKey = canonicalKeys[index]!; const currentKey = thread.pinOrderKey ?? null; if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); }); const currentKeyByThreadKey = new Map( canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), ); const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, ); const orderConfirmed = !membershipChanged && canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { setOptimisticPinnedOrder(null); } }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); 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 handlePinnedDragEnd = useCallback( (event: DragEndEvent) => { const activeKey = String(event.active.id); const overKey = event.over === null ? null : String(event.over.id); if (overKey === null || activeKey === overKey) return; const reorderable = orderedPinnedThreads.filter((thread) => reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), ); const keys = reorderable.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); const fromIndex = keys.indexOf(activeKey); const toIndex = keys.indexOf(overKey); if (fromIndex === -1 || toIndex === -1) return; const newOrder = arrayMove([...keys], fromIndex, toIndex); const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); const keysAtDrop = new Map( reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), ); const assignments = planPinnedReorder({ orderedIds: newOrder, keysById: keysAtDrop, movedId: activeKey, }); if (assignments.length === 0) return; setOptimisticPinnedOrder({ order: newOrder, keysAtDrop, assignedKeys: new Map( assignments.map((assignment) => [assignment.id, assignment.orderKey]), ), }); void (async () => { // Sequential, stop on first failure. There is deliberately no // rollback: every key write is a complete, valid placement on its // own, so a partial materialization leaves a sensible order (and // the next drag repairs the rest) — unwinding writes across // servers would trade that for real inconsistency windows. for (const assignment of assignments) { const thread = threadByKey.get(assignment.id); if (thread === undefined) continue; const result = await reorderPinnedThread( scopeThreadRef(thread.environmentId, thread.id), assignment.orderKey, ); if (result._tag === "Failure") { // Any failure — interrupted included — releases the override: // a key that never lands would otherwise hold it until some // unrelated world change came along. setOptimisticPinnedOrder(null); if (isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to reorder pinned threads", description: error instanceof Error ? error.message : "An error occurred.", }), ); return; } } })(); }, [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], ); // 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 threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( (threadKey) => threadByKeyRef.current.has(threadKey), ); if (threadKeys.length === 0) return; const count = threadKeys.length; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date(); 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?.latestRun?.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; } // Grown as deletions actually land, never seeded with the whole batch: // orphaned-worktree detection must only discount threads that are // really gone, or the first delete would treat still-alive batch mates // as deleted and remove a worktree they still point at. const deletedThreadKeys = new Set(); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); if (!thread) continue; const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to delete threads", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } deletedThreadKeys.add(threadKey); } removeFromSelection(threadKeys); }, [ attemptSettle, attemptSnooze, 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 ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? 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.runtime?.status === "running" && thread.runtime.activeRunId != 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.latestRun?.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, projectCwdByKey, 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 shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, keybindings, { platform: navigator.platform }, ); useEffect(() => { setShowJumpHints(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow]); const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { if (!node) return; autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. const handleNewThreadClick = useCallback( (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" 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"}
    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 renderThreadRow = ( thread: EnvironmentThreadShell, section: "pinned" | "active" | "snoozed" | "settled", sortable?: SortablePinnedRowBag, ) => { 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 ( ); }; // Draft block above everything, then the pinned block: // full cards above the inbox, closed by a thin divider (the // pin glyphs carry the meaning, so no header text). Both // vanish entirely at count 0. // Pinned rows render in the one shared pinned order; only // reorder-capable rows register as sortable (legacy-server // pins render in place as plain rows). const items: ReactNode[] = [ , scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ) .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} strategy={verticalListSortingStrategy} > {orderedPinnedThreads.map((thread) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); if (!reorderablePinnedKeys.has(threadKey)) { return renderThreadRow(thread, "pinned"); } return ( {(bag) => renderThreadRow(thread, "pinned", bag)} ); })} , ]; if (pinnedThreads.length > 0) { items.push(
    • , ); } for (const thread of activeThreads) { items.push(renderThreadRow(thread, "active")); } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when // collapsed); rows only when expanded. Vanishes entirely at // count 0. if (snoozedThreads.length > 0) { items.push(
    • , ); for (const thread of visibleSnoozedThreads) { items.push(renderThreadRow(thread, "snoozed")); } } if (settledThreads.length > 0) { items.push(
    • , ); } for (const thread of renderedSettledThreads) { items.push(renderThreadRow(thread, "settled")); } return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? (
    • ) : null}
    ) : 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}
    ); }