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,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import {
scopeProjectRef,
scopeThreadRef,
scopedThreadKey,
} from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import type { TimestampFormat } from "@t3tools/contracts/settings";
import {
AlarmClockIcon,
AlarmClockOffIcon,
CheckIcon,
ChevronDownIcon,
CircleAlertIcon,
CircleCheckIcon,
CircleDashedIcon,
ClockIcon,
FolderIcon,
FolderPlusIcon,
GitBranchIcon,
MessageSquareIcon,
PinIcon,
PlusIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
SquarePenIcon,
TerminalIcon,
Undo2Icon,
XIcon,
} from "lucide-react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { useParams, useRouter } from "@tanstack/react-router";
import {
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { isElectron } from "../env";
import {
resolveShortcutCommand,
shortcutLabelForCommand,
shouldShowThreadJumpHintsForModifiers,
threadJumpCommandForIndex,
threadJumpIndexFromCommand,
threadTraversalDirectionFromCommand,
} from "../keybindings";
import { useShortcutModifierState } from "../shortcutModifierState";
import { isTerminalFocused } from "../lib/terminalFocus";
import { isModelPickerOpen } from "../modelPickerVisibility";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { isMacPlatform } from "~/lib/utils";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { readLocalApi } from "../localApi";
import { 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 { 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 { 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 { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
buildBulkTitleRegenerationContextMenuItem,
formatWorkingDurationLabel,
firstValidTimestampMs,
hasUnseenCompletion,
isSidebarNestedLinkClick,
isTrailingDoubleClick,
orderItemsByPreferredIds,
planPinnedReorder,
resolveAdjacentThreadId,
resolveSettledTimestamp,
resolveSidebarThreadStatus,
searchSidebarThreadsByTitle,
shouldCreateNewThreadInCurrentProject,
resolveWorkingStartedAt,
sortLogicalProjectsForSidebar,
sortPinnedThreadsForSidebar,
sortSettledThreadsForSidebar,
sortThreadsForSidebar,
} from "./Sidebar.logic";
import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
import {
ThreadWorktreeIndicator,
prStatusIndicator,
resolveThreadPr,
settledPrHoverColorClass,
terminalStatusFromRunningIds,
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 { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
import { primaryServerProvidersAtom } from "../state/server";
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
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,
type ComposerThreadDraftState,
type DraftSessionState,
} from "../composerDraftStore";
// Settled-tail paging: recent history is the common lookup; the deep tail
// stays behind an explicit Show more.
const SETTLED_TAIL_INITIAL_COUNT = 10;
const SETTLED_TAIL_PAGE_COUNT = 25;
// Keep the v2 key so existing preferences survive the v2-to-default rename.
const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded";
const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2: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 resolveSettledTimestamp so label and order can't
// disagree.
function settledTimeLabel(thread: SidebarThreadSummary): string {
const timestamp = resolveSettledTimestamp(thread);
return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
}
// Floats at the row's right edge, vertically centered, while the jump
// modifier is held. An overlay pill instead of an inline slot: the hint
// must neither displace the status/time label (holding ⌘ used to blank
// out "Working") nor shift any layout when it appears. pointer-events-none
// so it never swallows clicks meant for the settle/un-settle buttons it
// can overlap.
function JumpHintBadge(props: { label: string }) {
return (
{props.label}
);
}
// Self-ticking so only this span re-renders each second, not the whole row.
function WorkingDuration(props: { startedAt: string | null }) {
const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
const [, setTick] = useState(0);
useEffect(() => {
if (Number.isNaN(startedMs)) return;
const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000);
return () => window.clearInterval(id);
}, [startedMs]);
if (Number.isNaN(startedMs)) return null;
return (
{formatWorkingDurationLabel(Date.now() - startedMs)}
);
}
function terminalProcessLabel(count: number): string {
return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
}
function SidebarThreadTooltip({
thread,
projectTitle,
projectCwd,
projectFaviconPath,
environmentLabel,
driverKind,
modelInstanceId,
modelLabel,
branchMismatch,
terminalStatus,
terminalProcessCount,
}: {
thread: SidebarThreadSummary;
projectTitle: string | null;
projectCwd: string | null;
projectFaviconPath: string | null;
environmentLabel: string | null;
driverKind: ProviderInstanceEntry["driverKind"] | null;
modelInstanceId: string;
modelLabel: string;
branchMismatch: {
threadBranch: string;
currentBranch: string;
} | null;
terminalStatus: TerminalStatusIndicator | null;
terminalProcessCount: number;
}) {
return (
{thread.title}
{projectTitle ? (
{projectTitle}
) : null}
{environmentLabel ? (
{environmentLabel}
) : null}
{thread.branch ? (
{thread.branch}
) : null}
{branchMismatch ? (
You're currently checked out on another branch.
) : null}
{driverKind ? (
{modelLabel}
) : null}
{terminalStatus ? (
{terminalProcessLabel(terminalProcessCount)}
) : null}
{thread.session?.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"
/>
}
>
{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 });
}
// 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;
projectCwd: string | null;
projectFaviconPath: string | null;
isActive: boolean;
onNavigate: (draftId: DraftId) => void;
onDiscard: (draftId: DraftId) => void;
}) {
const { composer, draftId, onDiscard, onNavigate, session } = props;
const promptPreview = composer.prompt.trim().split("\n", 1)[0] ?? "";
// images mirrors persistedAttachments once rehydration finishes; before
// that only the persisted list is populated, hence max not sum.
const attachmentCount =
Math.max(composer.images.length, composer.persistedAttachments.length) +
composer.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.projectTitle}
{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: {
projectDisplayNameByKey: ReadonlyMap;
projectCwdByKey: ReadonlyMap;
projectFaviconPathByKey: 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;
projectCwd: string | null;
projectFaviconPath: string | null;
projectTitle: 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;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
isRenaming,
onChangeRequestState,
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 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;
const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
(thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: null,
);
const pr = resolveThreadPr({
threadBranch: thread.branch,
gitStatus: gitStatus.data,
});
const prState = pr?.state ?? null;
// Same semantics as the legacy sidebar (never-visited counts as read):
// switching sidebars must not light up every historical thread as unread.
const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
const status = resolveSidebarThreadStatus(thread);
// 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 finishing the work outright (merged
// or closed PR). Timer wakes survive a mere visit. An unparseable visit
// timestamp counts as never-visited — corrupt local data must not eat
// the wake signal.
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
prState !== "merged" &&
prState !== "closed";
// In-flight rows (working, or waiting on approval/input) fade as a whole:
// there is nothing for the user to do yet, so prominence is reserved for
// rows that need a human — done (unread), read-but-unsettled, failed, and
// freshly woken. The status label keeps its hue, so waiting rows stay
// findable. In-flight rows recede the same as read-ready ones (inbox-zero:
// working threads aren't your problem yet) — only the colored status label
// stands out.
const isInFlight =
status === "working" || status === "monitoring" || status === "approval" || status === "input";
const shouldRecede =
(status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
// Status hues follow the system-wide convention set by sidebar v1 and the
// mobile Live Activity/widgets (amber approval, indigo input, sky working)
// so a thread reads the same color everywhere it surfaces.
const topStatus =
status === "working"
? {
label: "Working",
icon: "working" as const,
// No shimmer: a label that animates forever is noise in a sidebar
// full of them (and repaints every vsync on high-refresh displays).
// Working is a background state, so it rests at the dim end of what
// the old pulse cycled through; only the thread you have open gets
// the label at full strength.
className: cn("text-sky-600 dark:text-sky-400", !props.isActive && "opacity-75"),
}
: status === "monitoring"
? {
// Monitoring is calm background presence, not active progress
// (monitoring-pill D6), so it keeps the label at full strength.
label: "Monitoring",
icon: null,
className: "text-sky-600 dark:text-sky-400",
}
: status === "approval"
? {
label: "Approval",
icon: null,
className: "text-amber-700 dark:text-amber-300",
}
: status === "input"
? {
label: "Input",
icon: null,
className: "text-indigo-600 dark:text-indigo-300",
}
: status === "failed"
? {
label: "Failed",
icon: null,
className: "text-red-700 dark:text-red-300",
}
: isWoke
? {
label: "Woke",
icon: "woke" as const,
className: "text-amber-700 dark:text-amber-300",
}
: isUnread
? {
label: "Done",
icon: "done" as const,
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";
const branchMismatch = resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
activeWorktreePath: thread.worktreePath,
activeThreadBranch: thread.branch,
currentGitBranch: gitStatus.data?.refName ?? null,
});
const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
// Report the PR state up: the parent partitions rows with effectiveSettled,
// and a merged/closed PR auto-settles a thread — data only rows have.
useEffect(() => {
onChangeRequestState(threadKey, prState);
}, [onChangeRequestState, prState, threadKey]);
const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
const driverKind = providerEntry?.driverKind ?? null;
const selectedModel = providerEntry?.models.find(
(model) => model.slug === thread.modelSelection.model,
);
const modelLabel = selectedModel
? getTriggerDisplayModelLabel(selectedModel)
: thread.modelSelection.model;
const isRemote =
props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
const detailsTooltip = (
);
const handleClick = useCallback(
(event: ReactMouseEvent) => {
onThreadClick(event, threadRef);
},
[onThreadClick, threadRef],
);
const 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.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.
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"
: 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;
if (variant === "slim") {
return (
}
>
{/* Settled history recedes: dimmed favicon at rest, restored on
hover so the tail stays scannable when you're hunting. */}
{title}
{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.
) : (
{variantAction === "unsettle"
? settledTimeLabel(thread)
: threadTimeLabel(thread)}
)}
{variantAction === "unsnooze" ? (
!props.snoozeSupported ? null : (
)
) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
) : (
)}
{props.jumpLabel ? : null}
{detailsTooltip}
{props.projectTitle ? (
{props.projectTitle}
) : (
)}
{props.isPinned ? (
props.pinningSupported ? (
) : (
)
) : null}
{/* 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.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 ? (
{showSnoozeButton ? (
) : null}
{props.settlementSupported ? (
) : null}
) : null}
{title}
{isRegeneratingTitle ? (
Regenerating title
) : null}
{/* Always the branch. The plan step used to take this slot while
working, but it truncated to a half-sentence and dropped the
branch, so the row lost its most stable identifier. */}
{thread.branch ? (
<>
{thread.branch}
>
) : (
)}
{terminalStatusIcon}
{prBadge}
{diff ? (
+{diff.insertions}{" "}
−{diff.deletions}
) : null}
{isRemote ? (
) : null}
{driverKind ? (
) : null}
{props.jumpLabel ? : null}
{detailsTooltip}
);
});
function latestTurnDiff(
thread: SidebarThreadSummary,
): { insertions: number; deletions: number } | null {
// Shells don't carry checkpoint summaries; diff stats render only when the
// shell projection grows them. Kept as a seam so the row layout is ready.
void thread;
return null;
}
const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
thread: SidebarThreadSummary;
projectCwd: string | null;
projectFaviconPath: string | null;
projectTitle: string | null;
environmentLabel: string | null;
providerEntryByInstanceId: ReadonlyMap;
isHighlighted: boolean;
isRouteActive: boolean;
resultId: string;
onHighlight: () => void;
onSelect: () => void;
}) {
const { thread } = props;
// 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(
(thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd: gitCwd },
})
: null,
);
const branchMismatch = resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
activeWorktreePath: thread.worktreePath,
activeThreadBranch: thread.branch,
currentGitBranch: gitStatus.data?.refName ?? null,
});
const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
const driverKind = providerEntry?.driverKind ?? null;
const selectedModel = providerEntry?.models.find(
(model) => model.slug === thread.modelSelection.model,
);
const modelLabel = selectedModel
? getTriggerDisplayModelLabel(selectedModel)
: thread.modelSelection.model;
const 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 autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const {
settleThread,
unsettleThread,
snoozeThread,
unsnoozeThread,
pinThread,
unpinThread,
reorderPinnedThread,
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 [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
const newThreadContext = useHandleNewThread();
const openAddProjectCommandPalette = useCallback(
() => openCommandPalette({ open: "add-project" }),
[],
);
const { environments } = useEnvironments();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const 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 orderedProjects = useMemo(
() =>
orderItemsByPreferredIds({
items: projects,
preferredIds: projectOrder,
getId: getProjectOrderKey,
getPreferenceIds: (project) => [
getProjectOrderKey(project),
legacyProjectCwdPreferenceKey(project.workspaceRoot),
],
}),
[projectOrder, projects],
);
const unsortedProjectGroups = useMemo(
() =>
buildSidebarProjectSnapshots({
projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
settings: projectGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
}),
[
environmentLabelById,
orderedProjects,
primaryEnvironmentId,
projectGroupingSettings,
projects,
sidebarProjectSortOrder,
],
);
const projectGroups = useMemo(
() => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
[sidebarProjectSortOrder, threads, unsortedProjectGroups],
);
const serverProviders = useAtomValue(primaryServerProvidersAtom);
const providerEntryByInstanceId = useMemo(
() =>
new Map(
deriveProviderInstanceEntries(serverProviders).map(
(entry) => [entry.instanceId as string, entry] as const,
),
),
[serverProviders],
);
const projectCwdByKey = useMemo(
() =>
new Map(
projects.map((project) => [
`${project.environmentId}:${project.id}`,
project.workspaceRoot,
]),
),
[projects],
);
const projectFaviconPathByKey = useMemo(
() =>
new Map(
projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]),
),
[projects],
);
const projectDisplayNameByKey = useMemo(
() =>
new Map(
projectGroups.flatMap((group) =>
group.memberProjects.map(
(project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
),
),
),
[projectGroups],
);
// now is quantized to the minute so effectiveSettled memoization doesn't
// churn on every render; auto-settle thresholds are day-granular anyway.
const nowMinute = useNowMinute();
// Snooze wake times are second-precise, so classifying with the quantized
// minute would hold a woken thread on the shelf for up to a minute. The
// tick is a plain counter bumped exactly at the next wake boundary (armed
// below, after the partition knows the boundary); the partition reads a
// fresh clock whenever it recomputes.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
// PR states stream in per-row (rows own the VCS subscriptions); a merged or
// closed PR auto-settles its thread on the next partition.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
}
return next;
});
},
[],
);
// Project scope: one menu above the list. Scoping filters the list without
// making the header width depend on the number or length of project names.
const [projectScopeKey, setProjectScopeKey] = useState(null);
const scopedProjectGroup = useMemo(
() =>
projectScopeKey === null
? null
: (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
[projectGroups, projectScopeKey],
);
const scopedProjectKeys = useMemo(
() =>
scopedProjectGroup === null
? null
: new Set(
scopedProjectGroup.memberProjectRefs.map(
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
),
),
[scopedProjectGroup],
);
useEffect(() => {
if (projectScopeKey !== null && scopedProjectGroup === null) {
setProjectScopeKey(null);
}
}, [projectScopeKey, scopedProjectGroup]);
// 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 handleProjectSettings = useCallback(
(event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => {
event.preventDefault();
event.stopPropagation();
setProjectScopeMenuOpen(false);
if (isMobile) {
setOpenMobile(false);
}
void router.navigate({
to: "/projects/$projectKey",
params: { projectKey: projectGroup.projectKey },
});
},
[isMobile, router, setOpenMobile],
);
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells: no archived-snapshot
// merging, no optimistic holds. Archived threads remain hidden here —
// archive keeps its original "remove from sidebar" meaning.
const serverConfigs = useAtomValue(environmentServerConfigsAtom);
const {
pinnedThreads,
reorderablePinnedKeys,
activeThreads,
snoozedThreads,
settledThreads,
snoozeNow,
} = useMemo(() => {
const now = `${nowMinute}:00.000Z`;
// Snooze classification uses a REAL clock, not the quantized minute:
// wake times are second-precise and a woken thread must not linger on
// the shelf for the rest of the minute. snoozeWakeTick re-runs this
// memo exactly at the next wake boundary.
void snoozeWakeTick;
const preciseNow = new Date().toISOString();
const visible = threads.filter(
(thread) =>
thread.archivedAt === null &&
(scopedProjectKeys === null ||
scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
);
const 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;
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
// Snooze outranks everything, including a pin: "hide until Tuesday"
// temporarily suspends "keep on top". The pin survives underneath —
// and so does its pinOrderKey, so on wake the thread reappears at
// its exact slot in the pinned block. (For unpinned threads
// this is also the snooze-beats-auto-settle rule: the wake time is a
// stronger statement about when the thread matters again.)
if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
snoozed.push(thread);
// A pin otherwise overrides the lifecycle: pinned threads never
// auto-settle out of sight. (The decider clears settled state on
// pin and the pin on settle, so pin-vs-settled conflicts only
// arise from stale or raced writes.)
} else if (thread.pinnedAt != null) {
pinned.push(thread);
} else if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
) {
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,
};
}, [
autoSettleAfterDays,
changeRequestStateByKey,
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,
true,
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 unpinThread(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.",
}),
);
}
})();
},
[unpinThread],
);
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,
});
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
{ id: "settle", label: `Settle (${count})` },
...(canSnoozeSelection
? [
{
id: "snooze",
label: `Snooze (${count})`,
children: snoozePresets.map((preset) => ({
id: `snooze:${preset.id}`,
label: `${preset.label} (${preset.whenLabel})`,
})),
},
]
: []),
...(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 === "regenerate-title") {
for (const thread of regeneratableTitleThreads) {
const result = await updateThreadMetadata({
environmentId: thread.environmentId,
input: { threadId: thread.id, regenerateTitle: true },
});
if (result._tag === "Success") continue;
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to regenerate thread titles",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
clearSelection();
return;
}
if (clicked.value === "settle") {
// Post-settle navigation must skip threads settling in this same
// batch — they are all leaving the card block together. Rows that
// are already explicitly settled are skipped: nothing to do on a
// valid mixed selection. Pinned rows ARE included: the decider
// clears the pin as part of settling, so they park like the rest.
const coSettlingKeys = new Set(threadKeys);
for (const threadKey of threadKeys) {
const thread = threadByKeyRef.current.get(threadKey);
if (!thread || thread.settledOverride === "settled") continue;
attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
}
clearSelection();
return;
}
if (clicked.value === "mark-unread") {
for (const threadKey of threadKeys) {
const thread = threadByKeyRef.current.get(threadKey);
markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
}
clearSelection();
return;
}
if (clicked.value !== "delete") return;
if (confirmThreadDelete) {
const confirmed = await settlePromise(() =>
api.dialogs.confirm(
[
`Delete ${count} thread${count === 1 ? "" : "s"}?`,
"This permanently clears conversation history for these threads.",
].join("\n"),
{ variant: "destructive" },
),
);
if (confirmed._tag === "Failure" || !confirmed.value) return;
}
// Grown as deletions actually land, never seeded with the whole batch:
// orphaned-worktree detection must only discount threads that are
// really gone, or the first delete would treat still-alive batch mates
// as deleted and remove a worktree they still point at.
const deletedThreadKeys = new Set();
for (const threadKey of threadKeys) {
const thread = threadByKeyRef.current.get(threadKey);
if (!thread) continue;
const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
deletedThreadKeys,
});
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to delete threads",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
deletedThreadKeys.add(threadKey);
}
removeFromSelection(threadKeys);
},
[
attemptSettle,
attemptSnooze,
clearSelection,
confirmThreadDelete,
deleteThread,
markThreadUnread,
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 works on every settled row: for explicit settles it
// clears the override, for auto-settled rows it pins the thread
// active until real activity clears the pin. Environments without
// the settlement capability get no lifecycle items at all.
const supportsSettlement =
serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
true;
const supportsSnooze =
serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
const 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,
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 "new-thread-on-branch": {
// Explicit branch carry-over: reuse the thread's worktree when it
// has one, otherwise its branch on the local checkout.
const result = await settlePromise(() =>
handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
branch: thread.branch,
worktreePath: thread.worktreePath,
envMode: thread.worktreePath ? "worktree" : "local",
startFromOrigin: false,
}),
);
if (result._tag === "Failure") {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not create thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
case "settle":
attemptSettle(threadRef);
return;
case "unsettle":
attemptUnsettle(threadRef);
return;
case "unsnooze":
attemptUnsnooze(threadRef);
return;
case "pin":
attemptPin(threadRef);
return;
case "unpin":
attemptUnpin(threadRef);
return;
case "rename":
startThreadRename(threadRef, thread.title);
return;
case "regenerate-title": {
if (isRegeneratingTitle) return;
const result = await updateThreadMetadata({
environmentId: threadRef.environmentId,
input: { threadId: threadRef.threadId, regenerateTitle: true },
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to regenerate thread title",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
case "mark-unread":
markThreadUnread(threadKey, thread.latestTurn?.completedAt);
return;
case "copy-path":
if (!threadWorkspacePath) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Path unavailable",
description: "This thread does not have a workspace path to copy.",
}),
);
return;
}
copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
return;
case "copy-branch":
if (thread.branch) {
copyBranchToClipboard(thread.branch, { branch: thread.branch });
}
return;
case "copy-thread-id":
copyThreadIdToClipboard(thread.id, { threadId: thread.id });
return;
case "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;
}
})();
},
[
attemptPin,
attemptSettle,
attemptSnooze,
attemptUnpin,
attemptUnsettle,
attemptUnsnooze,
confirmThreadDelete,
copyBranchToClipboard,
copyPathToClipboard,
copyThreadIdToClipboard,
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
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) 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 (
<>
{(() => {
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(