1import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
2import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
3import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
4import {
5 setThreadChangeRequestSnapshot,
6 ThreadTerminalStatusButton,
7} from "./ThreadStatusIndicators";
8import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";
9import {
10 dropThreadContext,
11 endThreadContextDrag,
12 moveThreadContextDrag as moveThreadContextDragGhost,
13} from "./chat/threadContextDrag";
14import { discardComposerDraft } from "../lib/discardComposerDraft";
15import { requestCustomSnooze } from "./CustomSnoozeDialog";
16import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
17import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
18import { useAtomValue } from "@effect/atom-react";
19import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
20import * as Schema from "effect/Schema";
21import {
22 DndContext,
23 useSensor,
24 useSensors,
25 type DragEndEvent,
26 type DragOverEvent,
27 type DragStartEvent,
28 type Modifier,
29} from "@dnd-kit/core";
30import { SortableContext, useSortable } from "@dnd-kit/sortable";
31import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
32import { CSS } from "@dnd-kit/utilities";
33import {
34 canSnooze,
35 effectiveSnoozed,
36 threadWokeAt,
37} from "@t3tools/client-runtime/state/thread-settled";
38import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
39import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
40import {
41 resolveSettledThreadTimestamp,
42 sortSettledThreads,
43} from "@t3tools/client-runtime/state/thread-sort";
44import {
45 threadSearchMatchKey,
46 type EnvironmentThreadSearchMatch,
47} from "@t3tools/client-runtime/state/thread-search";
48import {
49 resolveThreadProviderStack,
50 threadRuntimeCanArchive,
51 type EnvironmentThreadShell,
52} from "@t3tools/client-runtime/state/models";
53import {
54 parseScopedThreadKey,
55 scopeProjectRef,
56 scopeThreadRef,
57 scopedThreadKey,
58} from "@t3tools/client-runtime/environment";
59import {
60 AuthOrchestrationOperateScope,
61 type EnvironmentMachineKind,
62 type ScopedThreadRef,
63 type ThreadId,
64} from "@t3tools/contracts";
65
66import type { TimestampFormat } from "@t3tools/contracts/settings";
67import {
68 AlarmClockIcon,
69 AlarmClockOffIcon,
70 ArrowRightLeftIcon,
71 CheckIcon,
72 CircleAlertIcon,
73 CircleCheckIcon,
74 CircleDashedIcon,
75 CircleDotDashedIcon,
76 ClockIcon,
77 EyeIcon,
78 FolderIcon,
79 GitBranchIcon,
80 ListFilterIcon,
81 MessageCircleQuestionIcon,
82 PinIcon,
83 PinOffIcon,
84 PlusIcon,
85 SettingsIcon,
86 ShieldQuestionIcon,
87 SquarePenIcon,
88 TerminalIcon,
89 Undo2Icon,
90 XIcon,
91} from "lucide-react";
92import {
93 memo,
94 useCallback,
95 useEffect,
96 useLayoutEffect,
97 useMemo,
98 useReducer,
99 useRef,
100 useState,
101 type KeyboardEvent as ReactKeyboardEvent,
102 type MouseEvent as ReactMouseEvent,
103 type PointerEvent as ReactPointerEvent,
104 type ReactNode,
105} from "react";
106import { useParams, useRouter } from "@tanstack/react-router";
107
108import { ThreadMergebotPill, useIsMergebotRepository } from "./review/ThreadMergebotPill";
109import { useRightPanelStore } from "../rightPanelStore";
110import {
111 isAtomCommandInterrupted,
112 settlePromise,
113 squashAtomCommandFailure,
114 type AtomCommandResult,
115} from "@t3tools/client-runtime/state/runtime";
116import { isElectron } from "../env";
117import {
118 resolveShortcutCommand,
119 shortcutLabelForCommand,
120 shouldShowThreadJumpHintsForModifiers,
121 threadJumpCommandForIndex,
122 threadJumpIndexFromCommand,
123 threadTraversalDirectionFromCommand,
124} from "../keybindings";
125import { useShortcutModifierState } from "../shortcutModifierState";
126import { isTerminalFocused } from "../lib/terminalFocus";
127import { isModelPickerOpen } from "../modelPickerVisibility";
128import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
129import { isMacPlatform } from "~/lib/utils";
130import { useOpenPrLink } from "../lib/openPullRequestLink";
131import { readLocalApi } from "../localApi";
132import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
133import { selectProjectGroupingSettings } from "../logicalProject";
134import {
135 projectGroupsSpanEnvironments,
136 resolveSidebarProjectScopeKey,
137 getSidebarProjectSettingsKey,
138 type SidebarProjectSnapshot,
139} from "../sidebarProjectGrouping";
140import { useUiStateStore } from "../uiStateStore";
141import {
142 getThreadKeysToDeselectAfterDelete,
143 useThreadSelectionStore,
144} from "../threadSelectionStore";
145import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
146import { useHandleNewThread } from "../hooks/useHandleNewThread";
147import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
148import { useTerminalFocus } from "../hooks/useTerminalFocus";
149import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
150import { registerSettleAndAdvance } from "../settleAndAdvance";
151import { startNewThreadFromContext } from "../lib/chatThreadActions";
152import { useClientSettings } from "../hooks/useSettings";
153import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
154import { useLocalStorage } from "../hooks/useLocalStorage";
155import { useNowMinute } from "../hooks/useNowMinute";
156import {
157 useEnvironmentIdentities,
158 useConnectedEnvironmentIds,
159 useEnvironmentMachines,
160 usePrimaryEnvironmentId,
161} from "../state/environments";
162import {
163 readThreadShell,
164 useAllEnvironmentProjectSnapshotsReady,
165 useProjects,
166 useThreadShells,
167} from "../state/entities";
168import {
169 environmentCapabilitiesAtom,
170 environmentServerConfigsAtom,
171 primaryServerKeybindingsAtom,
172} from "../state/server";
173import { environmentProjects } from "../state/projects";
174import { vcsEnvironment } from "../state/vcs";
175import { threadEnvironment } from "../state/threads";
176import { appAtomRegistry } from "../rpc/atomRegistry";
177import { environmentSnapshotAtom } from "../state/shell";
178import { useEnvironmentQuery } from "../state/query";
179import { useThreadSearch } from "../state/queries";
180import { useOrchestrationCommand } from "../state/use-orchestration-command";
181import {
182 readEnvironmentScope,
183 useEnvironmentScope,
184 readThreadOrganizationPermission,
185 useThreadOrganizationPermission,
186} from "../state/session";
187import {
188 buildThreadRouteParams,
189 resolveActiveThreadRouteRef,
190 resolveThreadRouteTarget,
191} from "../threadRoutes";
192import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
193import type { SidebarThreadSummary } from "../types";
194import { isScratchProject } from "@t3tools/client-runtime/state/projects";
195import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
196import { cn } from "~/lib/utils";
197import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
198import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
199import {
200 buildDraftActionMenuItems,
201 buildThreadActionMenuItems,
202 threadActionRequiresOperate,
203 threadActionIsOrganization,
204} from "./threadActionMenu.logic";
205import {
206 animateSidebarLayoutChanges,
207 archiveSelectedThreadEntries,
208 applySidebarThreadDrop,
209 filterSidebarV2VisibleThreads,
210 buildBulkTitleRegenerationContextMenuItem,
211 buildBulkUnpinContextMenuItem,
212 buildMultiSelectThreadContextMenuItems,
213 deleteSelectedThreadEntries,
214 filterSidebarProjectScopeItems,
215 formatWorkingDurationLabel,
216 firstValidTimestampMs,
217 hasUnseenCompletion,
218 isSidebarNestedLinkClick,
219 isSidebarThreadWorking,
220 isTrailingDoubleClick,
221 orderItemsByPreferredIds,
222 planSidebarThreadDrop,
223 reduceSidebarProjectScopeMenuState,
224 resolveAdjacentThreadId,
225 resolveSidebarSweepKeys,
226 resolveSidebarDropTarget,
227 resolveSidebarDropVerb,
228 resolveSidebarThreadSection,
229 resolveSidebarRowAccessibility,
230 type SidebarDropVerb,
231 resolveSidebarThreadStatus,
232 resolveThreadLastVisitedAt,
233 searchSidebarThreads,
234 shouldCreateNewThreadInCurrentProject,
235 shouldNavigateAfterThreadPark,
236 shouldRecedeSidebarThread,
237 resolveWorkingStartedAt,
238 sidebarListItemId,
239 sidebarMarkerId,
240 sidebarThreadKeyAtY,
241 sortInboxThreadsByReturn,
242 sortPinnedThreadsForSidebar,
243 sortThreadsForSidebar,
244 sortWorkingThreadsBySend,
245 useThreadJumpHintVisibility,
246 useRetainedValue,
247 useSidebarRowSubscriptionLease,
248 type SidebarListItem,
249 type SidebarListMarker,
250 type SidebarSection,
251} from "./Sidebar.logic";
252import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
253import {
254 createSidebarCollisionDetection,
255 createSidebarSortingStrategy,
256 restrictBelowSidebarLabel,
257} from "./Sidebar.drag";
258import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
259import { createSidebarListMotion } from "./Sidebar.motion";
260import {
261 ThreadPullRequestBadgeControl,
262 ThreadPullRequestsMiniList,
263 ThreadWorktreeIndicator,
264 nextThreadChangeRequestSnapshot,
265 prStatusIndicator,
266 resolveThreadPullRequestBadge,
267 terminalStatusFromRunningIds,
268 threadChangeRequestSnapshotsAtom,
269 type ThreadChangeRequestSnapshot,
270 type TerminalStatusIndicator,
271 useLinkedThreadPullRequest,
272 ThreadSearchPullRequestNumber,
273} from "./ThreadStatusIndicators";
274import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
275import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
276import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
277import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
278import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
279import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
280import {
281 deriveProviderEntriesByEnvironment,
282 shouldShowInstanceBadge,
283 type ProviderInstanceEntry,
284} from "../providerInstances";
285import { useThreadRunningTerminalIds } from "../state/terminalSessions";
286import { stackedThreadToast, toastManager } from "./ui/toast";
287import { Button, InlineButton } from "./ui/button";
288import {
289 Combobox,
290 ComboboxEmpty,
291 ComboboxSearchInput,
292 ComboboxItem,
293 ComboboxList,
294 ComboboxPopup,
295 ComboboxTrigger,
296 useComboboxFilter,
297} from "./ui/combobox";
298import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar";
299import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
300import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
301import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu";
302import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
303import { MiddleTruncate } from "./ui/middle-truncate";
304import {
305 composerDraftHasUserContent,
306 DraftId,
307 useComposerDraftStore,
308 useThreadHasUnsentDraft,
309 type ComposerThreadDraftState,
310 type DraftSessionState,
311} from "../composerDraftStore";
312
313const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
314// Collapsed shelves share one empty list so a route change alone does not
315// give the sidebar list a new identity.
316const EMPTY_THREADS: readonly EnvironmentThreadShell[] = [];
317
318const SETTLED_TAIL_INITIAL_COUNT = 10;
319// Fresh keys deliberately reset both shelves to collapsed for existing users.
320const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded";
321const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded";
322const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
323
324// Working beta: when this client saw each thread leave the Working shelf.
325// Module scope keeps the inbox order across routes that unmount the sidebar.
326const inboxReturns = createInboxReturnTracker();
327
328function canOperateThreads(
329 threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
330 organization = false,
331): boolean {
332 return threads.every((thread) =>
333 organization
334 ? readThreadOrganizationPermission(thread.environmentId)
335 : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope),
336 );
337}
338
339function checkThreadOperations(
340 threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
341 organization = false,
342): boolean {
343 if (canOperateThreads(threads, organization)) return true;
344 toastManager.add({
345 type: "error",
346 title: "Thread action unavailable",
347 description: "This connection cannot change one or more selected threads.",
348 });
349 return false;
350}
351
352function compactSidebarTimeLabel(label: string): string {
353 if (label === "just now") return "now";
354 return label.endsWith(" ago") ? label.slice(0, -4) : label;
355}
356
357function threadTimeLabel(thread: SidebarThreadSummary): string {
358 const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;
359 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
360}
361
362// Settled rows read "how long ago did this wrap up", matching their sort
363// key: both go through resolveSettledThreadTimestamp so label and order can't
364// disagree.
365function settledTimeLabel(thread: SidebarThreadSummary): string {
366 const timestamp = resolveSettledThreadTimestamp(thread);
367 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
368}
369
370// Floats at the row's right edge, vertically centered, while the jump
371// modifier is held. An overlay pill instead of an inline slot: the hint
372// must neither displace the status/time label (holding ⌘ used to blank
373// out "Working") nor shift any layout when it appears. pointer-events-none
374// so it never swallows clicks meant for the settle/un-settle buttons it
375// can overlap.
376function JumpHintBadge(props: { label: string }) {
377 return (
378 <span
379 aria-hidden
380 className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-3xs font-medium tracking-tight text-foreground shadow-sm"
381 >
382 {props.label}
383 </span>
384 );
385}
386
387// Self-ticking so only this span re-renders each second, not the whole row.
388function WorkingDuration(props: { startedAt: string | null }) {
389 const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
390 const [, setTick] = useState(0);
391 useEffect(() => {
392 if (Number.isNaN(startedMs)) return;
393 const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000);
394 return () => window.clearInterval(id);
395 }, [startedMs]);
396 if (Number.isNaN(startedMs)) return null;
397 return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>;
398}
399
400function terminalProcessLabel(count: number): string {
401 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
402}
403
404function SidebarProviderStack(props: {
405 thread: SidebarThreadSummary;
406 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
407}) {
408 const stack = resolveThreadProviderStack(props.thread);
409 const currentInstanceId = stack[stack.length - 1]!;
410 const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null;
411 if (currentEntry === null) return null;
412 const showInstanceBadge = shouldShowInstanceBadge(
413 currentEntry,
414 props.providerEntryByInstanceId.values(),
415 );
416 const current = (
417 <ProviderInstanceIcon
418 driverKind={currentEntry.driverKind}
419 displayName={currentEntry.displayName}
420 accentColor={currentEntry.accentColor}
421 acpRegistryAgentId={currentEntry.acpRegistryAgentId}
422 acpRegistryIconUrl={currentEntry.acpRegistryIconUrl}
423 showBadge={showInstanceBadge}
424 // Glyph dims, badge stays saturated; offset matches the composer trigger.
425 iconClassName="size-3.5 opacity-60"
426 badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs"
427 />
428 );
429 if (stack.length === 1) {
430 return <span className="inline-flex shrink-0 items-center">{current}</span>;
431 }
432 return (
433 <span className="inline-flex shrink-0 items-center gap-1.5">
434 {stack.slice(0, -1).map((instanceId) => {
435 const entry = props.providerEntryByInstanceId.get(instanceId);
436 if (entry === undefined) return null;
437 return (
438 <ProviderInstanceIcon
439 key={instanceId}
440 driverKind={entry.driverKind}
441 displayName={entry.displayName}
442 acpRegistryAgentId={entry.acpRegistryAgentId}
443 acpRegistryIconUrl={entry.acpRegistryIconUrl}
444 iconClassName="size-3 opacity-35 grayscale"
445 />
446 );
447 })}
448 <span className="relative z-10 inline-flex items-center">{current}</span>
449 </span>
450 );
451}
452
453function SidebarThreadTooltip({
454 thread,
455 project,
456 projectDisplayName,
457 environmentLabel,
458 environmentMachine,
459 providerEntry,
460 providerEntryByInstanceId,
461 showInstanceBadge,
462 modelInstanceId,
463 modelLabel,
464 branchMismatch,
465 terminalStatus,
466 terminalProcessCount,
467}: {
468 thread: SidebarThreadSummary;
469 project: ProjectFaviconProject | null;
470 projectDisplayName: string | null;
471 environmentLabel: string | null;
472 environmentMachine: EnvironmentMachineKind;
473 providerEntry: ProviderInstanceEntry | null;
474 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
475 showInstanceBadge: boolean;
476 modelInstanceId: string;
477 modelLabel: string;
478 branchMismatch: {
479 threadBranch: string;
480 currentBranch: string;
481 } | null;
482 terminalStatus: TerminalStatusIndicator | null;
483 terminalProcessCount: number;
484}) {
485 const driverKind = providerEntry?.driverKind ?? null;
486 const previousProviderNames = thread.providerInstanceHistory
487 .filter((instanceId) => instanceId !== modelInstanceId)
488 .map((instanceId) => providerEntryByInstanceId.get(instanceId)?.displayName ?? instanceId);
489 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
490 return (
491 <ThreadHoverCardPopup side="right" align="start" sideOffset={4}>
492 <ThreadHoverCard
493 title={thread.title}
494 footer={
495 supportsMultiplePullRequests && thread.pullRequests.length > 0 ? (
496 <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground">
497 <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} />
498 </div>
499 ) : null
500 }
501 >
502 {projectDisplayName ? (
503 <div className="flex min-w-0 items-center gap-2">
504 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}
505 <div className="min-w-0 truncate text-foreground/75">{projectDisplayName}</div>
506 </div>
507 ) : null}
508 {environmentLabel ? (
509 <div className="flex min-w-0 items-center gap-2">
510 <EnvironmentMachineIcon
511 kind={environmentMachine}
512 className="size-3 shrink-0 stroke-muted-foreground"
513 />
514 <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div>
515 </div>
516 ) : null}
517 {thread.branch ? (
518 <div className="flex min-w-0 items-center gap-2">
519 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />
520 <MiddleTruncate value={thread.branch} className="flex" />
521 </div>
522 ) : null}
523 {branchMismatch ? (
524 <div className="flex min-w-0 items-start gap-2 text-warning">
525 <CircleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" />
526 <div className="min-w-0 flex-1 wrap-break-word leading-5">
527 You're currently checked out on another branch.
528 </div>
529 </div>
530 ) : null}
531 {driverKind ? (
532 <div className="flex min-w-0 items-center gap-2">
533 <ProviderInstanceIcon
534 driverKind={driverKind}
535 displayName={
536 providerEntry?.displayName ?? thread.runtime?.providerName ?? modelInstanceId
537 }
538 accentColor={providerEntry?.accentColor}
539 acpRegistryAgentId={providerEntry?.acpRegistryAgentId}
540 acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl}
541 // Initials would swallow a size-3 glyph: accent dot, name in label.
542 showBadge={showInstanceBadge && providerEntry?.accentColor !== undefined}
543 badgeContent="none"
544 badgeClassName="h-2 min-w-2 px-0"
545 iconClassName="size-3 shrink-0 grayscale opacity-60"
546 />
547 <div className="min-w-0 truncate text-foreground/75">
548 {showInstanceBadge && providerEntry
549 ? `${modelLabel} · ${providerEntry.displayName}`
550 : modelLabel}
551 </div>
552 </div>
553 ) : null}
554 {previousProviderNames.length > 0 ? (
555 <div className="flex min-w-0 items-center gap-2">
556 <ArrowRightLeftIcon className="size-3 shrink-0 stroke-muted-foreground" />
557 <div className="min-w-0 truncate text-foreground/75">
558 Handed off from {previousProviderNames.join(", ")}
559 </div>
560 </div>
561 ) : null}
562 {terminalStatus ? (
563 <div className="flex min-w-0 items-center gap-2">
564 <TerminalIcon
565 aria-hidden
566 className={cn("size-3 shrink-0", terminalStatus.colorClass)}
567 />
568 <div className="min-w-0 truncate text-foreground/75">
569 {terminalProcessLabel(terminalProcessCount)}
570 </div>
571 </div>
572 ) : null}
573 {thread.runtime?.lastError ? (
574 <div
575 className={cn(
576 "flex min-w-0 items-center gap-2",
577 thread.runtime.lastErrorClass === "usage_limit" ? "text-warning" : "text-error",
578 )}
579 >
580 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />
581 <div className="min-w-0 truncate">
582 {thread.runtime.lastErrorClass === "usage_limit"
583 ? "Usage limit reached"
584 : "Error occurred"}
585 </div>
586 </div>
587 ) : null}
588 </ThreadHoverCard>
589 </ThreadHoverCardPopup>
590 );
591}
592
593/**
594 * Hover entry point for snooze: a clock button opening the preset menu.
595 * Controlled by the row (which also uses the open state to pin its hover
596 * actions while the menu is up).
597 */
598function SnoozeMenuButton(props: {
599 open: boolean;
600 onOpenChange: (open: boolean) => void;
601 onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void;
602 timestampFormat: TimestampFormat;
603}) {
604 const { open, onOpenChange, onSnooze, timestampFormat } = props;
605 // Presets resolve at open time so "In 1 hour" is relative to the click,
606 // not to when the row mounted.
607 const presets = useMemo(
608 () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []),
609 [open, timestampFormat],
610 );
611 return (
612 <Menu open={open} onOpenChange={onOpenChange}>
613 <Tooltip>
614 <TooltipTrigger
615 render={
616 <MenuTrigger
617 render={
618 <button
619 type="button"
620 aria-label="Snooze thread"
621 onClick={(event) => event.stopPropagation()}
622 onDoubleClick={(event) => event.stopPropagation()}
623 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"
624 />
625 }
626 />
627 }
628 >
629 <ClockIcon className="size-3" />
630 </TooltipTrigger>
631 <TooltipPopup>Snooze thread</TooltipPopup>
632 </Tooltip>
633 <MenuPopup side="bottom" align="end">
634 {presets.map((preset) => (
635 <MenuItem
636 key={preset.id}
637 onClick={(event) => {
638 event.stopPropagation();
639 onSnooze(preset);
640 }}
641 >
642 {preset.label}
643 <MenuShortcut>{preset.whenLabel}</MenuShortcut>
644 </MenuItem>
645 ))}
646 <MenuSeparator />
647 <MenuItem
648 onClick={async (event) => {
649 event.stopPropagation();
650 const choice = await requestCustomSnooze();
651 if (choice) onSnooze(choice);
652 }}
653 >
654 Custom…
655 </MenuItem>
656 </MenuPopup>
657 </Menu>
658 );
659}
660
661// Subset of useSortable applied to a thread row's root <li>. Listeners go
662// on the whole row (no dedicated handle): the pointer sensor's distance
663// constraint keeps plain clicks working, and we skip dnd-kit's aria
664// attributes since there is no keyboard sensor and the row body already
665// carries its own button semantics.
666type SortableThreadRowBag = Pick<
667 ReturnType<typeof useSortable>,
668 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
669>;
670
671function SortableThreadRow(props: {
672 id: string;
673 disabled: boolean;
674 contextDrag: boolean;
675 environmentId: EnvironmentId;
676 children: (bag: SortableThreadRowBag) => ReactNode;
677}) {
678 const canOrganizeThread = useThreadOrganizationPermission(props.environmentId);
679 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
680 id: props.id,
681 disabled: { draggable: props.disabled || !canOrganizeThread },
682 animateLayoutChanges: animateSidebarLayoutChanges,
683 });
684 // dnd-kit memoizes each field but not the bag, so the memoized row would
685 // rerender on every shell update without this.
686 const bag = useMemo(
687 () => ({
688 listeners,
689 setNodeRef,
690 transform: props.contextDrag ? null : transform,
691 // The lifted row normally follows the pointer without a transition.
692 // When it becomes a context ghost, glide its sidebar copy back home.
693 transition: props.contextDrag && isDragging ? "transform 150ms ease-out" : transition,
694 isDragging: isDragging && !props.contextDrag,
695 }),
696 [listeners, setNodeRef, transform, transition, isDragging, props.contextDrag],
697 );
698 return props.children(bag);
699}
700
701// Unsent work shares one look: the new-thread draft rows and thread rows
702// with unsent composer text both use this tint and pen so they read alike.
703const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8";
704const draftPenClassName = "size-3 shrink-0 text-warning-foreground";
705
706// Structural list items — the section headers and the
707// empty-section placeholders — take part in the sortable list so they shift
708// with the rows and the gap can open on either side of them. They can't be
709// picked up, and a marker is the sortable `over` when the pointer is on it,
710// which resolveSidebarDropTarget turns into the section the gap sits in.
711function SortableSidebarMarker(props: {
712 marker: SidebarListMarker;
713 className?: string;
714 children?: ReactNode;
715 "data-testid"?: string;
716}) {
717 const { setNodeRef, transform, transition } = useSortable({
718 id: sidebarMarkerId(props.marker),
719 disabled: { draggable: true },
720 animateLayoutChanges: animateSidebarLayoutChanges,
721 });
722 return (
723 <li
724 ref={setNodeRef}
725 data-thread-selection-safe
726 data-testid={props["data-testid"]}
727 className={cn("list-none", props.className)}
728 style={{
729 transform: CSS.Translate.toString(transform),
730 // A newly revealed target must not slide from its hidden position.
731 transition: props.marker.endsWith("-placeholder") ? "none" : transition,
732 visibility: transform?.scaleY === 0 ? "hidden" : undefined,
733 }}
734 >
735 {props.children}
736 </li>
737 );
738}
739
740// Empty targets stay measurable without reserving space at rest. The sorting
741// strategy opens their hint space during a drag.
742function SidebarSectionPlaceholder(props: {
743 marker: "active-placeholder" | "settled-placeholder";
744 label: string;
745 showHint: boolean;
746 isDropTarget: boolean;
747}) {
748 return (
749 <SortableSidebarMarker
750 marker={props.marker}
751 data-testid={`sidebar-${props.marker}`}
752 className="relative mx-0.5 -mb-px h-0"
753 >
754 {props.showHint ? (
755 <div
756 className={cn(
757 "absolute inset-x-0 top-0 flex h-9 items-center justify-center rounded-md border border-dashed border-sidebar-foreground/25 text-xs text-sidebar-foreground/80",
758 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
759 )}
760 >
761 {props.label}
762 </div>
763 ) : null}
764 </SortableSidebarMarker>
765 );
766}
767
768// Pointer travel before a press on a row starts a drag, or a press on its
769// action button starts a sweep. Shorter presses stay clicks.
770const SIDEBAR_DRAG_DISTANCE = 6;
771
772type SidebarSweepAction = "settle" | "unsettle" | "unsnooze";
773
774// Zero-height markers reserve no label space at rest. During a drag the
775// sorting strategy opens 24px for a 16px label with 4px clearance on each side.
776const SIDEBAR_DRAG_LABEL_HEIGHT = 24;
777
778function SidebarDragBoundary(props: {
779 marker: "pinned-header" | "pinned-divider";
780 label: string;
781 visible: boolean;
782 isDropTarget: boolean;
783}) {
784 return (
785 <SortableSidebarMarker
786 marker={props.marker}
787 data-testid={`sidebar-${props.marker}`}
788 className="pointer-events-none relative mx-0.5 -mb-px h-0"
789 >
790 {props.visible ? (
791 <div className="sidebar-drag-boundary-label absolute inset-x-2 top-1 flex h-4 items-center gap-2">
792 <span
793 className={cn(
794 "shrink-0 text-xs font-medium",
795 props.isDropTarget ? "text-primary" : "text-sidebar-foreground/80",
796 )}
797 >
798 {props.label}
799 </span>
800 <span
801 aria-hidden
802 className={cn(
803 "h-px flex-1",
804 props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25",
805 )}
806 />
807 </div>
808 ) : null}
809 </SortableSidebarMarker>
810 );
811}
812
813// Shelf headers stay visible and keep their measured height while dragging.
814function SidebarSectionHeader(props: {
815 marker: "working-header" | "snoozed-header" | "settled-header";
816 label: string;
817 className?: string;
818 // While dragging, the settled header reads at full strength and takes the
819 // accent while the lifted row is over it.
820 dragging?: boolean;
821 isDropTarget?: boolean;
822 toggle: { expanded: boolean; onToggle: () => void };
823 onContextMenu?: (position: { x: number; y: number }) => void;
824}) {
825 const shelf =
826 props.marker === "working-header"
827 ? "working"
828 : props.marker === "snoozed-header"
829 ? "snoozed"
830 : "settled";
831 const snoozed = shelf === "snoozed";
832 return (
833 <SortableSidebarMarker
834 marker={props.marker}
835 data-testid={`sidebar-${props.marker}`}
836 className={cn("mx-0.5 h-8", props.className)}
837 >
838 <CollapsibleSectionHeader
839 onClick={props.toggle.onToggle}
840 onContextMenu={(event) => {
841 if (!props.onContextMenu) return;
842 event.preventDefault();
843 props.onContextMenu({ x: event.clientX, y: event.clientY });
844 }}
845 onKeyDown={(event) => {
846 if (
847 !props.onContextMenu ||
848 (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
849 ) {
850 return;
851 }
852 event.preventDefault();
853 const rect = event.currentTarget.getBoundingClientRect();
854 props.onContextMenu({ x: rect.left, y: rect.bottom });
855 }}
856 expanded={props.toggle.expanded}
857 tone={
858 props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted"
859 }
860 data-testid={`sidebar-${shelf}-shelf-toggle`}
861 >
862 {props.label}
863 </CollapsibleSectionHeader>
864 </SortableSidebarMarker>
865 );
866}
867
868// One unsent draft session the user has invested content in. Two lines,
869// nothing else: project name, then the typed prompt. All the draft's
870// settings (model, env mode, branch, worktree) still travel with it —
871// clicking is a plain navigation to /draft/$draftId, which touches nothing.
872// While the draft is open the row renders a frozen snapshot (see
873// SidebarDraftBlock); memoized so per-keystroke block re-renders skip it
874// entirely.
875const SidebarDraftRow = memo(function SidebarDraftRow(props: {
876 draftId: DraftId;
877 composer: ComposerThreadDraftState;
878 project: ProjectFaviconProject | null;
879 projectDisplayName: string | null;
880 isActive: boolean;
881 onNavigate: (draftId: DraftId) => void;
882 onDiscard: (draftId: DraftId) => void;
883 onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
884}) {
885 const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
886 const promptPreview =
887 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
888 .trim()
889 .split("\n", 1)[0] ?? "";
890 // images mirrors persistedAttachments once rehydration finishes; before
891 // that only the persisted list is populated, hence max not sum.
892 const attachmentCount =
893 Math.max(composer.images.length, composer.persistedAttachments.length) +
894 composer.files.length +
895 composer.terminalContexts.length +
896 composer.previewAnnotations.length +
897 composer.reviewComments.length;
898 const preview =
899 promptPreview.length > 0
900 ? promptPreview
901 : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;
902 const accessibility = resolveSidebarRowAccessibility({
903 title: preview,
904 statusLabel: "Unsent draft",
905 projectDisplayName: props.projectDisplayName,
906 isActive: props.isActive,
907 });
908 const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);
909 const handleKeyDown = useCallback(
910 (event: ReactKeyboardEvent) => {
911 // Keys targeting the nested discard button belong to the button:
912 // preventDefault here would swallow Space's synthesized click and
913 // navigate instead of discarding.
914 if ((event.target as HTMLElement).closest("button")) return;
915 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
916 event.preventDefault();
917 const rect = event.currentTarget.getBoundingClientRect();
918 onContextMenu(draftId, { x: rect.left, y: rect.bottom });
919 } else if (event.key === "Enter" || event.key === " ") {
920 event.preventDefault();
921 onNavigate(draftId);
922 }
923 },
924 [draftId, onContextMenu, onNavigate],
925 );
926 const handleContextMenu = useCallback(
927 (event: ReactMouseEvent) => {
928 event.preventDefault();
929 onContextMenu(draftId, { x: event.clientX, y: event.clientY });
930 },
931 [draftId, onContextMenu],
932 );
933 const handleDiscard = useCallback(
934 (event: ReactMouseEvent) => {
935 event.preventDefault();
936 event.stopPropagation();
937 onDiscard(draftId);
938 },
939 [draftId, onDiscard],
940 );
941 return (
942 <li className="list-none py-0.5">
943 <div
944 role="button"
945 tabIndex={0}
946 aria-label={accessibility.label}
947 aria-current={accessibility.current}
948 data-testid="sidebar-draft-row"
949 className={cn(
950 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
951 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
952 )}
953 onClick={handleActivate}
954 onContextMenu={handleContextMenu}
955 onKeyDown={handleKeyDown}
956 >
957 <span className="sr-only">{preview}</span>
958 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
959 <div className="flex h-5 min-w-0 items-center gap-1.5">
960 <SquarePenIcon aria-hidden className={draftPenClassName} />
961 {props.project ? (
962 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
963 ) : null}
964 <span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
965 {props.projectDisplayName}
966 </span>
967 <span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end">
968 <Tooltip>
969 <TooltipTrigger
970 render={
971 <button
972 type="button"
973 aria-label="Discard draft"
974 onClick={handleDiscard}
975 className="pointer-events-none inline-flex cursor-pointer items-center rounded-md bg-transparent px-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100"
976 >
977 <XIcon className="size-3" />
978 </button>
979 }
980 />
981 <TooltipPopup side="top">Discard draft</TooltipPopup>
982 </Tooltip>
983 </span>
984 </div>
985 <div aria-hidden className="mt-0.5 truncate text-sm font-medium text-foreground/90">
986 {preview}
987 </div>
988 </div>
989 </div>
990 </li>
991 );
992});
993
994interface SidebarDraftRowData {
995 draftId: DraftId;
996 session: DraftSessionState;
997 composer: ComposerThreadDraftState;
998}
999
1000// Draft sessions with user content, surfaced above the pinned block so an
1001// interrupted "new thread" stays one click away. Self-contained (own store
1002// subscription + closing divider) so per-keystroke composer updates
1003// re-render only this block, never the whole sidebar. Vanishes at count 0.
1004const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
1005 projectByKey: ReadonlyMap<string, EnvironmentProject>;
1006 projectDisplayNameByKey: ReadonlyMap<string, string>;
1007 scopedProjectKeys: ReadonlySet<string> | null;
1008 routeDraftId: string | null;
1009 onNavigateToDraft: (draftId: DraftId) => void;
1010 onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
1011}) {
1012 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
1013 const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
1014 // The open draft's row is FROZEN at the moment the draft became the route:
1015 // it stays visible (like a thread row) but never repaints while the user
1016 // types. A draft that was never navigated away from has no snapshot to
1017 // freeze, so a fresh typing session shows no row at all. Captured
1018 // synchronously on route change (setState-during-render derived state) so
1019 // the row never flickers out for a frame between route change and capture.
1020 const [frozenActive, setFrozenActive] = useState<{
1021 routeDraftId: string | null;
1022 row: SidebarDraftRowData | null;
1023 }>({ routeDraftId: null, row: null });
1024 if (frozenActive.routeDraftId !== props.routeDraftId) {
1025 let row: SidebarDraftRowData | null = null;
1026 if (props.routeDraftId !== null) {
1027 const draftId = DraftId.make(props.routeDraftId);
1028 const store = useComposerDraftStore.getState();
1029 const session = store.getDraftSession(draftId);
1030 const composer = store.getComposerDraft(draftId);
1031 row =
1032 session && session.promotedTo == null && composer && composerDraftHasUserContent(composer)
1033 ? { draftId, session, composer }
1034 : null;
1035 }
1036 setFrozenActive({ routeDraftId: props.routeDraftId, row });
1037 }
1038 const drafts = useMemo(() => {
1039 const rows: SidebarDraftRowData[] = [];
1040 // Every non-promoted session with content gets a row, mapped or not:
1041 // new-thread surfaces mint fresh drafts and leave invested ones behind
1042 // unmapped, so the mapping only knows about the latest per project.
1043 for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) {
1044 if (session.promotedTo != null) {
1045 continue;
1046 }
1047 if (
1048 props.scopedProjectKeys !== null &&
1049 !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`)
1050 ) {
1051 continue;
1052 }
1053 if (draftKey === props.routeDraftId) {
1054 // Open draft: render the frozen entry snapshot, or nothing for a
1055 // draft that has never been left. Gated on the LIVE session above so
1056 // send/discard still removes the row immediately.
1057 if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) {
1058 rows.push(frozenActive.row);
1059 }
1060 continue;
1061 }
1062 const composer = draftsByThreadKey[draftKey];
1063 if (!composer || !composerDraftHasUserContent(composer)) {
1064 continue;
1065 }
1066 rows.push({ draftId: DraftId.make(draftKey), session, composer });
1067 }
1068 rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt));
1069 return rows;
1070 }, [
1071 draftThreadsByThreadKey,
1072 draftsByThreadKey,
1073 frozenActive,
1074 props.routeDraftId,
1075 props.scopedProjectKeys,
1076 ]);
1077 if (drafts.length === 0) {
1078 return null;
1079 }
1080 return (
1081 <>
1082 {drafts.map(({ composer, draftId, session }) => {
1083 const projectKey = `${session.environmentId}:${session.projectId}`;
1084 return (
1085 <SidebarDraftRow
1086 key={draftId}
1087 draftId={draftId}
1088 composer={composer}
1089 project={props.projectByKey.get(projectKey) ?? null}
1090 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
1091 isActive={draftId === props.routeDraftId}
1092 onNavigate={props.onNavigateToDraft}
1093 // The /draft/$draftId route redirects home on its own when the
1094 // draft it renders disappears, so discarding the open draft needs
1095 // no special-casing here.
1096 onDiscard={discardComposerDraft}
1097 onContextMenu={props.onDraftContextMenu}
1098 />
1099 );
1100 })}
1101 <li
1102 aria-hidden
1103 data-testid="sidebar-draft-divider"
1104 className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
1105 />
1106 </>
1107 );
1108});
1109
1110// Verb and icon on the lifted row while it hovers over another section. Uses
1111// the same icons as the row actions and context menu so the drop reads as the
1112// action it performs.
1113const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
1114 pin: (
1115 <>
1116 <PinIcon aria-hidden className="size-3" />
1117 Pin
1118 </>
1119 ),
1120 unpin: (
1121 <>
1122 <PinOffIcon aria-hidden className="size-3" />
1123 Unpin
1124 </>
1125 ),
1126 settle: (
1127 <>
1128 <CircleCheckIcon aria-hidden className="size-3" />
1129 Settle
1130 </>
1131 ),
1132 unsettle: (
1133 <>
1134 <Undo2Icon aria-hidden className="size-3" />
1135 Un-settle
1136 </>
1137 ),
1138 wake: (
1139 <>
1140 <AlarmClockOffIcon aria-hidden className="size-3" />
1141 Wake
1142 </>
1143 ),
1144};
1145
1146const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1147 thread: SidebarThreadSummary;
1148 variant: "card" | "slim";
1149 // Settled rows un-settle, snoozed rows wake, and cards settle.
1150 variantAction: SidebarSweepAction;
1151 // False on environments whose server predates thread.settle/unsettle:
1152 // the lifecycle affordances hide entirely rather than fail on click.
1153 settlementSupported: boolean;
1154 // Same contract for thread.snooze/unsnooze.
1155 snoozeSupported: boolean;
1156 // Renders the pin glyph. Pinned cards keep the full settle/snooze quick
1157 // actions: settling clears the pin server-side, and snoozing hides the
1158 // card until wake with the pin intact underneath. The glyph is also the
1159 // in-row pin state cue (the pinned block has no header), so it always
1160 // shows while pinned; it only becomes a clickable unpin quick-action once
1161 // the pinning capability is confirmed, and stays a passive marker while
1162 // the descriptor is not loaded. Pinning itself lives in the context menu.
1163 pinningSupported: boolean;
1164 isPinned: boolean;
1165 // Present on rows whose server supports every drop outcome: dnd-kit
1166 // sortable bag applied to the row root so the whole row drags (the
1167 // pointer sensor's distance constraint keeps plain clicks working).
1168 sortable?: SortableThreadRowBag | undefined;
1169 dropVerb: SidebarDropVerb | null;
1170 // While dragging, the pin marker stays only for a pinned thread still over
1171 // the pinned section. Any other position shows the verb badge instead, and
1172 // the badge carries its own icon.
1173 dragOverPinned: boolean;
1174 // The action this row will take when the sweep is released.
1175 sweepAction: SidebarSweepAction | null;
1176 // Compact wake countdown ("2h") for rows in the snoozed shelf.
1177 snoozeWakeLabelText: string | null;
1178 // When a snooze ended (timer or early wake); drives the Woke pill until
1179 // the user visits the thread.
1180 wokeAt: string | null;
1181 isActive: boolean;
1182 openPullRequestsInRightPanel: boolean;
1183 jumpLabel: string | null;
1184 currentEnvironmentId: string | null;
1185 environmentLabel: string | null;
1186 environmentMachine: EnvironmentMachineKind;
1187 scratchMachineLabel: string | null;
1188 project: EnvironmentProject | null;
1189 projectDisplayName: string | null;
1190 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
1191 timestampFormat: TimestampFormat;
1192 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
1193 onThreadActivate: (threadRef: ScopedThreadRef) => void;
1194 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
1195 onRenameTitleChange: (title: string) => void;
1196 onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;
1197 onCancelRename: () => void;
1198 isRenaming: boolean;
1199 renamingTitle: string;
1200 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
1201 onSettle: (threadRef: ScopedThreadRef) => void;
1202 onActionSweepStart: (
1203 threadRef: ScopedThreadRef,
1204 action: SidebarSweepAction,
1205 event: PointerEvent,
1206 ) => void;
1207 onUnsettle: (threadRef: ScopedThreadRef) => void;
1208 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;
1209 onUnsnooze: (threadRef: ScopedThreadRef) => void;
1210 onUnpin: (threadRef: ScopedThreadRef) => void;
1211 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
1212 onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined;
1213 changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
1214 onChangeRequestSnapshot: (
1215 threadKey: string,
1216 snapshot: ThreadChangeRequestSnapshot | null,
1217 ) => void;
1218}) {
1219 const {
1220 isRenaming,
1221 changeRequestSnapshot,
1222 onChangeRequestSnapshot,
1223 onCancelRename,
1224 onCommitRename,
1225 onContextMenu,
1226 onAcknowledgeWoke,
1227 onFileDropThreads,
1228 onRenameTitleChange,
1229 onSettle,
1230 onActionSweepStart,
1231 onSnooze,
1232 onStartRename,
1233 onThreadActivate,
1234 onThreadClick,
1235 onUnsettle,
1236 onUnsnooze,
1237 onUnpin,
1238 openPullRequestsInRightPanel,
1239 renamingTitle,
1240 thread,
1241 variant,
1242 variantAction,
1243 } = props;
1244 const threadRef = useMemo(
1245 () => scopeThreadRef(thread.environmentId, thread.id),
1246 [thread.environmentId, thread.id],
1247 );
1248 const threadKey = scopedThreadKey(threadRef);
1249 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
1250 const canOrganizeThread = useThreadOrganizationPermission(thread.environmentId);
1251 useEffect(() => {
1252 if (!canOperateThread && isRenaming) onCancelRename();
1253 }, [canOperateThread, isRenaming, onCancelRename]);
1254 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
1255 const isRegeneratingTitle = thread.titleRegeneration != null;
1256 const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
1257 const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt);
1258 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
1259 const openPrLink = useOpenPrLink();
1260 const runningTerminalIds = useThreadRunningTerminalIds({
1261 environmentId: thread.environmentId,
1262 threadId: thread.id,
1263 });
1264 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
1265 const terminalProcessCount = runningTerminalIds.length;
1266 // Unsent composer text on this thread. The open thread shows its own
1267 // composer, so the marker only decorates rows you have navigated away from.
1268 const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
1269 const handleDiscardDraftClick = useCallback(
1270 (event: ReactMouseEvent) => {
1271 event.preventDefault();
1272 event.stopPropagation();
1273 discardComposerDraft(threadRef);
1274 },
1275 [threadRef],
1276 );
1277
1278 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
1279 const linkedPullRequestStatus = useLinkedThreadPullRequest(
1280 thread.environmentId,
1281 thread.linkedPullRequest,
1282 leaseLiveStatus,
1283 thread.pullRequests,
1284 thread.branchPullRequest,
1285 );
1286 const gitStatus = useEnvironmentQuery(
1287 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1288 ? vcsEnvironment.status({
1289 environmentId: thread.environmentId,
1290 input: { cwd: gitCwd, includeRemote: false },
1291 })
1292 : null,
1293 );
1294 const visibleGitStatus = useRetainedValue(
1295 JSON.stringify([thread.environmentId, gitCwd]),
1296 gitStatus.data,
1297 );
1298 const pr = linkedPullRequestStatus?.pr ?? null;
1299 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
1300 const currentLinkedPr = supportsMultiplePullRequests
1301 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
1302 : null;
1303 const mergebotPullRequest = useIsMergebotRepository(
1304 thread.environmentId,
1305 currentLinkedPr?.repository,
1306 )
1307 ? currentLinkedPr
1308 : null;
1309
1310 // Same semantics as the legacy sidebar (never-visited counts as read):
1311 // switching sidebars must not light up every historical thread as unread.
1312 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
1313 const status = resolveSidebarThreadStatus(thread);
1314 const isInFlight =
1315 status === "working" || status === "waiting" || status === "approval" || status === "input";
1316 // A woken thread reappears at its original position (the sort is
1317 // deliberately static), so the pill has to carry the weight. Snoozing is
1318 // an explicit act, so the pill clears only when the user re-engages:
1319 // reading a completion-triggered wake, clicking the pill, sending a
1320 // message, settling, archiving, or a change request state that settles the
1321 // thread. Timer wakes survive a mere visit. An unparseable visit timestamp
1322 // counts as never-visited, so corrupt local data cannot eat the wake signal.
1323 const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
1324 const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
1325 const isWoke =
1326 wokeAtDate !== null &&
1327 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
1328 thread.settledOverride !== "settled";
1329 // Background work always recedes when it is not selected: an unread parent
1330 // completion must not pull a still-working thread back into the foreground.
1331 // Ready and action-required rows keep their unread and wake prominence.
1332 const shouldRecede = shouldRecedeSidebarThread({
1333 status,
1334 isUnread,
1335 isWoke,
1336 isActive: props.isActive,
1337 isSelected,
1338 });
1339 // Status hues follow the system-wide convention set by sidebar v1 and the
1340 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
1341 // so a thread reads the same color everywhere it surfaces.
1342 const topStatus =
1343 status === "working"
1344 ? {
1345 // A native /goal keeps the agent going across turns until it is met.
1346 label: thread.goal?.status === "active" ? "Goal" : "Working",
1347 icon: "working" as const,
1348 // No shimmer: a label that animates forever is noise in a sidebar
1349 // full of them (and repaints every vsync on high-refresh displays).
1350 className: "text-info",
1351 }
1352 : status === "waiting"
1353 ? {
1354 // Waiting is calm background presence (post-settle background
1355 // roster), not active progress, so the label keeps full strength.
1356 label: "Waiting",
1357 icon: "waiting" as const,
1358 className: "text-muted-foreground",
1359 }
1360 : status === "approval"
1361 ? {
1362 label: "Approval",
1363 icon: "approval" as const,
1364 className: "text-warning-foreground",
1365 }
1366 : status === "input"
1367 ? {
1368 label: "Input",
1369 icon: "input" as const,
1370 className: "text-indigo-600 dark:text-indigo-300",
1371 }
1372 : status === "limited"
1373 ? {
1374 label: "Limited",
1375 icon: "failed" as const,
1376 className: "text-warning",
1377 }
1378 : status === "failed"
1379 ? {
1380 label: "Failed",
1381 icon: "failed" as const,
1382 className: "text-error",
1383 }
1384 : isWoke
1385 ? {
1386 label: "Woke",
1387 icon: "woke" as const,
1388 className: "text-warning",
1389 }
1390 : isUnread
1391 ? {
1392 label: "Done",
1393 icon: "done" as const,
1394 className: "text-success",
1395 }
1396 : null;
1397 const isWokeStatus = topStatus?.icon === "woke";
1398
1399 const branchMismatch = resolveLocalCheckoutBranchMismatch({
1400 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
1401 activeWorktreePath: thread.worktreePath,
1402 activeThreadBranch: thread.branch,
1403 currentGitBranch: visibleGitStatus?.refName ?? null,
1404 });
1405 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
1406
1407 const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
1408 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
1409 const showInstanceBadge =
1410 providerEntry !== null &&
1411 shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values());
1412 const selectedModel = providerEntry?.models.find(
1413 (model) => model.slug === thread.modelSelection.model,
1414 );
1415 const modelLabel = selectedModel
1416 ? getTriggerDisplayModelLabel(selectedModel)
1417 : thread.modelSelection.model;
1418
1419 // The local environment is "this machine" and needs no marker; every other
1420 // one gets its machine glyph. With no local environment (the hosted app)
1421 // that is every thread, which is the point: the glyph is what tells rows on
1422 // different machines apart.
1423 const isRemote = thread.environmentId !== props.currentEnvironmentId;
1424 const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null;
1425
1426 const detailsTooltip = (
1427 <SidebarThreadTooltip
1428 thread={thread}
1429 project={props.project}
1430 projectDisplayName={props.projectDisplayName}
1431 environmentLabel={props.environmentLabel}
1432 environmentMachine={props.environmentMachine}
1433 providerEntry={providerEntry}
1434 providerEntryByInstanceId={props.providerEntryByInstanceId}
1435 showInstanceBadge={showInstanceBadge}
1436 modelInstanceId={modelInstanceId}
1437 modelLabel={modelLabel}
1438 branchMismatch={branchMismatch}
1439 terminalStatus={terminalStatus}
1440 terminalProcessCount={terminalProcessCount}
1441 />
1442 );
1443
1444 const handleClick = useCallback(
1445 (event: ReactMouseEvent) => {
1446 onThreadClick(event, threadRef);
1447 },
1448 [onThreadClick, threadRef],
1449 );
1450 const handleAcknowledgeWokeClick = useCallback(
1451 (event: ReactMouseEvent) => {
1452 event.preventDefault();
1453 event.stopPropagation();
1454 if (props.wokeAt === null) return;
1455 onAcknowledgeWoke(threadRef, props.wokeAt);
1456 },
1457 [onAcknowledgeWoke, props.wokeAt, threadRef],
1458 );
1459 const handleContextMenu = useCallback(
1460 (event: ReactMouseEvent) => {
1461 event.preventDefault();
1462 onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
1463 },
1464 [onContextMenu, threadRef],
1465 );
1466 const handleKeyDown = useCallback(
1467 (event: ReactKeyboardEvent) => {
1468 if (event.target !== event.currentTarget) return;
1469 if (event.key !== "Enter" && event.key !== " ") return;
1470 event.preventDefault();
1471 onThreadActivate(threadRef);
1472 },
1473 [onThreadActivate, threadRef],
1474 );
1475 const handleDoubleClick = useCallback(
1476 (event: ReactMouseEvent) => {
1477 if (
1478 !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) ||
1479 isRenaming ||
1480 event.metaKey ||
1481 event.ctrlKey ||
1482 event.shiftKey ||
1483 event.altKey
1484 ) {
1485 return;
1486 }
1487 if ((event.target as HTMLElement).closest("button, a, input")) return;
1488 event.preventDefault();
1489 onStartRename(threadRef, thread.title);
1490 },
1491 [isRenaming, onStartRename, thread.title, threadRef],
1492 );
1493 const [isFileDragOver, setIsFileDragOver] = useState(false);
1494 const fileDropHandlers = useMemo(
1495 () =>
1496 onFileDropThreads
1497 ? makeWorkspaceFileDropHandlers({
1498 setDragActive: setIsFileDragOver,
1499 addFiles: (files) => {
1500 onFileDropThreads(threadRef, files);
1501 },
1502 addFolders: () => {},
1503 })
1504 : null,
1505 [onFileDropThreads, threadRef],
1506 );
1507 useEffect(() => {
1508 if (!isFileDragOver) return;
1509 const clearFileDrag = () => setIsFileDragOver(false);
1510 window.addEventListener("dragend", clearFileDrag);
1511 return () => window.removeEventListener("dragend", clearFileDrag);
1512 }, [isFileDragOver]);
1513 const renameCommittedRef = useRef(false);
1514 useEffect(() => {
1515 if (isRenaming) renameCommittedRef.current = false;
1516 }, [isRenaming]);
1517 const handleRenameKeyDown = useCallback(
1518 (event: ReactKeyboardEvent<HTMLInputElement>) => {
1519 event.stopPropagation();
1520 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
1521 if (event.key === "Enter") {
1522 event.preventDefault();
1523 renameCommittedRef.current = true;
1524 onCommitRename(threadRef, renamingTitle, thread.title);
1525 } else if (event.key === "Escape") {
1526 event.preventDefault();
1527 renameCommittedRef.current = true;
1528 onCancelRename();
1529 }
1530 },
1531 [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
1532 );
1533 const handleRenameBlur = useCallback(() => {
1534 if (!renameCommittedRef.current) {
1535 onCommitRename(threadRef, renamingTitle, thread.title);
1536 }
1537 }, [onCommitRename, renamingTitle, thread.title, threadRef]);
1538 const handleSettleClick = useCallback(
1539 (event: ReactMouseEvent) => {
1540 event.preventDefault();
1541 event.stopPropagation();
1542 onSettle(threadRef);
1543 },
1544 [onSettle, threadRef],
1545 );
1546 const handleActionPointerDown = useCallback(
1547 (event: ReactPointerEvent) => {
1548 if (!event.isPrimary || event.button !== 0) return;
1549 // Action buttons sweep their section rather than picking up the row.
1550 event.stopPropagation();
1551 onActionSweepStart(threadRef, variantAction, event.nativeEvent);
1552 },
1553 [onActionSweepStart, threadRef, variantAction],
1554 );
1555 const handleUnsettleClick = useCallback(
1556 (event: ReactMouseEvent) => {
1557 event.preventDefault();
1558 event.stopPropagation();
1559 onUnsettle(threadRef);
1560 },
1561 [onUnsettle, threadRef],
1562 );
1563 const handleUnsnoozeClick = useCallback(
1564 (event: ReactMouseEvent) => {
1565 event.preventDefault();
1566 event.stopPropagation();
1567 onUnsnooze(threadRef);
1568 },
1569 [onUnsnooze, threadRef],
1570 );
1571 const handleUnpinClick = useCallback(
1572 (event: ReactMouseEvent) => {
1573 event.preventDefault();
1574 event.stopPropagation();
1575 onUnpin(threadRef);
1576 },
1577 [onUnpin, threadRef],
1578 );
1579 const handleSnoozePreset = useCallback(
1580 (preset: Pick<SnoozePreset, "snoozedUntil">) => {
1581 onSnooze(threadRef, preset);
1582 },
1583 [onSnooze, threadRef],
1584 );
1585 // While the snooze popover is open the pointer leaves the row, which
1586 // would fade the hover actions out from under the open menu. Pin them and
1587 // suppress the row tooltip so its portal cannot overlap the popover.
1588 const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false);
1589 // Snooze is offered only where it can succeed: capability-gated and never
1590 // on blocked-on-you work or queued turns (the server rejects both).
1591 const showSnoozeButton =
1592 canOrganizeThread &&
1593 props.snoozeSupported &&
1594 canSnooze(thread, { now: new Date().toISOString() });
1595 const showHoverActions =
1596 (canOrganizeThread && props.settlementSupported) || showSnoozeButton || hasUnsentDraft;
1597 // If the thread becomes blocked while the popover is open, the button
1598 // unmounts without firing onOpenChange(false). Deriving the flag keeps a
1599 // stale true from permanently hiding the status label / pinning the
1600 // hover actions, and the effect clears the raw state so the popover
1601 // doesn't resurrect if the button later remounts.
1602 const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton;
1603 useEffect(() => {
1604 if (!showSnoozeButton) setSnoozeMenuOpen(false);
1605 }, [showSnoozeButton]);
1606 const handlePrClick = useCallback(
1607 (event: ReactMouseEvent<HTMLElement>, targetUrl?: string) => {
1608 const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url;
1609 if (!url) return;
1610 const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel;
1611 const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined);
1612 if (openedInRightPanel && showInRightPanel && !props.isActive) {
1613 onThreadActivate(threadRef);
1614 }
1615 },
1616 [
1617 onThreadActivate,
1618 openPrLink,
1619 openPullRequestsInRightPanel,
1620 pr,
1621 currentLinkedPr,
1622 props.isActive,
1623 threadRef,
1624 ],
1625 );
1626
1627 // All sidebar rows share one surface model. Live threads used to look
1628 // like elevated cards while settled threads were plain rows, leaving neither
1629 // a useful hierarchy nor a reliable hover cue. Status now lives in the row
1630 // content; surface is reserved for interaction (hover, multi-select, route).
1631 const rowSurfaceClassName = cn(
1632 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1633 variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
1634 props.isActive
1635 ? "bg-sidebar-row-active text-sidebar-foreground"
1636 : isSelected || props.sweepAction !== null
1637 ? "bg-sidebar-row-selected text-sidebar-foreground"
1638 : hasUnsentDraft
1639 ? cn(draftSurfaceClassName, "text-sidebar-foreground")
1640 : shouldRecede
1641 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
1642 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
1643 // Background work fades as a whole row, status label included, so it
1644 // takes less attention than rows that need a human (input, approval).
1645 shouldRecede &&
1646 status === "working" &&
1647 "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none",
1648 isFileDragOver && "ring-1 ring-inset ring-primary/70",
1649 isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover",
1650 // The lifted row is an opaque card so the rows beneath it never show
1651 // through. The row tint is translucent in dark themes and the pointer
1652 // keeps the hover color applied, so both the tint and the solid sidebar
1653 // color are stacked as background images.
1654 props.sortable?.isDragging &&
1655 "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg",
1656 );
1657 // dnd-kit props for the row root. Same bag on both variants: every row in
1658 // the list translates around the gap as the drag passes it.
1659 const sortable = props.sortable;
1660 const sortableRootProps = sortable
1661 ? {
1662 ref: sortable.setNodeRef,
1663 style: {
1664 transform: CSS.Translate.toString(sortable.transform),
1665 transition: sortable.transition,
1666 // A zero-height boundary also makes dnd-kit scale the source to
1667 // zero. Only projected peers use scaleY as a visibility sentinel.
1668 visibility:
1669 !sortable.isDragging && sortable.transform?.scaleY === 0
1670 ? ("hidden" as const)
1671 : undefined,
1672 },
1673 ...sortable.listeners,
1674 }
1675 : {};
1676 // Sweeps reuse the corresponding row-drop action badge.
1677 const destinationVerb = sortable?.isDragging
1678 ? props.dropVerb
1679 : props.sweepAction === "unsnooze"
1680 ? "wake"
1681 : props.sweepAction;
1682 const dragDestination =
1683 destinationVerb !== null ? (
1684 <span
1685 role="status"
1686 className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-2xs font-medium text-primary"
1687 >
1688 {dropVerbBadge[destinationVerb]}
1689 </span>
1690 ) : null;
1691
1692 const accessibility = resolveSidebarRowAccessibility({
1693 title: thread.title,
1694 statusLabel: topStatus?.label ?? null,
1695 projectDisplayName: props.projectDisplayName,
1696 isActive: props.isActive,
1697 });
1698
1699 const title =
1700 isRenaming && canOperateThread ? (
1701 <input
1702 autoFocus
1703 value={renamingTitle}
1704 aria-label="Thread title"
1705 onChange={(event) => onRenameTitleChange(event.target.value)}
1706 onFocus={(event) => event.currentTarget.select()}
1707 onKeyDown={handleRenameKeyDown}
1708 onBlur={handleRenameBlur}
1709 onClick={(event) => event.stopPropagation()}
1710 onDoubleClick={(event) => event.stopPropagation()}
1711 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"
1712 />
1713 ) : (
1714 <span
1715 aria-hidden
1716 className={cn(
1717 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
1718 shouldRecede ? "font-normal" : "font-medium",
1719 variant === "card"
1720 ? cn(
1721 "truncate",
1722 shouldRecede
1723 ? "text-secondary-label"
1724 : isUnread || isWoke || status === "input"
1725 ? "text-foreground"
1726 : status === "failed"
1727 ? "text-foreground/95"
1728 : "text-foreground/90",
1729 )
1730 : cn(
1731 "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground",
1732 shouldRecede
1733 ? "text-secondary-label/70"
1734 : props.isActive || isWoke || status === "input"
1735 ? "text-foreground"
1736 : isUnread
1737 ? "text-muted-foreground"
1738 : "text-secondary-label/70",
1739 ),
1740 isRegeneratingTitle && "opacity-55",
1741 )}
1742 >
1743 {thread.title}
1744 </span>
1745 );
1746 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
1747
1748 // Stacks show their layer count; multiple unrelated links show their total count.
1749 // Either opens the thread's pull requests tab; a single PR link opens that PR and still
1750 // supports opening the host in a new tab.
1751 const prBadgeShape = supportsMultiplePullRequests
1752 ? resolveThreadPullRequestBadge(thread.pullRequests)
1753 : null;
1754 const handlePrListClick = useCallback(() => {
1755 useRightPanelStore.getState().open(threadRef, "pull-requests");
1756 if (!props.isActive) onThreadActivate(threadRef);
1757 }, [onThreadActivate, props.isActive, threadRef]);
1758 const prBadge =
1759 prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
1760 <ThreadPullRequestBadgeControl
1761 render={<InlineButton />}
1762 badge={prBadgeShape}
1763 pullRequests={thread.pullRequests}
1764 number={pr?.number ?? currentLinkedPr?.number}
1765 url={pr?.url ?? currentLinkedPr?.url}
1766 status={prStatus}
1767 onOpenList={handlePrListClick}
1768 onOpenPullRequest={handlePrClick}
1769 />
1770 ) : null;
1771 const terminalStatusIcon = (
1772 <ThreadTerminalStatusButton
1773 threadRef={threadRef}
1774 terminalIds={runningTerminalIds}
1775 onThreadActivate={onThreadActivate}
1776 />
1777 );
1778 // Same pen the new-thread draft rows lead with, so both kinds of unsent
1779 // work read the same way in the list.
1780 const draftIndicator = hasUnsentDraft ? (
1781 <Tooltip>
1782 <TooltipTrigger
1783 render={
1784 <span
1785 role="img"
1786 aria-label="Unsent draft"
1787 data-testid={`sidebar-draft-indicator-${thread.id}`}
1788 className="inline-flex shrink-0 items-center"
1789 />
1790 }
1791 >
1792 <SquarePenIcon aria-hidden className={draftPenClassName} />
1793 </TooltipTrigger>
1794 <TooltipPopup side="top">Unsent draft</TooltipPopup>
1795 </Tooltip>
1796 ) : null;
1797 const showPin =
1798 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
1799 const pinIndicator = showPin ? (
1800 props.pinningSupported && canOrganizeThread && !sortable?.isDragging ? (
1801 <Tooltip>
1802 <TooltipTrigger
1803 render={
1804 <button
1805 type="button"
1806 aria-label="Unpin thread"
1807 onClick={handleUnpinClick}
1808 className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1809 />
1810 }
1811 >
1812 {/* Pin marks the pinned state at rest; hover and focus swap in pin-off so the
1813 icon reads as the action the button performs. */}
1814 <PinIcon
1815 aria-hidden
1816 className="size-3 shrink-0 group-hover/unpin:hidden group-focus-visible/unpin:hidden"
1817 />
1818 <PinOffIcon
1819 aria-hidden
1820 className="hidden size-3 shrink-0 group-hover/unpin:block group-focus-visible/unpin:block"
1821 />
1822 </TooltipTrigger>
1823 <TooltipPopup>Unpin thread</TooltipPopup>
1824 </Tooltip>
1825 ) : (
1826 <PinIcon
1827 aria-label="Pinned"
1828 role="img"
1829 className="size-3 shrink-0 text-muted-foreground/65"
1830 />
1831 )
1832 ) : null;
1833
1834 if (variant === "slim") {
1835 return (
1836 <li
1837 data-thread-item={threadKey}
1838 {...sortableRootProps}
1839 {...(fileDropHandlers ?? {})}
1840 className={cn(
1841 // Matches the h-9 row so unrendered rows never shift the list when they paint.
1842 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]",
1843 sortable?.isDragging && "relative z-20",
1844 )}
1845 >
1846 <Tooltip disabled={sortable?.isDragging}>
1847 <TooltipTrigger
1848 render={
1849 <div
1850 ref={rowRef}
1851 role="button"
1852 tabIndex={0}
1853 aria-label={accessibility.label}
1854 aria-current={accessibility.current}
1855 data-testid="sidebar-row-slim"
1856 aria-busy={isRegeneratingTitle || undefined}
1857 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
1858 onClick={handleClick}
1859 onDoubleClick={handleDoubleClick}
1860 onKeyDown={handleKeyDown}
1861 onContextMenu={handleContextMenu}
1862 />
1863 }
1864 >
1865 {accessibleTitle}
1866 {/* Settled history recedes: dimmed favicon at rest, restored on
1867 hover so the tail stays scannable when you're hunting. */}
1868 <span
1869 className={cn(
1870 "flex shrink-0 items-center transition-opacity",
1871 (!props.isActive || variantAction === "unsettle") &&
1872 "opacity-40 grayscale group-focus-within/sidebar-row:opacity-100 group-focus-within/sidebar-row:grayscale-0 group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
1873 )}
1874 >
1875 {props.project ? <ProjectFavicon project={props.project} className="size-4" /> : null}
1876 </span>
1877 {draftIndicator}
1878 {title}
1879 {pinIndicator}
1880 {terminalStatusIcon}
1881 {isRegeneratingTitle ? (
1882 <span role="status" className="sr-only">
1883 Regenerating title
1884 </span>
1885 ) : null}
1886 {/* The PR badge stays outside the hover-fading slot: it must
1887 remain visible AND clickable while the row is hovered. Only
1888 the time/jump label yields to the settle affordance. */}
1889 {prBadge}
1890 {sortable?.isDragging ? (
1891 dragDestination
1892 ) : (
1893 <span
1894 className={cn(
1895 "relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end",
1896 props.sweepAction !== null && "hidden",
1897 )}
1898 >
1899 <span
1900 className={cn(
1901 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
1902 !isWoke && "group-any-hover/sidebar-row:opacity-0",
1903 )}
1904 >
1905 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
1906 // Snoozed rows show when they come BACK, not when they were
1907 // last touched — the return ticket is the row's whole story.
1908 <span className="text-xs text-info-foreground tabular-nums">
1909 {props.snoozeWakeLabelText}
1910 </span>
1911 ) : isWoke ? (
1912 // A wake can land straight in the settled tail (e.g. PR
1913 // merged while snoozed); the signal must survive the trip.
1914 <Tooltip>
1915 <TooltipTrigger
1916 render={
1917 <button
1918 type="button"
1919 aria-label="Dismiss Woke notification"
1920 onClick={handleAcknowledgeWokeClick}
1921 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1922 >
1923 <AlarmClockIcon aria-hidden className="size-3" />
1924 <span role="status">Woke</span>
1925 </button>
1926 }
1927 />
1928 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1929 </Tooltip>
1930 ) : (
1931 <span className="text-xs">
1932 {variantAction === "unsettle"
1933 ? settledTimeLabel(thread)
1934 : threadTimeLabel(thread)}
1935 </span>
1936 )}
1937 </span>
1938 {variantAction === "unsnooze" ? (
1939 !props.snoozeSupported ? null : (
1940 <button
1941 type="button"
1942 aria-label="Wake thread now"
1943 onClick={handleUnsnoozeClick}
1944 onPointerDown={handleActionPointerDown}
1945 className={cn(
1946 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100",
1947 isWoke && "group-any-hover/sidebar-row:static",
1948 )}
1949 >
1950 <AlarmClockOffIcon className="mb-px size-3" />
1951 </button>
1952 )
1953 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
1954 <Tooltip>
1955 <TooltipTrigger
1956 render={
1957 <button
1958 type="button"
1959 aria-label="Un-settle thread"
1960 onClick={handleUnsettleClick}
1961 onPointerDown={handleActionPointerDown}
1962 className={cn(
1963 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100",
1964 isWoke && "group-any-hover/sidebar-row:static",
1965 )}
1966 />
1967 }
1968 >
1969 <Undo2Icon className="mb-px size-3.5" />
1970 </TooltipTrigger>
1971 <TooltipPopup side="top">Un-settle thread</TooltipPopup>
1972 </Tooltip>
1973 ) : (
1974 <button
1975 type="button"
1976 aria-label="Settle thread"
1977 onClick={handleSettleClick}
1978 onPointerDown={handleActionPointerDown}
1979 className={cn(
1980 "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100",
1981 isWoke && "group-any-hover/sidebar-row:static",
1982 )}
1983 >
1984 <CheckIcon className="size-3" />
1985 </button>
1986 )}
1987 </span>
1988 )}
1989 {props.sweepAction !== null ? dragDestination : null}
1990 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
1991 </TooltipTrigger>
1992 {detailsTooltip}
1993 </Tooltip>
1994 </li>
1995 );
1996 }
1997
1998 const diff = latestRunDiff(thread);
1999
2000 return (
2001 <li
2002 data-thread-item={threadKey}
2003 {...sortableRootProps}
2004 {...(fileDropHandlers ?? {})}
2005 className={cn(
2006 // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.
2007 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",
2008 sortable?.isDragging && "relative z-20",
2009 )}
2010 >
2011 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
2012 <TooltipTrigger
2013 render={
2014 <div
2015 ref={rowRef}
2016 role="button"
2017 tabIndex={0}
2018 aria-label={accessibility.label}
2019 aria-current={accessibility.current}
2020 data-testid="sidebar-row-card"
2021 aria-busy={isRegeneratingTitle || undefined}
2022 className={rowSurfaceClassName}
2023 onClick={handleClick}
2024 onDoubleClick={handleDoubleClick}
2025 onKeyDown={handleKeyDown}
2026 onContextMenu={handleContextMenu}
2027 />
2028 }
2029 >
2030 {accessibleTitle}
2031 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
2032 <div className="flex h-5 min-w-0 items-center gap-1.5">
2033 {draftIndicator}
2034 {props.project ? (
2035 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2036 ) : null}
2037 {props.projectDisplayName ? (
2038 <span
2039 className={cn(
2040 "min-w-0 flex-1 truncate text-secondary-label text-xs",
2041 shouldRecede ? "font-normal" : "font-medium",
2042 )}
2043 >
2044 {props.projectDisplayName}
2045 </span>
2046 ) : (
2047 <span className="flex-1" />
2048 )}
2049 {pinIndicator}
2050 {/* The visible state owns this slot's width: status at rest,
2051 actions on hover/keyboard focus or while the popover is open. Keeping
2052 the hidden state out of flow lets the project label reclaim
2053 space without either state overlapping it. */}
2054 {sortable?.isDragging ? (
2055 dragDestination
2056 ) : (
2057 <span
2058 className={cn(
2059 "group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs",
2060 props.sweepAction !== null && "hidden",
2061 )}
2062 >
2063 {/* Read-only status labels yield to the hover actions. Woke is
2064 itself an action, so it stays pointer-enabled and visible
2065 while the other controls appear beside it. */}
2066 <span
2067 className={cn(
2068 isWokeStatus
2069 ? "pointer-events-auto"
2070 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:right-0 group-any-hover/sidebar-row:opacity-0",
2071 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
2072 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
2073 )}
2074 >
2075 {topStatus ? (
2076 isWokeStatus ? (
2077 <Tooltip>
2078 <TooltipTrigger
2079 render={
2080 <button
2081 type="button"
2082 aria-label="Dismiss Woke notification"
2083 onClick={handleAcknowledgeWokeClick}
2084 className={cn(
2085 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
2086 topStatus.className,
2087 )}
2088 >
2089 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
2090 <span role="status">{topStatus.label}</span>
2091 </button>
2092 }
2093 />
2094 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
2095 </Tooltip>
2096 ) : (
2097 <span
2098 className={cn(
2099 "inline-flex items-center gap-1 font-medium",
2100 topStatus.className,
2101 )}
2102 >
2103 {topStatus.icon === "working" ? (
2104 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
2105 ) : topStatus.icon === "waiting" ? (
2106 <CircleDotDashedIcon aria-hidden className="size-4 shrink-0" />
2107 ) : topStatus.icon === "input" ? (
2108 <MessageCircleQuestionIcon aria-hidden className="size-4 shrink-0" />
2109 ) : topStatus.icon === "approval" ? (
2110 <ShieldQuestionIcon aria-hidden className="size-4 shrink-0" />
2111 ) : topStatus.icon === "failed" ? (
2112 <CircleAlertIcon aria-hidden className="size-4 shrink-0" />
2113 ) : topStatus.icon === "done" ? (
2114 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
2115 ) : null}
2116 {/* The label alone is the live region: a role="status"
2117 wrapper around the ticking duration would make
2118 screen readers announce every second. */}
2119 <span role="status">{topStatus.label}</span>
2120 {status === "working" ? (
2121 <span aria-hidden>
2122 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
2123 </span>
2124 ) : status === "waiting" ? (
2125 // Waiting starts when the turn ends and background
2126 // work keeps the thread held.
2127 <span aria-hidden>
2128 <WorkingDuration startedAt={thread.latestRun?.completedAt ?? null} />
2129 </span>
2130 ) : null}
2131 </span>
2132 )
2133 ) : (
2134 threadTimeLabel(thread)
2135 )}
2136 </span>
2137 {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
2138 <span
2139 className={cn(
2140 // focus-visible, not focus-within: a mouse click leaves
2141 // the Settle button focused, and a plain focus-within
2142 // would keep the controls pinned over the status label
2143 // once the pointer moves away (e.g. after a failed
2144 // settle) instead of cross-fading back.
2145 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:static group-any-hover/sidebar-row:opacity-100",
2146 snoozeMenuOpen && "pointer-events-auto static opacity-100",
2147 )}
2148 >
2149 {hasUnsentDraft ? (
2150 <Tooltip>
2151 <TooltipTrigger
2152 render={
2153 <button
2154 type="button"
2155 aria-label="Discard draft"
2156 onClick={handleDiscardDraftClick}
2157 className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
2158 />
2159 }
2160 >
2161 <XIcon className="size-3.5" />
2162 </TooltipTrigger>
2163 <TooltipPopup side="top">Discard draft</TooltipPopup>
2164 </Tooltip>
2165 ) : null}
2166 {showSnoozeButton ? (
2167 <SnoozeMenuButton
2168 open={snoozeMenuOpen}
2169 onOpenChange={setSnoozeMenuOpen}
2170 onSnooze={handleSnoozePreset}
2171 timestampFormat={props.timestampFormat}
2172 />
2173 ) : null}
2174 {props.settlementSupported ? (
2175 <Tooltip>
2176 <TooltipTrigger
2177 render={
2178 <button
2179 type="button"
2180 aria-label="Settle thread"
2181 onClick={handleSettleClick}
2182 onPointerDown={handleActionPointerDown}
2183 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
2184 />
2185 }
2186 >
2187 <CheckIcon className="size-3.5" />
2188 Settle
2189 </TooltipTrigger>
2190 <TooltipPopup>Settle thread</TooltipPopup>
2191 </Tooltip>
2192 ) : null}
2193 </span>
2194 ) : null}
2195 </span>
2196 )}
2197 {/* A sweep hides the slot rather than unmounting it, so the
2198 pressed action button stays connected and a cancelled sweep's
2199 release click still fires and is consumed. */}
2200 {props.sweepAction !== null ? dragDestination : null}
2201 </div>
2202 <div className="mt-1 flex min-w-0">
2203 {title}
2204 {isRegeneratingTitle ? (
2205 <span role="status" className="sr-only">
2206 Regenerating title
2207 </span>
2208 ) : null}
2209 </div>
2210 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
2211 {mergebotPullRequest ? (
2212 <ThreadMergebotPill
2213 environmentId={thread.environmentId}
2214 repository={mergebotPullRequest.repository}
2215 number={mergebotPullRequest.number}
2216 />
2217 ) : null}
2218 {/* Always the branch. The plan step used to take this slot while
2219 working, but it truncated to a half-sentence and dropped the
2220 branch, so the row lost its most stable identifier. */}
2221 {thread.branch ? (
2222 <>
2223 <ThreadWorktreeIndicator thread={thread} />
2224 <span className="flex min-w-0 flex-1 text-muted-foreground/40">
2225 <MiddleTruncate value={thread.branch} showTitle={false} />
2226 </span>
2227 </>
2228 ) : showsScratchMachine ? (
2229 <>
2230 <EnvironmentMachineIcon
2231 aria-hidden
2232 kind={props.environmentMachine}
2233 className="size-3 shrink-0 text-muted-foreground/40"
2234 />
2235 <span className="min-w-0 flex-1 truncate text-muted-foreground/40">
2236 {props.scratchMachineLabel}
2237 </span>
2238 </>
2239 ) : (
2240 <span className="flex-1" />
2241 )}
2242 {terminalStatusIcon}
2243 {mergebotPullRequest ? null : prBadge}
2244 {diff ? (
2245 <span className="shrink-0 font-mono">
2246 <span className="text-diff-addition-foreground">
2247 +{formatDiffCount(diff.insertions)}
2248 </span>{" "}
2249 <span className="text-diff-deletion-foreground">
2250 −{formatDiffCount(diff.deletions)}
2251 </span>
2252 </span>
2253 ) : null}
2254 <span
2255 aria-hidden
2256 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
2257 >
2258 {isRemote && !showsScratchMachine ? (
2259 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
2260 <EnvironmentMachineIcon
2261 aria-hidden
2262 kind={props.environmentMachine}
2263 className="size-3.5"
2264 />
2265 </span>
2266 ) : null}
2267 <SidebarProviderStack
2268 thread={thread}
2269 providerEntryByInstanceId={props.providerEntryByInstanceId}
2270 />
2271 </span>
2272 </div>
2273 </div>
2274 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
2275 </TooltipTrigger>
2276 {detailsTooltip}
2277 </Tooltip>
2278 </li>
2279 );
2280});
2281
2282function latestRunDiff(
2283 thread: SidebarThreadSummary,
2284): { insertions: number; deletions: number } | null {
2285 // Shells don't carry checkpoint summaries; diff stats render only when the
2286 // shell projection grows them. Kept as a seam so the row layout is ready.
2287 void thread;
2288 return null;
2289}
2290
2291const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2292 isSettled: boolean;
2293 thread: SidebarThreadSummary;
2294 project: EnvironmentProject | null;
2295 projectDisplayName: string | null;
2296 environmentLabel: string | null;
2297 environmentMachine: EnvironmentMachineKind;
2298 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
2299 isHighlighted: boolean;
2300 isRouteActive: boolean;
2301 resultId: string;
2302 searchMatch: EnvironmentThreadSearchMatch | null;
2303 searchQuery: string;
2304 onHighlight: () => void;
2305 onSelect: () => void;
2306 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
2307}) {
2308 const { thread } = props;
2309 const accessibility = resolveSidebarRowAccessibility({
2310 title: thread.title,
2311 statusLabel: null,
2312 projectDisplayName: props.projectDisplayName,
2313 isActive: props.isRouteActive,
2314 });
2315 const threadRef = useMemo(
2316 () => scopeThreadRef(thread.environmentId, thread.id),
2317 [thread.environmentId, thread.id],
2318 );
2319 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(
2320 props.isHighlighted || props.isRouteActive,
2321 );
2322 // Same details tooltip as the regular rows: a search hit is still a thread,
2323 // and the hover card is how you disambiguate identically-titled results.
2324 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
2325 const gitStatus = useEnvironmentQuery(
2326 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
2327 ? vcsEnvironment.status({
2328 environmentId: thread.environmentId,
2329 input: { cwd: gitCwd, includeRemote: false },
2330 })
2331 : null,
2332 );
2333 const visibleGitStatus = useRetainedValue(
2334 JSON.stringify([thread.environmentId, gitCwd]),
2335 gitStatus.data,
2336 );
2337 const branchMismatch = resolveLocalCheckoutBranchMismatch({
2338 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
2339 activeWorktreePath: thread.worktreePath,
2340 activeThreadBranch: thread.branch,
2341 currentGitBranch: visibleGitStatus?.refName ?? null,
2342 });
2343 const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
2344 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
2345 const showInstanceBadge =
2346 providerEntry !== null &&
2347 shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values());
2348 const selectedModel = providerEntry?.models.find(
2349 (model) => model.slug === thread.modelSelection.model,
2350 );
2351 const modelLabel = selectedModel
2352 ? getTriggerDisplayModelLabel(selectedModel)
2353 : thread.modelSelection.model;
2354 const runningTerminalIds = useThreadRunningTerminalIds({
2355 environmentId: thread.environmentId,
2356 threadId: thread.id,
2357 });
2358 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
2359 const [isFileDragOver, setIsFileDragOver] = useState(false);
2360 const fileDropHandlers = useMemo(
2361 () =>
2362 makeWorkspaceFileDropHandlers({
2363 setDragActive: setIsFileDragOver,
2364 addFiles: (files) => {
2365 props.onFileDropThreads(threadRef, files);
2366 },
2367 addFolders: () => {},
2368 }),
2369 [props.onFileDropThreads, threadRef],
2370 );
2371 useEffect(() => {
2372 if (!isFileDragOver) return;
2373 const clearFileDrag = () => setIsFileDragOver(false);
2374 window.addEventListener("dragend", clearFileDrag);
2375 return () => window.removeEventListener("dragend", clearFileDrag);
2376 }, [isFileDragOver]);
2377 return (
2378 <li role="presentation" className="list-none" {...fileDropHandlers}>
2379 <Tooltip>
2380 <TooltipTrigger
2381 render={
2382 <button
2383 ref={rowRef}
2384 id={props.resultId}
2385 type="button"
2386 role="option"
2387 // aria-activedescendant options: focus stays on the search input,
2388 // which owns all keyboard interaction for the listbox.
2389 tabIndex={-1}
2390 aria-selected={props.isHighlighted}
2391 aria-current={accessibility.current}
2392 aria-describedby={`${props.resultId}-pr`}
2393 aria-label={accessibility.label}
2394 onMouseMove={props.onHighlight}
2395 onClick={props.onSelect}
2396 className={cn(
2397 "group/sidebar-row group/v2-row flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none",
2398 props.isHighlighted || props.isRouteActive
2399 ? "bg-sidebar-row-active text-sidebar-foreground"
2400 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
2401 isFileDragOver && "ring-1 ring-inset ring-primary/70",
2402 isFileDragOver && !props.isRouteActive && "bg-sidebar-row-hover",
2403 )}
2404 />
2405 }
2406 >
2407 {props.project ? (
2408 <ProjectFavicon
2409 project={props.project}
2410 className={cn(
2411 "size-4 shrink-0 transition-opacity",
2412 props.isSettled &&
2413 !props.isRouteActive &&
2414 !props.isHighlighted &&
2415 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
2416 )}
2417 />
2418 ) : null}
2419 <span className="flex min-w-0 flex-1 flex-col">
2420 <span className="flex min-w-0 items-center gap-2.5">
2421 <span
2422 className={cn(
2423 "min-w-0 flex-1 truncate",
2424 props.isSettled
2425 ? "font-normal text-secondary-label/70 group-hover/sidebar-row:text-foreground"
2426 : "font-medium text-foreground",
2427 (props.isHighlighted || props.isRouteActive) && "text-foreground",
2428 thread.titleRegeneration != null && "opacity-55",
2429 )}
2430 >
2431 {thread.title}
2432 </span>
2433 <span id={`${props.resultId}-pr`} className="contents">
2434 <ThreadSearchPullRequestNumber
2435 thread={thread}
2436 query={props.searchQuery}
2437 enabled={leaseLiveStatus}
2438 settled={props.isSettled}
2439 />
2440 </span>
2441 <span className="w-8 shrink-0 text-right text-xs text-muted-foreground/55 tabular-nums">
2442 {threadTimeLabel(thread)}
2443 </span>
2444 </span>
2445 {props.searchMatch ? (
2446 <ThreadSearchMatchExcerpt
2447 match={{
2448 source: props.searchMatch.source,
2449 snippet: props.searchMatch.snippet,
2450 query: props.searchQuery,
2451 }}
2452 />
2453 ) : null}
2454 </span>
2455 </TooltipTrigger>
2456 <SidebarThreadTooltip
2457 thread={thread}
2458 project={props.project}
2459 projectDisplayName={props.projectDisplayName}
2460 environmentLabel={props.environmentLabel}
2461 environmentMachine={props.environmentMachine}
2462 providerEntry={providerEntry}
2463 providerEntryByInstanceId={props.providerEntryByInstanceId}
2464 showInstanceBadge={showInstanceBadge}
2465 modelInstanceId={modelInstanceId}
2466 modelLabel={modelLabel}
2467 branchMismatch={branchMismatch}
2468 terminalStatus={terminalStatus}
2469 terminalProcessCount={runningTerminalIds.length}
2470 />
2471 </Tooltip>
2472 </li>
2473 );
2474});
2475
2476export default function Sidebar() {
2477 const projects = useProjects();
2478 const threads = useThreadShells();
2479 const router = useRouter();
2480 const { isMobile, setOpenMobile } = useSidebar();
2481 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2482 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
2483 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
2484 const timestampFormat = useClientSettings((s) => s.timestampFormat);
2485 const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
2486 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
2487 const {
2488 settleThread,
2489 confirmThreadSettlement,
2490 unsettleThread,
2491 snoozeThread,
2492 unsnoozeThread,
2493 pinThread,
2494 unpinThread,
2495 confirmAndUnpinThread,
2496 setThreadAutoSettle,
2497 reorderPinnedThread,
2498 reorderActiveThread,
2499 markThreadUnread,
2500 archiveThread,
2501 deleteThread,
2502 } = useThreadActions();
2503 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
2504 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
2505 reportFailure: false,
2506 });
2507 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
2508 onCopy: ({ path }) => {
2509 toastManager.add({
2510 type: "success",
2511 title: "Path copied",
2512 description: path,
2513 });
2514 },
2515 onError: (error) => {
2516 toastManager.add(
2517 stackedThreadToast({
2518 type: "error",
2519 title: "Failed to copy path",
2520 description: error instanceof Error ? error.message : "An error occurred.",
2521 }),
2522 );
2523 },
2524 });
2525 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
2526 target: "branch name",
2527 onCopy: ({ branch }) => {
2528 toastManager.add({
2529 type: "success",
2530 title: "Branch copied",
2531 description: branch,
2532 });
2533 },
2534 onError: (error) => {
2535 toastManager.add(
2536 stackedThreadToast({
2537 type: "error",
2538 title: "Failed to copy branch",
2539 description: error instanceof Error ? error.message : "An error occurred.",
2540 }),
2541 );
2542 },
2543 });
2544 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
2545 onCopy: ({ threadId }) => {
2546 toastManager.add({
2547 type: "success",
2548 title: "Thread ID copied",
2549 description: threadId,
2550 });
2551 },
2552 onError: (error) => {
2553 toastManager.add(
2554 stackedThreadToast({
2555 type: "error",
2556 title: "Failed to copy thread ID",
2557 description: error instanceof Error ? error.message : "An error occurred.",
2558 }),
2559 );
2560 },
2561 });
2562 const newThreadContext = useHandleNewThread();
2563 const openAddProjectCommandPalette = useCallback(
2564 () => openCommandPalette({ open: "add-project" }),
2565 [],
2566 );
2567 const environments = useEnvironmentIdentities();
2568 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
2569 const environmentCapabilities = useAtomValue(environmentCapabilitiesAtom);
2570 const primaryEnvironmentId = usePrimaryEnvironmentId();
2571 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
2572 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
2573 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
2574 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
2575 const acknowledgeWoke = useAcknowledgeThreadWoke();
2576 const routeTarget = useParams({
2577 strict: false,
2578 select: (params) => resolveThreadRouteTarget(params),
2579 });
2580 const routeDraftThread = useComposerDraftStore((store) =>
2581 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
2582 );
2583 const routeThreadRef = useMemo(
2584 () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread),
2585 [routeDraftThread, routeTarget],
2586 );
2587 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
2588 const routeTargetRef = useRef(routeTarget);
2589 routeTargetRef.current = routeTarget;
2590 // Post-settle navigation validates against the CURRENT route, not the one
2591 // captured when the settle started: if the user navigated elsewhere while
2592 // the command was in flight, completing it must not yank them away.
2593 const routeThreadKeyRef = useRef(routeThreadKey);
2594 routeThreadKeyRef.current = routeThreadKey;
2595
2596 const environmentLabelById = useMemo(
2597 () =>
2598 new Map(
2599 environments.map((environment) => [environment.environmentId, environment.label] as const),
2600 ),
2601 [environments],
2602 );
2603 const environmentMachineById = useEnvironmentMachines();
2604 const projectGroups = useSidebarProjectGroups();
2605 const projectGroupsRef = useRef(projectGroups);
2606 projectGroupsRef.current = projectGroups;
2607 // Threads on non-primary environments (T3 Connect, hosted) resolve their
2608 // provider entry from their own environment's config: default instance ids
2609 // are driver slugs, so a flat map would collide across environments.
2610 const providerEntriesByEnvironment = useMemo(
2611 () =>
2612 deriveProviderEntriesByEnvironment(
2613 [...serverConfigs].map(
2614 ([environmentId, config]) => [environmentId, config.providers, config.settings] as const,
2615 ),
2616 ),
2617 [serverConfigs],
2618 );
2619 // Rows read the project record for its icon and cwd. Group labels can include
2620 // a repository owner or a different title, so they travel separately.
2621 const projectByKey = useMemo(
2622 () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])),
2623 [projects],
2624 );
2625 const projectDisplayNameByKey = useMemo(
2626 () =>
2627 new Map(
2628 projectGroups.flatMap((group) =>
2629 group.memberProjects.map(
2630 (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
2631 ),
2632 ),
2633 ),
2634 [projectGroups],
2635 );
2636
2637 const nowMinute = useNowMinute();
2638 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
2639 // Snooze wake times are second-precise, so classifying with the quantized
2640 // minute would hold a woken thread on the shelf for up to a minute. The
2641 // tick is a plain counter bumped exactly at the next wake boundary (armed
2642 // below, after the partition knows the boundary); the partition reads a
2643 // fresh clock whenever it recomputes.
2644 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
2645
2646 // Project scope: one menu above the list. Scoping filters the list without
2647 // making the header width depend on the number or length of project names.
2648 // The selection lives in the persisted UI store next to the other sidebar
2649 // project preferences, so routes that unmount the sidebar (Settings) and
2650 // app restarts keep it.
2651 const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey);
2652 const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey);
2653 const projectScopeKey = useMemo(
2654 () =>
2655 resolveSidebarProjectScopeKey({
2656 groups: projectGroups,
2657 key: persistedProjectScopeKey,
2658 settings: projectGroupingSettings,
2659 }),
2660 [persistedProjectScopeKey, projectGroups, projectGroupingSettings],
2661 );
2662 // {value, label} items let Base UI drive the combobox selection contract
2663 // while the popup search filters the same collection.
2664 const projectScopeItems = useMemo(
2665 () => [
2666 { value: "all", label: "All projects" },
2667 ...projectGroups.map((project) => ({
2668 value: project.projectKey,
2669 label: project.displayName,
2670 })),
2671 ],
2672 [projectGroups],
2673 );
2674 // Same-named projects on two machines are only told apart by where they
2675 // live, so rows on another machine carry its icon once the catalog spans
2676 // more than one environment; a single-machine catalog stays as it was.
2677 const showProjectEnvironments = useMemo(
2678 () => projectGroupsSpanEnvironments(projectGroups),
2679 [projectGroups],
2680 );
2681 const scratchMachineLabelFor = useCallback(
2682 (thread: Pick<SidebarThreadSummary, "environmentId" | "projectId">) => {
2683 if (!showProjectEnvironments) return null;
2684 const project = projectByKey.get(`${thread.environmentId}:${thread.projectId}`);
2685 if (
2686 !project ||
2687 !isScratchProject(project, serverConfigs.get(thread.environmentId)?.scratchWorkspaceRoot)
2688 ) {
2689 return null;
2690 }
2691 return environmentLabelById.get(thread.environmentId) ?? null;
2692 },
2693 [environmentLabelById, projectByKey, serverConfigs, showProjectEnvironments],
2694 );
2695 const projectGroupByScopeKey = useMemo(
2696 () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
2697 [projectGroups],
2698 );
2699 const selectedProjectScopeItem = useMemo(
2700 () =>
2701 projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ??
2702 projectScopeItems[0]!,
2703 [projectScopeItems, projectScopeKey],
2704 );
2705 const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer(
2706 reduceSidebarProjectScopeMenuState,
2707 { open: false, query: "" },
2708 );
2709 const projectScopeFilter = useComboboxFilter();
2710 // Filtering derives from the same React state that controls the input, so
2711 // the visible query and the visible list can never desync — the peer wiring
2712 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default
2713 // row, not a searchable entry: it heads the list while the query is empty and
2714 // drops out while filtering, so it can't outrank a project match under
2715 // autoHighlight and no-hit queries reach the empty state.
2716 const filteredProjectScopeItems = useMemo(
2717 () =>
2718 filterSidebarProjectScopeItems({
2719 items: projectScopeItems,
2720 query: projectScopeMenuState.query,
2721 matches: (item, query) =>
2722 projectScopeFilter.contains(item, query, (candidate) => candidate.label),
2723 }),
2724 [projectScopeFilter, projectScopeItems, projectScopeMenuState.query],
2725 );
2726 const scopedProjectGroup = useMemo(
2727 () =>
2728 projectScopeKey === null
2729 ? null
2730 : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null),
2731 [projectGroups, projectScopeKey],
2732 );
2733 const scopedProjectKeys = useMemo(
2734 () =>
2735 scopedProjectGroup === null
2736 ? null
2737 : new Set(
2738 scopedProjectGroup.memberProjectRefs.map(
2739 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
2740 ),
2741 ),
2742 [scopedProjectGroup],
2743 );
2744 // Configs identify scratch projects, so both they and live project snapshots
2745 // must arrive before a persisted scope can be considered missing.
2746 const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady();
2747 const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom);
2748 useEffect(() => {
2749 const nextKey = resolveSidebarProjectScopeKey({
2750 groups: projectGroups,
2751 key: persistedProjectScopeKey,
2752 settings: projectGroupingSettings,
2753 canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady,
2754 });
2755 if (nextKey !== persistedProjectScopeKey) {
2756 setProjectScopeKey(nextKey);
2757 }
2758 }, [
2759 allProjectConfigsReady,
2760 allProjectSnapshotsReady,
2761 persistedProjectScopeKey,
2762 projectGroups,
2763 projectGroupingSettings,
2764 setProjectScopeKey,
2765 ]);
2766 // Count-only subscription: the parent needs "are there draft rows" for the
2767 // empty state, while SidebarDraftBlock owns the per-keystroke content
2768 // subscription. Selecting a number keeps typing in a draft composer from
2769 // re-rendering the whole sidebar. Approximates the block's row filter
2770 // (every non-promoted session with content); it can overcount by one for
2771 // an open never-left draft, which only softens the empty state.
2772 const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
2773 const visibleDraftSessionCount = useComposerDraftStore((store) => {
2774 let count = 0;
2775 for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) {
2776 if (session.promotedTo != null) {
2777 continue;
2778 }
2779 if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) {
2780 continue;
2781 }
2782 if (
2783 scopedProjectKeys !== null &&
2784 !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`)
2785 ) {
2786 continue;
2787 }
2788 count += 1;
2789 }
2790 return count;
2791 });
2792 // Scope flips drop the selection: rows selected under the old scope may be
2793 // hidden now, and bulk actions must never count or touch invisible rows.
2794 useEffect(() => {
2795 clearSelection();
2796 }, [clearSelection, projectScopeKey]);
2797
2798 const openProjectSettings = useCallback(
2799 (projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => {
2800 if (isMobile) {
2801 setOpenMobile(false);
2802 }
2803 void router.navigate({
2804 to: "/projects/$projectKey",
2805 params: {
2806 projectKey: getSidebarProjectSettingsKey(
2807 projectGroup,
2808 projectGroupingSettings,
2809 projects,
2810 projectRef,
2811 ),
2812 },
2813 });
2814 },
2815 [isMobile, projectGroupingSettings, projects, router, setOpenMobile],
2816 );
2817 // Anchor for the scope popup: the header search field, not its icon trigger.
2818 const headerSearchRef = useRef<HTMLDivElement | null>(null);
2819 // Safari can send a click after Ctrl+click opens settings. Ignore that one
2820 // selection, then clear the guard when the picker opens again.
2821 const suppressNextScopeChangeRef = useRef(false);
2822 const highlightedProjectScopeKeyRef = useRef<string | null>(null);
2823 const handleProjectSettings = useCallback(
2824 (
2825 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLInputElement>,
2826 projectGroup: SidebarProjectSnapshot,
2827 ) => {
2828 event.preventDefault();
2829 event.stopPropagation();
2830 suppressNextScopeChangeRef.current = true;
2831 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2832 openProjectSettings(projectGroup);
2833 },
2834 [openProjectSettings],
2835 );
2836
2837 // Keep a dropped row at its destination while its server applies the
2838 // lifecycle command and any order-key writes. The next pickup waits for
2839 // this hold so a second drop cannot replace an unconfirmed placement.
2840 const [optimisticDrop, setOptimisticDrop] = useState<{
2841 readonly key: string;
2842 readonly sourceSection: SidebarSection;
2843 readonly section: "pinned" | "active" | "settled";
2844 readonly occurredAt: string;
2845 readonly clearsSnooze: boolean;
2846 /** Full destination order for pinned and active drops. */
2847 readonly order: readonly string[] | null;
2848 /** Destination order keys before the drop, to recognize concurrent writes. */
2849 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2850 /** The keys this drop writes (one per planned assignment). The
2851 override holds until all of them appear in canonical state. */
2852 readonly assignedKeys: ReadonlyMap<string, string>;
2853 } | null>(null);
2854 const {
2855 pinnedThreads,
2856 draggableThreadKeys,
2857 activeReorderableThreadKeys,
2858 activeThreads,
2859 workingThreads,
2860 snoozedThreads,
2861 settledThreads,
2862 snoozeNow,
2863 } = useMemo(() => {
2864 // Snooze classification uses a REAL clock, not the quantized minute:
2865 // wake times are second-precise and a woken thread must not linger on
2866 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
2867 // memo exactly at the next wake boundary.
2868 void snoozeWakeTick;
2869 const preciseNow = new Date().toISOString();
2870 // Subagent child threads live in the parent's Agents surface, not the
2871 // sidebar roster (v2 models them as real threads with lineage).
2872 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
2873 inboxReturns.observe(workingShelfEnabled ? threads : null);
2874 const pinned: EnvironmentThreadShell[] = [];
2875 const active: EnvironmentThreadShell[] = [];
2876 const working: EnvironmentThreadShell[] = [];
2877 // Working beta: only inbox threads fold away. Pins stay where the user
2878 // put them, and snoozed or settled threads keep their shelves.
2879 const inbox = (thread: EnvironmentThreadShell) =>
2880 workingShelfEnabled && isSidebarThreadWorking(thread) ? working : active;
2881 const snoozed: EnvironmentThreadShell[] = [];
2882 const settled: EnvironmentThreadShell[] = [];
2883 const draggable = new Set<string>();
2884 const activeReorderable = new Set<string>();
2885 for (const thread of visible) {
2886 const capabilities = environmentCapabilities.get(thread.environmentId);
2887 // Threads on servers without the settlement capability (old server,
2888 // or descriptor not loaded yet) never classify as settled: the user
2889 // could neither un-settle nor pin them, so auto-settling them would
2890 // strand rows in a tail with no working affordances.
2891 const supportsSettlement = capabilities?.threadSettlement === true;
2892 const supportsSnooze = capabilities?.threadSnooze === true;
2893 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
2894 if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey);
2895 // Older servers retain their existing drag actions. Active placement
2896 // additionally requires its own ordering capability at the drop target.
2897 if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) {
2898 draggable.add(threadKey);
2899 }
2900 if (optimisticDrop?.key === threadKey) {
2901 const projected = applySidebarThreadDrop(
2902 thread,
2903 optimisticDrop.section,
2904 optimisticDrop.occurredAt,
2905 optimisticDrop.assignedKeys.get(threadKey),
2906 );
2907 (optimisticDrop.section === "pinned"
2908 ? pinned
2909 : optimisticDrop.section === "settled"
2910 ? settled
2911 : inbox(projected)
2912 ).push(
2913 optimisticDrop.clearsSnooze
2914 ? projected
2915 : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil },
2916 );
2917 } else {
2918 const section = resolveSidebarThreadSection({
2919 snoozed: supportsSnooze && effectiveSnoozed(thread, { now: preciseNow }),
2920 settled: supportsSettlement && thread.settledOverride === "settled",
2921 pinned: thread.pinnedAt != null,
2922 });
2923 (section === "snoozed"
2924 ? snoozed
2925 : section === "settled"
2926 ? settled
2927 : section === "pinned"
2928 ? pinned
2929 : inbox(thread)
2930 ).push(thread);
2931 }
2932 }
2933 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
2934 // user-arranged keys first, keyless threads in creation order below.
2935 // Server capability only gates DRAGGING — it must not influence the
2936 // sort, or mixed-version fleets would render different pinned orders on
2937 // web and mobile from the same data.
2938 const sortedPinned = sortPinnedThreadsForSidebar(pinned);
2939 const sortedActive = workingShelfEnabled
2940 ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt)
2941 : sortThreadsForSidebar(active);
2942 return {
2943 pinnedThreads:
2944 optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
2945 ? sortedPinned
2946 : orderItemsByPreferredIds({
2947 items: sortedPinned,
2948 preferredIds: optimisticDrop.order,
2949 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2950 }),
2951 draggableThreadKeys: draggable,
2952 activeReorderableThreadKeys: activeReorderable,
2953 activeThreads:
2954 optimisticDrop?.section !== "active" || optimisticDrop.order === null
2955 ? sortedActive
2956 : orderItemsByPreferredIds({
2957 items: sortedActive,
2958 preferredIds: optimisticDrop.order,
2959 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2960 }),
2961 // Newest send first; finishing and waking again do not move a row.
2962 workingThreads: sortWorkingThreadsBySend(working),
2963 // Soonest wake first: "what comes back next" is the shelf's question.
2964 snoozedThreads: snoozed.toSorted(
2965 (left, right) =>
2966 firstValidTimestampMs(left.snoozedUntil ?? null) -
2967 firstValidTimestampMs(right.snoozedUntil ?? null),
2968 ),
2969 settledThreads: sortSettledThreads(settled),
2970 snoozeNow: preciseNow,
2971 };
2972 }, [
2973 nowMinute,
2974 optimisticDrop,
2975 scopedProjectKeys,
2976 environmentCapabilities,
2977 snoozeWakeTick,
2978 threads,
2979 workingShelfEnabled,
2980 ]);
2981
2982 const threadSearchInputRef = useRef<HTMLInputElement>(null);
2983 const [threadSearchQuery, setThreadSearchQuery] = useState("");
2984 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
2985 const isSearchingThreads = threadSearchQuery.trim().length > 0;
2986 const searchableThreads = useMemo(
2987 () => [
2988 ...pinnedThreads,
2989 ...activeThreads,
2990 ...workingThreads,
2991 ...snoozedThreads,
2992 ...settledThreads,
2993 ],
2994 [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads],
2995 );
2996 const searchEnvironmentIds = useConnectedEnvironmentIds();
2997 // useThreadSearch owns the debounce and the two-character floor.
2998 const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery);
2999 const threadSearchMatchByKey = useMemo(
3000 () =>
3001 new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)),
3002 [threadSearch.matches],
3003 );
3004 const threadSearchResults = useMemo(
3005 () =>
3006 searchSidebarThreads(
3007 searchableThreads,
3008 threadSearchQuery,
3009 new Set(threadSearchMatchByKey.keys()),
3010 ),
3011 [searchableThreads, threadSearchQuery, threadSearchMatchByKey],
3012 );
3013 const threadSearchResultOrderKey = threadSearchResults
3014 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
3015 .join("\0");
3016
3017 useEffect(() => {
3018 setActiveSearchResultIndex(0);
3019 }, [threadSearchResultOrderKey]);
3020
3021 useEffect(() => {
3022 if (!isSearchingThreads) return;
3023 document
3024 .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`)
3025 ?.scrollIntoView({ block: "nearest" });
3026 }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]);
3027
3028 // Arm a timeout for the earliest upcoming wake so the shelf empties the
3029 // moment a snooze expires instead of on the next minute tick. Sorted
3030 // soonest-first, so entry 0 is the boundary.
3031 useEffect(() => {
3032 const nextWakeAtMs =
3033 snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null
3034 ? Date.parse(snoozedThreads[0].snoozedUntil)
3035 : Number.NaN;
3036 if (Number.isNaN(nextWakeAtMs)) return;
3037 // setTimeout delays are signed 32-bit: anything larger overflows and
3038 // fires immediately, turning a far-future wake (event-condition snoozes
3039 // synced from elsewhere) into a tight re-arm loop. Clamped, the timer
3040 // just re-arms every ~24.8 days until the wake is in range.
3041 const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647);
3042 const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
3043 return () => window.clearTimeout(id);
3044 }, [snoozedThreads]);
3045
3046 // Changing project scope restores the recent tail; the open thread stays visible.
3047 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
3048 const settledResetKey = projectScopeKey ?? "all";
3049 const lastSettledResetKeyRef = useRef(settledResetKey);
3050 if (lastSettledResetKeyRef.current !== settledResetKey) {
3051 lastSettledResetKeyRef.current = settledResetKey;
3052 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
3053 }
3054 const visibleSettledThreads = useMemo(() => {
3055 if (settledThreads.length <= settledVisibleCount) return settledThreads;
3056 const visible = settledThreads.slice(0, settledVisibleCount);
3057 // The open thread must never hide under "Show more": navigating into a
3058 // deep settled thread (search, deep link) pulls its row into the visible
3059 // tail so the highlight and the un-settle affordance stay reachable.
3060 if (routeThreadKey !== null) {
3061 const routeThread = settledThreads
3062 .slice(settledVisibleCount)
3063 .find(
3064 (thread) =>
3065 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
3066 );
3067 if (routeThread !== undefined) visible.push(routeThread);
3068 }
3069 return visible;
3070 }, [routeThreadKey, settledThreads, settledVisibleCount]);
3071 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;
3072 const showMoreSettled = useCallback(
3073 () => setSettledVisibleCount(settledThreads.length),
3074 [settledThreads.length],
3075 );
3076 const [archivingThreads, setArchivingThreads] = useState(false);
3077 const archiveThreads = useCallback(
3078 async (targets: readonly EnvironmentThreadShell[], onlySettled = false) => {
3079 if (archivingThreads || targets.length === 0 || !checkThreadOperations(targets)) return;
3080 const entries = targets.map((thread) => {
3081 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
3082 return { threadRef, threadKey: scopedThreadKey(threadRef) };
3083 });
3084 setArchivingThreads(true);
3085 try {
3086 if (confirmThreadArchive) {
3087 const api = readLocalApi();
3088 if (!api) return;
3089 const confirmation = await settlePromise(() =>
3090 api.dialogs.confirm(
3091 `Archive ${entries.length}${onlySettled ? " settled" : ""} thread${entries.length === 1 ? "" : "s"}?`,
3092 ),
3093 );
3094 if (confirmation._tag === "Failure" || !confirmation.value) return;
3095 }
3096 if (!checkThreadOperations(targets)) return;
3097 const outcome = await archiveSelectedThreadEntries({
3098 entries,
3099 archive: ({ threadRef }, onArchived) => {
3100 const snapshot = appAtomRegistry.get(environmentSnapshotAtom(threadRef.environmentId));
3101 const current = snapshot?.threads.find((thread) => thread.id === threadRef.threadId);
3102 if (
3103 snapshot === null ||
3104 current === undefined ||
3105 current.archivedAt !== null ||
3106 (onlySettled && current.settledOverride !== "settled")
3107 ) {
3108 return Promise.resolve(null);
3109 }
3110 return archiveThread(threadRef, {
3111 onArchived,
3112 ...(onlySettled ? { snapshotSequence: snapshot.snapshotSequence } : {}),
3113 });
3114 },
3115 });
3116 removeFromSelection(outcome.archivedThreadKeys);
3117 for (const failure of [outcome.mutationFailure, ...outcome.followupFailures]) {
3118 if (failure === null || isAtomCommandInterrupted(failure)) continue;
3119 const error = squashAtomCommandFailure(failure);
3120 toastManager.add(
3121 stackedThreadToast({
3122 type: "error",
3123 title:
3124 failure === outcome.mutationFailure
3125 ? "Failed to archive threads"
3126 : "Thread archived, but navigation failed",
3127 description: error instanceof Error ? error.message : "An error occurred.",
3128 }),
3129 );
3130 }
3131 } finally {
3132 setArchivingThreads(false);
3133 }
3134 },
3135 [archiveThread, archivingThreads, confirmThreadArchive, removeFromSelection],
3136 );
3137 const handleSettledContextMenu = async (position: { x: number; y: number }) => {
3138 const api = readLocalApi();
3139 if (!api) return;
3140 const clicked = await settlePromise(() =>
3141 api.contextMenu.show(
3142 [
3143 {
3144 id: "archive-all",
3145 label: "Archive all",
3146 disabled:
3147 settledThreads.length === 0 || archivingThreads || !canOperateThreads(settledThreads),
3148 },
3149 ],
3150 position,
3151 ),
3152 );
3153 if (clicked._tag === "Success" && clicked.value === "archive-all") {
3154 await archiveThreads(settledThreads, true);
3155 }
3156 };
3157 const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage(
3158 SETTLED_SHELF_EXPANDED_KEY,
3159 false,
3160 Schema.Boolean,
3161 );
3162 const toggleSettledShelf = useCallback(
3163 () => setSettledShelfExpanded((value) => !value),
3164 [setSettledShelfExpanded],
3165 );
3166 const renderedSettledThreads = useMemo(() => {
3167 if (settledShelfExpanded) return visibleSettledThreads;
3168 if (routeThreadKey === null) return EMPTY_THREADS;
3169 const routeThread = visibleSettledThreads.find(
3170 (thread) =>
3171 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
3172 );
3173 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
3174 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
3175
3176 // The snoozed shelf is collapsed by default: out of the way, never gone.
3177 // Collapsed threads don't render (and so don't participate in jump
3178 // shortcuts or multi-select), matching the settled tail's paging model.
3179 const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useLocalStorage(
3180 SNOOZED_SHELF_EXPANDED_KEY,
3181 false,
3182 Schema.Boolean,
3183 );
3184 const toggleSnoozedShelf = useCallback(
3185 () => setSnoozedShelfExpanded((value) => !value),
3186 [setSnoozedShelfExpanded],
3187 );
3188 const visibleSnoozedThreads = useMemo(() => {
3189 if (snoozedShelfExpanded) return snoozedThreads;
3190 // The open thread must never vanish behind the collapsed shelf: a
3191 // snoozed thread reached by route (deep link, open before snoozing
3192 // elsewhere) keeps its row — with highlight and wake affordance — same
3193 // exception the settled tail's "Show more" makes.
3194 if (routeThreadKey === null) return EMPTY_THREADS;
3195 const routeThread = snoozedThreads.find(
3196 (thread) =>
3197 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
3198 );
3199 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
3200 }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]);
3201
3202 // The Working shelf (beta) collapses the same way, with the same route
3203 // exception: sending a message folds the open thread into the shelf, and
3204 // its row must stay visible there.
3205 const [workingShelfExpanded, setWorkingShelfExpanded] = useLocalStorage(
3206 WORKING_SHELF_EXPANDED_KEY,
3207 false,
3208 Schema.Boolean,
3209 );
3210 const toggleWorkingShelf = useCallback(
3211 () => setWorkingShelfExpanded((value) => !value),
3212 [setWorkingShelfExpanded],
3213 );
3214 const visibleWorkingThreads = useMemo(() => {
3215 if (workingShelfExpanded) return workingThreads;
3216 if (routeThreadKey === null) return EMPTY_THREADS;
3217 const routeThread = workingThreads.find(
3218 (thread) =>
3219 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
3220 );
3221 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
3222 }, [routeThreadKey, workingShelfExpanded, workingThreads]);
3223
3224 const orderedThreads = useMemo(
3225 () => [
3226 ...pinnedThreads,
3227 ...activeThreads,
3228 ...visibleWorkingThreads,
3229 ...visibleSnoozedThreads,
3230 ...renderedSettledThreads,
3231 ],
3232 [
3233 pinnedThreads,
3234 activeThreads,
3235 visibleWorkingThreads,
3236 visibleSnoozedThreads,
3237 renderedSettledThreads,
3238 ],
3239 );
3240 const orderedThreadKeys = useMemo(
3241 () =>
3242 orderedThreads.map((thread) =>
3243 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3244 ),
3245 [orderedThreads],
3246 );
3247 // Rows call back into the click handler without carrying the ordered list as
3248 // a prop — a fresh array identity per shell update would defeat every row's
3249 // memoization. The ref keeps shift-range-select working against the list as
3250 // rendered at click time.
3251 const orderedThreadKeysRef = useRef(orderedThreadKeys);
3252 orderedThreadKeysRef.current = orderedThreadKeys;
3253 const threadByKey = useMemo(
3254 () =>
3255 new Map(
3256 orderedThreads.map(
3257 (thread) =>
3258 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
3259 ),
3260 ),
3261 [orderedThreads],
3262 );
3263 // Handlers read these through refs: depending on per-update Map/Set
3264 // identities would give every row a fresh callback prop on each shell
3265 // event and defeat row memoization during streaming.
3266 const threadByKeyRef = useRef(threadByKey);
3267 threadByKeyRef.current = threadByKey;
3268 // handleNewThread is inherently unstable (depends on the projects list);
3269 // a ref keeps it out of attemptSettle's dependency array.
3270 const handleNewThreadRef = useRef(newThreadContext.handleNewThread);
3271 handleNewThreadRef.current = newThreadContext.handleNewThread;
3272 const settledThreadKeys = useMemo(
3273 () =>
3274 new Set(
3275 settledThreads.map((thread) =>
3276 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3277 ),
3278 ),
3279 [settledThreads],
3280 );
3281 const settledThreadKeysRef = useRef(settledThreadKeys);
3282 settledThreadKeysRef.current = settledThreadKeys;
3283 const snoozedThreadKeys = useMemo(
3284 () =>
3285 new Set(
3286 snoozedThreads.map((thread) =>
3287 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3288 ),
3289 ),
3290 [snoozedThreads],
3291 );
3292 const snoozedThreadKeysRef = useRef(snoozedThreadKeys);
3293 snoozedThreadKeysRef.current = snoozedThreadKeys;
3294
3295 const jumpLabelByKey = useMemo(() => {
3296 const mapping = new Map<string, string>();
3297 for (const [index, threadKey] of orderedThreadKeys.entries()) {
3298 const jumpCommand = threadJumpCommandForIndex(index);
3299 if (!jumpCommand) break;
3300 const label = shortcutLabelForCommand(keybindings, jumpCommand);
3301 if (label) mapping.set(threadKey, label);
3302 }
3303 return mapping;
3304 }, [keybindings, orderedThreadKeys]);
3305 const { showThreadJumpHints: showJumpHints, updateThreadJumpHintsVisibility } =
3306 useThreadJumpHintVisibility();
3307
3308 // Settled threads are live shells, so opening one is plain navigation:
3309 // history stays readable without un-settling, and sending a message or
3310 // starting a session un-settles server-side.
3311 const navigateToThread = useCallback(
3312 (threadRef: ScopedThreadRef) => {
3313 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
3314 clearSelection();
3315 }
3316 setSelectionAnchor(scopedThreadKey(threadRef));
3317 if (isMobile) {
3318 setOpenMobile(false);
3319 }
3320 return router.navigate({
3321 to: "/$environmentId/$threadId",
3322 params: buildThreadRouteParams(threadRef),
3323 });
3324 },
3325 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
3326 );
3327
3328 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
3329 const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);
3330 const handleThreadFileDrop = useCallback(
3331 async (threadRef: ScopedThreadRef, files: File[]) => {
3332 const dropId = queuePendingFileDrop({ threadRef, files });
3333 const landedBefore =
3334 router.buildLocation({
3335 to: "/$environmentId/$threadId",
3336 params: buildThreadRouteParams(threadRef),
3337 }).pathname === router.state.location.pathname;
3338 if (landedBefore) return;
3339
3340 try {
3341 await navigateToThread(threadRef);
3342 const landed =
3343 router.buildLocation({
3344 to: "/$environmentId/$threadId",
3345 params: buildThreadRouteParams(threadRef),
3346 }).pathname === router.state.location.pathname;
3347 if (!landed) clearPendingFileDrop(dropId);
3348 } catch {
3349 clearPendingFileDrop(dropId);
3350 }
3351 },
3352 [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router],
3353 );
3354
3355 const navigateToDraft = useCallback(
3356 (draftId: DraftId) => {
3357 // Unconditional: also drops a stale selection anchor left by
3358 // plain-click navigation, so a later shift-click starts fresh
3359 // instead of ranging from a row that is no longer the context.
3360 // (clearSelection no-ops when there is nothing to clear.)
3361 clearSelection();
3362 if (isMobile) {
3363 setOpenMobile(false);
3364 }
3365 void router.navigate({ to: "/draft/$draftId", params: { draftId } });
3366 },
3367 [clearSelection, isMobile, router, setOpenMobile],
3368 );
3369
3370 const clearThreadSearch = useCallback(() => {
3371 setThreadSearchQuery("");
3372 setActiveSearchResultIndex(0);
3373 }, []);
3374 const selectThreadSearchResult = useCallback(
3375 (thread: EnvironmentThreadShell) => {
3376 clearThreadSearch();
3377 navigateToThread(scopeThreadRef(thread.environmentId, thread.id));
3378 },
3379 [clearThreadSearch, navigateToThread],
3380 );
3381 const handleThreadSearchKeyDown = useCallback(
3382 (event: ReactKeyboardEvent<HTMLInputElement>) => {
3383 // IME composition (Japanese/Chinese input) uses the same keys; committing
3384 // a candidate must not move the highlight or navigate away mid-compose.
3385 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
3386 if (event.key === "Escape" && isSearchingThreads) {
3387 event.preventDefault();
3388 event.stopPropagation();
3389 clearThreadSearch();
3390 return;
3391 }
3392 if (threadSearchResults.length === 0) return;
3393 if (event.key === "ArrowDown") {
3394 event.preventDefault();
3395 setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length);
3396 return;
3397 }
3398 if (event.key === "ArrowUp") {
3399 event.preventDefault();
3400 setActiveSearchResultIndex(
3401 (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length,
3402 );
3403 return;
3404 }
3405 if (event.key === "Enter") {
3406 event.preventDefault();
3407 const result = threadSearchResults[activeSearchResultIndex];
3408 if (result) selectThreadSearchResult(result);
3409 }
3410 },
3411 [
3412 activeSearchResultIndex,
3413 clearThreadSearch,
3414 isSearchingThreads,
3415 selectThreadSearchResult,
3416 threadSearchResults,
3417 ],
3418 );
3419
3420 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
3421 const [renamingTitle, setRenamingTitle] = useState("");
3422 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
3423 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
3424 setRenamingThreadKey(scopedThreadKey(threadRef));
3425 setRenamingTitle(title);
3426 }, []);
3427 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
3428 const commitThreadRename = useCallback(
3429 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
3430 void (async () => {
3431 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
3432 const trimmed = title.trim();
3433 setRenamingThreadKey(null);
3434 if (trimmed.length === 0) {
3435 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
3436 return;
3437 }
3438 if (trimmed === originalTitle) return;
3439 const result = await updateThreadMetadata({
3440 environmentId: threadRef.environmentId,
3441 input: { threadId: threadRef.threadId, title: trimmed },
3442 });
3443 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3444 const error = squashAtomCommandFailure(result);
3445 toastManager.add(
3446 stackedThreadToast({
3447 type: "error",
3448 title: "Failed to rename thread",
3449 description: error instanceof Error ? error.message : "An error occurred.",
3450 }),
3451 );
3452 }
3453 })();
3454 },
3455 [updateThreadMetadata],
3456 );
3457
3458 const handleThreadClick = useCallback(
3459 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
3460 if (isSidebarNestedLinkClick(event.target)) return;
3461 const isMac = isMacPlatform(navigator.platform);
3462 const isModClick = isMac ? event.metaKey : event.ctrlKey;
3463 const threadKey = scopedThreadKey(threadRef);
3464 if (isModClick) {
3465 event.preventDefault();
3466 toggleThreadSelection(threadKey);
3467 return;
3468 }
3469 if (event.shiftKey) {
3470 event.preventDefault();
3471 rangeSelectTo(threadKey, orderedThreadKeysRef.current);
3472 return;
3473 }
3474 if (isTrailingDoubleClick(event.detail)) {
3475 return;
3476 }
3477 navigateToThread(threadRef);
3478 },
3479 [navigateToThread, rangeSelectTo, toggleThreadSelection],
3480 );
3481
3482 // A settle per thread at a time: double clicks and repeated menu picks
3483 // must not dispatch a second settle that fails and toasts a false error.
3484 const settlingThreadKeysRef = useRef(new Set<string>());
3485 // Parking the thread you're looking at (settle or snooze) moves you
3486 // forward: the next remaining card (never a settled or snoozed row, never
3487 // one leaving in the same batch), or a fresh draft in this project when it
3488 // was the last active one. Callers snapshot the plan BEFORE the command
3489 // mutates the partition; background parks never navigate (null plan).
3490 const planForwardNavigation = useCallback(
3491 (threadKey: string, coParkingKeys?: ReadonlySet<string>): (() => void) | null => {
3492 if (routeThreadKeyRef.current !== threadKey) return null;
3493 const shell = threadByKeyRef.current.get(threadKey);
3494 const orderedKeys = orderedThreadKeysRef.current;
3495 const settledKeys = settledThreadKeysRef.current;
3496 const snoozedKeys = snoozedThreadKeysRef.current;
3497 const currentIndex = orderedKeys.indexOf(threadKey);
3498 const nextCardKey =
3499 currentIndex === -1
3500 ? null
3501 : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find(
3502 (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key),
3503 ) ?? null);
3504 const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
3505 return nextThread
3506 ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
3507 : shell
3508 ? () =>
3509 void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId))
3510 : () => void router.navigate({ to: "/" });
3511 },
3512 [navigateToThread, router],
3513 );
3514
3515 const attemptSettle = useCallback(
3516 (
3517 threadRef: ScopedThreadRef,
3518 opts: { coSettlingKeys?: ReadonlySet<string>; confirmed?: true } = {},
3519 ) => {
3520 void (async () => {
3521 const threadKey = scopedThreadKey(threadRef);
3522 if (settlingThreadKeysRef.current.has(threadKey)) return;
3523 settlingThreadKeysRef.current.add(threadKey);
3524 try {
3525 const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
3526 const result = await settleThread(threadRef, opts.confirmed ? { confirmed: true } : {});
3527 if (result._tag === "Failure") {
3528 // Never navigate away from a thread that did not settle.
3529 if (!isAtomCommandInterrupted(result)) {
3530 const error = squashAtomCommandFailure(result);
3531 toastManager.add(
3532 stackedThreadToast({
3533 type: "error",
3534 title: "Failed to settle thread",
3535 description: error instanceof Error ? error.message : "An error occurred.",
3536 }),
3537 );
3538 }
3539 return;
3540 }
3541 // Only move forward if the user is still on the settled thread —
3542 // a navigation made during the await wins over ours.
3543 if (
3544 shouldNavigateAfterThreadPark({
3545 threadKey,
3546 currentThreadKey: routeThreadKeyRef.current,
3547 action: "settle",
3548 now: new Date().toISOString(),
3549 thread: readThreadShell(threadRef),
3550 })
3551 ) {
3552 navigateAfterSettle?.();
3553 }
3554 } finally {
3555 settlingThreadKeysRef.current.delete(threadKey);
3556 }
3557 })();
3558 },
3559 [planForwardNavigation, settleThread],
3560 );
3561 // The thread.settle shortcut parks the open thread through this same path.
3562 useEffect(() => registerSettleAndAdvance(attemptSettle), [attemptSettle]);
3563 // Post-settle navigation must skip threads settling in this same batch —
3564 // they are all leaving the card block together. Rows that are already
3565 // explicitly settled are skipped: nothing to do on a valid mixed selection.
3566 // Pinned rows ARE included: the decider clears the pin as part of settling,
3567 // so they park like the rest.
3568 const settleThreads = useCallback(
3569 async (threadKeys: readonly string[]) => {
3570 const targets = threadKeys.flatMap((threadKey) => {
3571 const thread = threadByKeyRef.current.get(threadKey);
3572 return thread && thread.settledOverride !== "settled"
3573 ? [scopeThreadRef(thread.environmentId, thread.id)]
3574 : [];
3575 });
3576 const confirmation = await confirmThreadSettlement(targets);
3577 if (confirmation._tag === "Failure") {
3578 if (!isAtomCommandInterrupted(confirmation)) {
3579 const error = squashAtomCommandFailure(confirmation);
3580 toastManager.add(
3581 stackedThreadToast({
3582 type: "error",
3583 title: "Failed to settle chats",
3584 description: error instanceof Error ? error.message : "An error occurred.",
3585 }),
3586 );
3587 }
3588 return false;
3589 }
3590 if (!confirmation.value) return false;
3591 const coSettlingKeys = new Set(targets.map(scopedThreadKey));
3592 for (const target of targets) {
3593 attemptSettle(target, { coSettlingKeys, confirmed: true });
3594 }
3595 return true;
3596 },
3597 [attemptSettle, confirmThreadSettlement],
3598 );
3599 const attemptUnsettle = useCallback(
3600 (threadRef: ScopedThreadRef) => {
3601 void (async () => {
3602 const result = await unsettleThread(threadRef);
3603 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3604 const error = squashAtomCommandFailure(result);
3605 toastManager.add(
3606 stackedThreadToast({
3607 type: "error",
3608 title: "Failed to un-settle thread",
3609 description: error instanceof Error ? error.message : "An error occurred.",
3610 }),
3611 );
3612 }
3613 })();
3614 },
3615 [unsettleThread],
3616 );
3617 const attemptUnsnooze = useCallback(
3618 (threadRef: ScopedThreadRef) => {
3619 void (async () => {
3620 const result = await unsnoozeThread(threadRef);
3621 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3622 const error = squashAtomCommandFailure(result);
3623 toastManager.add(
3624 stackedThreadToast({
3625 type: "error",
3626 title: "Failed to wake thread",
3627 description: error instanceof Error ? error.message : "An error occurred.",
3628 }),
3629 );
3630 }
3631 })();
3632 },
3633 [unsnoozeThread],
3634 );
3635 const threadListRef = useRef<HTMLUListElement | null>(null);
3636 const dragLabelOffsetRef = useRef(0);
3637 const restrictBelowPins = useCallback<Modifier>(
3638 (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current),
3639 [],
3640 );
3641 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
3642 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
3643 threadListRef.current = node;
3644 listMotionRef.current?.dispose();
3645 listMotionRef.current = node === null ? null : createSidebarListMotion(node);
3646 listMotionRef.current?.update(false);
3647 }, []);
3648
3649 // Hold the chosen section and order until every key write arrives. This
3650 // also covers first-time ordering, which assigns keys to keyless neighbors.
3651 // A failed write, concurrent reorder, or membership change releases the hold.
3652 const [dragState, setDragState] = useState<{
3653 readonly activeKey: string;
3654 readonly activeSection: SidebarSection;
3655 readonly occurredAt: string;
3656 readonly activationY: number | null;
3657 readonly targetSection: SidebarSection | null;
3658 readonly contextDrag: boolean;
3659 } | null>(null);
3660 const isContextDrag = dragState?.contextDrag === true;
3661 const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null);
3662 const dragSensorRef = useRef<SidebarPointerSensor | null>(null);
3663 const contextDragKeyRef = useRef<string | null>(null);
3664 const finishThreadDrag = useCallback((started: boolean) => {
3665 dragSensorRef.current = null;
3666 contextDragKeyRef.current = null;
3667 endThreadContextDrag();
3668 if (started) {
3669 listMotionRef.current?.release();
3670 setDragState(null);
3671 }
3672 }, []);
3673 const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => {
3674 dragSensorRef.current = sensor;
3675 }, []);
3676 const cancelThreadDrag = useCallback(() => {
3677 dragSensorRef.current?.cancel();
3678 }, []);
3679 // Dragged threads: the multi-selection when the picked-up row is part of it.
3680 const contextDragThreads = useCallback((): ReadonlyArray<ScopedThreadRef> => {
3681 const key = contextDragKeyRef.current;
3682 if (key === null) return [];
3683 const selected = useThreadSelectionStore.getState().selectedThreadKeys;
3684 const keys = selected.has(key) ? [...selected] : [key];
3685 return keys.flatMap((candidate) => {
3686 const ref = parseScopedThreadKey(candidate);
3687 return ref ? [ref] : [];
3688 });
3689 }, []);
3690 const pointerOutsideThreadList = useCallback((point: { x: number; y: number }) => {
3691 const bounds = threadListRef.current?.getBoundingClientRect();
3692 return bounds !== undefined && (point.x < bounds.left || point.x > bounds.right);
3693 }, []);
3694 const moveThreadContextDrag = useCallback(
3695 (point: { x: number; y: number }) => {
3696 const contextDrag = pointerOutsideThreadList(point);
3697 setDragState((current) =>
3698 current === null || current.contextDrag === contextDrag
3699 ? current
3700 : { ...current, contextDrag },
3701 );
3702 if (!contextDrag) {
3703 endThreadContextDrag();
3704 return false;
3705 }
3706 const threads = contextDragThreads();
3707 const title =
3708 threadByKeyRef.current.get(contextDragKeyRef.current ?? "")?.title.trim() || "Thread";
3709 moveThreadContextDragGhost(point, { title, count: threads.length });
3710 return true;
3711 },
3712 [contextDragThreads, pointerOutsideThreadList],
3713 );
3714 const dropThreadContextDrag = useCallback(
3715 (point: { x: number; y: number }) => {
3716 if (!pointerOutsideThreadList(point)) return false;
3717 dropThreadContext(point, contextDragThreads());
3718 // Releasing outside the list never reorders, whether or not a composer took the drop.
3719 return true;
3720 },
3721 [contextDragThreads, pointerOutsideThreadList],
3722 );
3723 const dndSensors = useSensors(
3724 useSensor(SidebarPointerSensor, {
3725 distance: SIDEBAR_DRAG_DISTANCE,
3726 onAttach: attachDragSensor,
3727 onFinish: finishThreadDrag,
3728 onMove: moveThreadContextDrag,
3729 onDrop: dropThreadContextDrag,
3730 }),
3731 );
3732 const sectionByThreadKey = useMemo(() => {
3733 const map = new Map<string, SidebarSection>();
3734 const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {
3735 for (const thread of list) {
3736 map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section);
3737 }
3738 };
3739 add(pinnedThreads, "pinned");
3740 add(activeThreads, "active");
3741 add(workingThreads, "working");
3742 add(snoozedThreads, "snoozed");
3743 add(settledThreads, "settled");
3744 return map;
3745 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads]);
3746 const sectionByThreadKeyRef = useRef(sectionByThreadKey);
3747 sectionByThreadKeyRef.current = sectionByThreadKey;
3748 // Drag a row action to apply it to the armed rows in the same section.
3749 // Staying in one section keeps an overshoot from changing neighboring
3750 // sections, whose boundaries may be hidden outside a row drag. The sweep
3751 // runs on the row drag's sensor and ref, so unmounting the list cancels it.
3752 const [actionSweep, setActionSweep] = useState<{
3753 action: SidebarSweepAction;
3754 keys: ReadonlySet<string>;
3755 } | null>(null);
3756 const startActionSweep = useCallback(
3757 (threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => {
3758 const originKey = scopedThreadKey(threadRef);
3759 const originSection = sectionByThreadKeyRef.current.get(originKey);
3760 const canApply = (key: string) => {
3761 const thread = threadByKeyRef.current.get(key);
3762 const capabilities = thread && environmentCapabilities.get(thread.environmentId);
3763 return (
3764 thread !== undefined &&
3765 sectionByThreadKeyRef.current.get(key) === originSection &&
3766 (action === "unsnooze"
3767 ? capabilities?.threadSnooze === true
3768 : capabilities?.threadSettlement === true)
3769 );
3770 };
3771 let targetKey: string | null = null;
3772 let sweptKeys: string[] = [];
3773 const sweepTo = (key: string | null) => {
3774 if (key === null || key === targetKey) return;
3775 targetKey = key;
3776 sweptKeys = resolveSidebarSweepKeys(orderedThreadKeysRef.current, originKey, key, canApply);
3777 setActionSweep({ action, keys: new Set(sweptKeys) });
3778 };
3779 dragSensorRef.current = new SidebarPointerSensor({
3780 active: originKey,
3781 event,
3782 options: {
3783 distance: SIDEBAR_DRAG_DISTANCE,
3784 onAttach: () => {},
3785 onFinish: () => {
3786 dragSensorRef.current = null;
3787 setActionSweep(null);
3788 },
3789 },
3790 onPending: () => {},
3791 onStart: () => sweepTo(originKey),
3792 onMove: ({ y }) =>
3793 sweepTo(threadListRef.current && sidebarThreadKeyAtY(threadListRef.current, y)),
3794 // Also runs after a press that never moved. Nothing is swept then,
3795 // and the button's own click applies its action. Rows that changed
3796 // section mid-gesture, say pinned from another device, are skipped.
3797 onEnd: () => {
3798 const keys = sweptKeys.filter(canApply);
3799 if (action === "settle") {
3800 settleThreads(keys);
3801 return;
3802 }
3803 for (const key of keys) {
3804 const ref = parseScopedThreadKey(key);
3805 if (ref === null) continue;
3806 if (action === "unsettle") attemptUnsettle(ref);
3807 else attemptUnsnooze(ref);
3808 }
3809 },
3810 onCancel: () => {},
3811 onAbort: () => {},
3812 });
3813 },
3814 [attemptUnsettle, attemptUnsnooze, environmentCapabilities, settleThreads],
3815 );
3816 const pinnedKeys = useMemo(
3817 () =>
3818 pinnedThreads.map((thread) =>
3819 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3820 ),
3821 [pinnedThreads],
3822 );
3823 const activeKeys = useMemo(
3824 () =>
3825 activeThreads.map((thread) =>
3826 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3827 ),
3828 [activeThreads],
3829 );
3830 useEffect(() => {
3831 if (optimisticDrop === null) return;
3832 const canonicalByKey = new Map(
3833 threads.map((thread) => [
3834 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3835 thread,
3836 ]),
3837 );
3838 const thread = canonicalByKey.get(optimisticDrop.key);
3839 if (thread === undefined || thread.archivedAt !== null) {
3840 setOptimisticDrop(null);
3841 return;
3842 }
3843 const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() })
3844 ? "snoozed"
3845 : thread.settledOverride === "settled"
3846 ? "settled"
3847 : thread.pinnedAt != null
3848 ? "pinned"
3849 : "active";
3850 if (
3851 canonicalSection !== optimisticDrop.sourceSection &&
3852 canonicalSection !== optimisticDrop.section
3853 ) {
3854 setOptimisticDrop(null);
3855 return;
3856 }
3857 if (optimisticDrop.order === null) {
3858 // Settle also emits unpin/unsnooze events. Wait for the entire move
3859 // before releasing the projected fields and sort timestamps.
3860 if (
3861 canonicalSection === optimisticDrop.section &&
3862 thread.pinnedAt == null &&
3863 (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null)
3864 ) {
3865 setOptimisticDrop(null);
3866 }
3867 return;
3868 }
3869 if (canonicalSection !== optimisticDrop.section) return;
3870 if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return;
3871 const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys;
3872 const canonicalDestination = destinationKeys.flatMap((key) => {
3873 const canonical = canonicalByKey.get(key);
3874 return canonical === undefined ? [] : [canonical];
3875 });
3876 const keyByThread = new Map(
3877 canonicalDestination.map((thread) => [
3878 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3879 (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null,
3880 ]),
3881 );
3882 const heldOrder = optimisticDrop.order;
3883 const heldKeys = new Set(heldOrder);
3884 const membershipChanged =
3885 destinationKeys.length !== heldOrder.length ||
3886 destinationKeys.some((key) => !heldKeys.has(key));
3887 const foreignKeyLanded = destinationKeys.some((threadKey) => {
3888 const currentKey = keyByThread.get(threadKey) ?? null;
3889 if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false;
3890 return currentKey !== optimisticDrop.assignedKeys.get(threadKey);
3891 });
3892 const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every(
3893 ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey,
3894 );
3895 if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) {
3896 setOptimisticDrop(null);
3897 }
3898 }, [activeKeys, optimisticDrop, pinnedKeys, threads]);
3899 const attemptPin = useCallback(
3900 (threadRef: ScopedThreadRef) => {
3901 void (async () => {
3902 // Fresh pins take the top of the arranged run: pinThread computes a
3903 // key before the smallest key across ALL pinned shells — including
3904 // snoozed pins hidden from this list, whose keys are still part of
3905 // the run — so the new pin can't land beneath a hidden head.
3906 const result = await pinThread(threadRef);
3907 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3908 const error = squashAtomCommandFailure(result);
3909 toastManager.add(
3910 stackedThreadToast({
3911 type: "error",
3912 title: "Failed to pin thread",
3913 description: error instanceof Error ? error.message : "An error occurred.",
3914 }),
3915 );
3916 }
3917 })();
3918 },
3919 [pinThread],
3920 );
3921 const attemptUnpin = useCallback(
3922 (threadRef: ScopedThreadRef) => {
3923 void (async () => {
3924 const result = await confirmAndUnpinThread(threadRef);
3925 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3926 const error = squashAtomCommandFailure(result);
3927 toastManager.add(
3928 stackedThreadToast({
3929 type: "error",
3930 title: "Failed to unpin thread",
3931 description: error instanceof Error ? error.message : "An error occurred.",
3932 }),
3933 );
3934 }
3935 })();
3936 },
3937 [confirmAndUnpinThread],
3938 );
3939
3940 const handleThreadDragStart = useCallback(
3941 (event: DragStartEvent) => {
3942 const activeKey = String(event.active.id);
3943 contextDragKeyRef.current = activeKey;
3944 const activeSection = sectionByThreadKey.get(activeKey);
3945 if (activeSection === undefined) return;
3946 // Stop normal section motion before dnd-kit measures the picked-up row.
3947 listMotionRef.current?.suspend();
3948 const list = threadListRef.current;
3949 const header = list?.querySelector<HTMLElement>('[data-testid="sidebar-pinned-header"]');
3950 if (list && header) {
3951 const listRect = list.getBoundingClientRect();
3952 const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1;
3953 dragLabelOffsetRef.current =
3954 header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale;
3955 } else {
3956 dragLabelOffsetRef.current = 0;
3957 }
3958 setDragState({
3959 activeKey,
3960 activeSection,
3961 targetSection: activeSection,
3962 contextDrag: false,
3963 occurredAt: new Date().toISOString(),
3964 activationY:
3965 event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
3966 });
3967 },
3968 [sectionByThreadKey],
3969 );
3970 // Include every visible row in the measured order. Older servers disable
3971 // pickup on their rows without changing where those rows render.
3972 const sidebarListItems = useMemo((): readonly SidebarListItem[] => {
3973 const rowsOf = (
3974 list: readonly EnvironmentThreadShell[],
3975 section: SidebarSection,
3976 ): SidebarListItem[] =>
3977 list.map((thread) => {
3978 const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
3979 return { kind: "thread", key, section };
3980 });
3981 if (
3982 pinnedThreads.length +
3983 activeThreads.length +
3984 workingThreads.length +
3985 snoozedThreads.length +
3986 settledThreads.length ===
3987 0
3988 ) {
3989 return [];
3990 }
3991 const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }];
3992 const pinnedRows = rowsOf(pinnedThreads, "pinned");
3993 items.push(...pinnedRows);
3994 items.push({ kind: "marker", marker: "pinned-divider" });
3995 const activeRows = rowsOf(activeThreads, "active");
3996 items.push({ kind: "marker", marker: "active-placeholder" });
3997 items.push(...activeRows);
3998 if (workingThreads.length > 0) {
3999 items.push({ kind: "marker", marker: "working-header" });
4000 items.push(...rowsOf(visibleWorkingThreads, "working"));
4001 }
4002 if (snoozedThreads.length > 0) {
4003 items.push({ kind: "marker", marker: "snoozed-header" });
4004 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
4005 }
4006 items.push({ kind: "marker", marker: "settled-header" });
4007 const settledRows = rowsOf(renderedSettledThreads, "settled");
4008 items.push({ kind: "marker", marker: "settled-placeholder" });
4009 items.push(...settledRows);
4010 return items;
4011 }, [
4012 activeThreads,
4013 pinnedThreads,
4014 renderedSettledThreads,
4015 settledThreads.length,
4016 snoozedThreads.length,
4017 visibleSnoozedThreads,
4018 visibleWorkingThreads,
4019 workingThreads.length,
4020 ]);
4021 useEffect(() => {
4022 if (
4023 dragState !== null &&
4024 !sidebarListItems.some((item) => item.kind === "thread" && item.key === dragState.activeKey)
4025 ) {
4026 cancelThreadDrag();
4027 }
4028 }, [cancelThreadDrag, dragState, sidebarListItems]);
4029 const listMotionPaused = dragState !== null;
4030 // Every shell event rebuilds sidebarListItems, but rows only move when the
4031 // rendered order or a row's section changes. Keying the motion pass on that
4032 // keeps ordinary updates from forcing a layout read and animating rows
4033 // whose position drifted for other reasons.
4034 const sidebarListOrderKey = useMemo(
4035 () =>
4036 sidebarListItems
4037 .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker))
4038 .join("\0"),
4039 [sidebarListItems],
4040 );
4041 const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0;
4042 useLayoutEffect(() => {
4043 // Drag release clears the baseline, so its commit cannot replay the
4044 // sortable preview; rows glide from their released positions instead.
4045 // Later thread actions can animate while writes settle.
4046 // Draft navigation can reveal a frozen row without changing the draft count.
4047 void sidebarListOrderKey;
4048 listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows);
4049 }, [
4050 listMotionPaused,
4051 routeDraftIdForRows,
4052 sidebarListHasRows,
4053 sidebarListOrderKey,
4054 visibleDraftSessionCount,
4055 ]);
4056 const handleThreadDragOver = useCallback(
4057 (event: DragOverEvent) => {
4058 const target = event.over
4059 ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))
4060 : null;
4061 setDragState((current) =>
4062 current === null || current.activeKey !== String(event.active.id)
4063 ? current
4064 : { ...current, targetSection: target?.section ?? null },
4065 );
4066 },
4067 [sidebarListItems],
4068 );
4069 const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]);
4070 const draggedSettledOrder = useMemo(() => {
4071 const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
4072 if (dragState === null || thread === undefined) return [];
4073 const key = (candidate: EnvironmentThreadShell) =>
4074 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
4075 return sortSettledThreads([
4076 ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
4077 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
4078 ]).map(key);
4079 }, [dragState, settledThreads, threadByKey]);
4080 // Working beta: the inbox is time-ordered too, so the preview shows the
4081 // slot a drop will land in, not the slot under the pointer.
4082 const draggedActiveOrder = useMemo(() => {
4083 const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
4084 if (!workingShelfEnabled || dragState === null || thread === undefined) return undefined;
4085 const key = (candidate: EnvironmentThreadShell) =>
4086 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
4087 return sortInboxThreadsByReturn(
4088 [
4089 ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
4090 applySidebarThreadDrop(thread, "active", dragState.occurredAt),
4091 ],
4092 inboxReturns.returnedAt,
4093 ).map(key);
4094 }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
4095 const sidebarSortingStrategy = useMemo(
4096 () =>
4097 createSidebarSortingStrategy({
4098 items: sidebarListItems,
4099 enabled: !isContextDrag,
4100 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
4101 settledOrder: draggedSettledOrder,
4102 ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }),
4103 settledExpanded: settledShelfExpanded,
4104 settledVisibleCount,
4105 routeThreadKey,
4106 snoozedThreadCount: snoozedThreads.length,
4107 }),
4108 [
4109 draggedActiveOrder,
4110 draggedSettledOrder,
4111 isContextDrag,
4112 routeThreadKey,
4113 settledShelfExpanded,
4114 settledVisibleCount,
4115 sidebarListItems,
4116 snoozedThreads.length,
4117 ],
4118 );
4119 // Hidden and filtered threads keep their keys. Reserve those slots without
4120 // including the rows in the visible drop order or writing to them.
4121 const { pinnedKeysById, activeKeysById } = useMemo(
4122 () => ({
4123 pinnedKeysById: new Map(
4124 threads.map((thread) => [
4125 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4126 thread.pinOrderKey ?? null,
4127 ]),
4128 ),
4129 activeKeysById: new Map(
4130 threads.map((thread) => [
4131 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4132 thread.activeOrderKey ?? null,
4133 ]),
4134 ),
4135 }),
4136 [threads],
4137 );
4138 const draggedThreadKey = dragState?.activeKey;
4139 const draggedFromSection = dragState?.activeSection;
4140 const dragActivationY = dragState?.activationY;
4141 const dndCollisionDetection = useMemo(() => {
4142 if (draggedThreadKey === undefined || draggedFromSection === undefined)
4143 return createSidebarCollisionDetection(() => true);
4144 const source = threadByKey.get(draggedThreadKey);
4145 if (source === undefined) return createSidebarCollisionDetection(() => false);
4146 return createSidebarCollisionDetection(
4147 (id) => {
4148 const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id);
4149 if (target === null) return false;
4150 return (
4151 planSidebarThreadDrop({
4152 activeKey: draggedThreadKey,
4153 activeSection: draggedFromSection,
4154 activePinned: source.pinnedAt != null,
4155 activeSettled: source.settledOverride === "settled",
4156 supportsSettlement:
4157 environmentCapabilities.get(source.environmentId)?.threadSettlement === true,
4158 target,
4159 pinnedOrder: pinnedKeys,
4160 pinnedKeysById,
4161 reorderableKeys: draggableThreadKeys,
4162 activeOrder: activeKeys,
4163 activeKeysById,
4164 activeReorderableKeys: activeReorderableThreadKeys,
4165 activeTimeOrdered: workingShelfEnabled,
4166 }).kind !== "none"
4167 );
4168 },
4169 {
4170 items: sidebarListItems,
4171 activationY: dragActivationY ?? null,
4172 },
4173 );
4174 }, [
4175 activeKeysById,
4176 pinnedKeysById,
4177 environmentCapabilities,
4178 activeKeys,
4179 activeReorderableThreadKeys,
4180 draggedThreadKey,
4181 draggedFromSection,
4182 dragActivationY,
4183 draggableThreadKeys,
4184 pinnedKeys,
4185 sidebarListItems,
4186 threadByKey,
4187 workingShelfEnabled,
4188 ]);
4189 const handleThreadDragEnd = useCallback(
4190 (event: DragEndEvent) => {
4191 const activeKey = String(event.active.id);
4192 const activeSection = sectionByThreadKey.get(activeKey);
4193 const target =
4194 event.over === null
4195 ? null
4196 : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id));
4197 const activeThread = threadByKey.get(activeKey);
4198 if (activeSection === undefined || target === null || activeThread === undefined) return;
4199 const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
4200 const plan = planSidebarThreadDrop({
4201 activeKey,
4202 activeSection,
4203 activePinned: activeThread.pinnedAt != null,
4204 activeSettled: activeThread.settledOverride === "settled",
4205 supportsSettlement:
4206 environmentCapabilities.get(activeThread.environmentId)?.threadSettlement === true,
4207 target,
4208 pinnedOrder: pinnedKeys,
4209 pinnedKeysById,
4210 reorderableKeys: draggableThreadKeys,
4211 activeOrder: activeKeys,
4212 activeKeysById,
4213 activeReorderableKeys: activeReorderableThreadKeys,
4214 activeTimeOrdered: workingShelfEnabled,
4215 });
4216 if (plan.kind === "none") return;
4217 if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
4218 const assignments =
4219 plan.kind === "pin"
4220 ? [
4221 ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]),
4222 ...plan.extraAssignments,
4223 ]
4224 : plan.kind === "reorder-pinned" || plan.kind === "move-active"
4225 ? plan.assignments
4226 : [];
4227 // A drop rewrites the dragged row and every row it renumbers, so all of
4228 // them must be writable before any of them changes.
4229 if (
4230 !checkThreadOperations(
4231 [activeKey, ...assignments.map(({ id }) => id)].flatMap((key) => {
4232 const thread = threadByKey.get(key);
4233 return thread ? [thread] : [];
4234 }),
4235 true,
4236 )
4237 )
4238 return;
4239 const drop = {
4240 key: activeKey,
4241 sourceSection: activeSection,
4242 section: target.section,
4243 occurredAt: new Date().toISOString(),
4244 clearsSnooze:
4245 plan.kind === "pin" ||
4246 plan.kind === "settle" ||
4247 (plan.kind === "move-active" && plan.unsnooze),
4248 order: plan.kind === "settle" ? null : plan.order,
4249 keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById,
4250 assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])),
4251 };
4252 setOptimisticDrop(drop);
4253 void (async () => {
4254 const run = async (
4255 operation: Promise<AtomCommandResult<unknown, unknown>>,
4256 title: string,
4257 ) => {
4258 const result = await operation;
4259 if (result._tag === "Success") return true;
4260 // A late failure must not cancel a newer drag's preview.
4261 setOptimisticDrop((current) => (current === drop ? null : current));
4262 if (!isAtomCommandInterrupted(result)) {
4263 const error = squashAtomCommandFailure(result);
4264 toastManager.add(
4265 stackedThreadToast({
4266 type: "error",
4267 title,
4268 description: error instanceof Error ? error.message : "An error occurred.",
4269 }),
4270 );
4271 }
4272 return false;
4273 };
4274 switch (plan.kind) {
4275 case "settle": {
4276 settlingThreadKeysRef.current.add(activeKey);
4277 const navigateAfterSettle = planForwardNavigation(activeKey);
4278 const settled = await run(settleThread(threadRef), "Failed to settle thread").finally(
4279 () => settlingThreadKeysRef.current.delete(activeKey),
4280 );
4281 if (
4282 settled &&
4283 shouldNavigateAfterThreadPark({
4284 threadKey: activeKey,
4285 currentThreadKey: routeThreadKeyRef.current,
4286 action: "settle",
4287 now: new Date().toISOString(),
4288 thread: readThreadShell(threadRef),
4289 })
4290 )
4291 navigateAfterSettle?.();
4292 return;
4293 }
4294 case "move-active":
4295 // The drag expresses unpin intent; button/menu confirmation is unchanged.
4296 if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread")))
4297 return;
4298 if (
4299 plan.unsettle &&
4300 !(await run(unsettleThread(threadRef), "Failed to un-settle thread"))
4301 )
4302 return;
4303 if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread")))
4304 return;
4305 break;
4306 case "pin":
4307 if (
4308 !(await run(
4309 pinThread(
4310 threadRef,
4311 plan.orderKey === undefined ? {} : { orderKey: plan.orderKey },
4312 ),
4313 "Failed to pin thread",
4314 ))
4315 )
4316 return;
4317 break;
4318 case "reorder-pinned":
4319 break;
4320 }
4321 // Stop on failure; each successful key write remains a valid placement.
4322 const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments;
4323 for (const assignment of keyWrites) {
4324 const thread = threadByKey.get(assignment.id);
4325 if (thread === undefined) continue;
4326 if (
4327 !(await run(
4328 (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)(
4329 scopeThreadRef(thread.environmentId, thread.id),
4330 assignment.orderKey,
4331 ),
4332 plan.kind === "move-active"
4333 ? "Failed to reorder active threads"
4334 : "Failed to reorder pinned threads",
4335 ))
4336 )
4337 return;
4338 }
4339 })();
4340 },
4341 [
4342 activeKeysById,
4343 pinnedKeysById,
4344 environmentCapabilities,
4345 activeKeys,
4346 activeReorderableThreadKeys,
4347 draggableThreadKeys,
4348 pinThread,
4349 pinnedKeys,
4350 planForwardNavigation,
4351 reorderPinnedThread,
4352 reorderActiveThread,
4353 sectionByThreadKey,
4354 settleThread,
4355 sidebarListItems,
4356 threadByKey,
4357 unpinThread,
4358 unsettleThread,
4359 unsnoozeThread,
4360 workingShelfEnabled,
4361 ],
4362 );
4363 // One snooze per thread at a time — same double-dispatch guard as settle.
4364 const snoozingThreadKeysRef = useRef(new Set<string>());
4365 const performSnooze = useCallback(
4366 async (
4367 threadRef: ScopedThreadRef,
4368 preset: Pick<SnoozePreset, "snoozedUntil">,
4369 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
4370 ) => {
4371 const threadKey = scopedThreadKey(threadRef);
4372 if (snoozingThreadKeysRef.current.has(threadKey)) {
4373 return { status: "skipped" } as const;
4374 }
4375 snoozingThreadKeysRef.current.add(threadKey);
4376 try {
4377 // Snoozing the open thread moves you forward, same as settle —
4378 // both park the thread you're done with for now.
4379 const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
4380 const result = await snoozeThread(threadRef, preset.snoozedUntil);
4381 if (result._tag === "Failure") {
4382 // Never navigate away from a thread that did not snooze.
4383 return isAtomCommandInterrupted(result)
4384 ? ({ status: "interrupted" } as const)
4385 : ({ status: "failure", error: squashAtomCommandFailure(result) } as const);
4386 }
4387 // Only move forward if the user is still on the snoozed thread —
4388 // a navigation made during the await wins over ours.
4389 if (
4390 shouldNavigateAfterThreadPark({
4391 threadKey,
4392 currentThreadKey: routeThreadKeyRef.current,
4393 action: "snooze",
4394 now: new Date().toISOString(),
4395 thread: readThreadShell(threadRef),
4396 })
4397 ) {
4398 navigateAfterSnooze?.();
4399 }
4400 return { status: "success" } as const;
4401 } finally {
4402 snoozingThreadKeysRef.current.delete(threadKey);
4403 }
4404 },
4405 [planForwardNavigation, snoozeThread],
4406 );
4407 const attemptSnooze = useCallback(
4408 (
4409 threadRef: ScopedThreadRef,
4410 preset: Pick<SnoozePreset, "snoozedUntil">,
4411 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
4412 ) => {
4413 void (async () => {
4414 const outcome = await performSnooze(threadRef, preset, opts);
4415 if (outcome.status === "failure") {
4416 toastManager.add(
4417 stackedThreadToast({
4418 type: "error",
4419 title: "Failed to snooze thread",
4420 description:
4421 outcome.error instanceof Error ? outcome.error.message : "An error occurred.",
4422 }),
4423 );
4424 return;
4425 }
4426 })();
4427 },
4428 [performSnooze],
4429 );
4430
4431 const handleMultiSelectContextMenu = useCallback(
4432 async (position: { x: number; y: number }) => {
4433 const api = readLocalApi();
4434 if (!api) return;
4435 const readSelectedThread = (threadKey: string) => {
4436 const threadRef = parseScopedThreadKey(threadKey);
4437 const thread = threadRef ? readThreadShell(threadRef) : null;
4438 return thread?.archivedAt === null ? thread : null;
4439 };
4440 const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
4441 const selectedThreads = selectedThreadKeys.flatMap((threadKey) => {
4442 const thread = readSelectedThread(threadKey);
4443 return thread ? [thread] : [];
4444 });
4445 const threadKeys = selectedThreads.map((thread) =>
4446 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4447 );
4448 if (threadKeys.length === 0) return;
4449 const count = threadKeys.length;
4450 // Snooze (N) is offered when every selected thread can actually take
4451 // it — a mixed selection with blocked-on-you work would half-apply.
4452 const selectionNow = new Date();
4453 const settlingThreads = selectedThreads.filter(
4454 (thread) => thread.settledOverride !== "settled",
4455 );
4456 const canSnoozeSelection = selectedThreads.every(
4457 (thread) =>
4458 environmentCapabilities.get(thread.environmentId)?.threadSnooze === true &&
4459 canSnooze(thread, { now: selectionNow.toISOString() }),
4460 );
4461 const titleRegenerationThreads = selectedThreads.filter(
4462 (thread) =>
4463 environmentCapabilities.get(thread.environmentId)?.threadTitleRegeneration === true,
4464 );
4465 const regeneratableTitleThreads = titleRegenerationThreads.filter(
4466 (thread) => thread.titleRegeneration == null,
4467 );
4468 const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({
4469 supportedCount: titleRegenerationThreads.length,
4470 actionableCount: regeneratableTitleThreads.length,
4471 });
4472 // Unpin (k) counts only the pinned rows in pin-capable environments —
4473 // on a mixed selection the unpinned rows are untouched, and the item
4474 // is omitted entirely when nothing selected is pinned.
4475 const pinnedSelectedThreads = selectedThreads.filter(
4476 (thread) =>
4477 environmentCapabilities.get(thread.environmentId)?.threadPinning === true &&
4478 thread.pinnedAt != null,
4479 );
4480 const unpinMenuItem = buildBulkUnpinContextMenuItem({
4481 pinnedCount: pinnedSelectedThreads.length,
4482 });
4483 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
4484 const clicked = await settlePromise(() =>
4485 api.contextMenu.show(
4486 [
4487 ...(unpinMenuItem
4488 ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads, true) }]
4489 : []),
4490 {
4491 id: "settle",
4492 label: `Settle (${count})`,
4493 disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads, true),
4494 },
4495 ...(canSnoozeSelection
4496 ? [
4497 {
4498 id: "snooze",
4499 label: `Snooze (${count})`,
4500 disabled: !canOperateThreads(selectedThreads, true),
4501 children: [
4502 ...snoozePresets.map((preset) => ({
4503 id: `snooze:${preset.id}`,
4504 label: `${preset.label} (${preset.whenLabel})`,
4505 disabled: !canOperateThreads(selectedThreads, true),
4506 })),
4507 {
4508 id: "snooze:custom",
4509 label: "Custom…",
4510 separatorBefore: true,
4511 disabled: !canOperateThreads(selectedThreads, true),
4512 },
4513 ],
4514 },
4515 ]
4516 : []),
4517 ...(titleRegenerationMenuItem
4518 ? [
4519 {
4520 ...titleRegenerationMenuItem,
4521 disabled:
4522 titleRegenerationMenuItem.disabled ||
4523 !canOperateThreads(regeneratableTitleThreads),
4524 },
4525 ]
4526 : []),
4527 ...buildMultiSelectThreadContextMenuItems({
4528 count,
4529 hasRunningThread: selectedThreads.some(
4530 (thread) => !threadRuntimeCanArchive(thread.runtime),
4531 ),
4532 }).map((item) =>
4533 item.id === "mark-unread"
4534 ? item
4535 : {
4536 ...item,
4537 disabled:
4538 item.disabled ||
4539 !canOperateThreads(selectedThreads) ||
4540 (item.id === "archive" && archivingThreads),
4541 },
4542 ),
4543 ],
4544 position,
4545 ),
4546 );
4547 if (clicked._tag === "Failure" || clicked.value === null) return;
4548 const actionTargets =
4549 clicked.value === "unpin"
4550 ? pinnedSelectedThreads
4551 : clicked.value === "regenerate-title"
4552 ? regeneratableTitleThreads
4553 : clicked.value === "settle"
4554 ? settlingThreads.flatMap((thread) => {
4555 const current = readSelectedThread(
4556 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4557 );
4558 return current && current.settledOverride !== "settled" ? [current] : [];
4559 })
4560 : selectedThreads;
4561 if (clicked.value === "settle" && actionTargets.length === 0) return;
4562 if (
4563 clicked.value !== "mark-unread" &&
4564 !checkThreadOperations(
4565 actionTargets,
4566 clicked.value === "settle" ||
4567 clicked.value === "unpin" ||
4568 clicked.value.startsWith("snooze:"),
4569 )
4570 )
4571 return;
4572 if (clicked.value?.startsWith("snooze:")) {
4573 const preset =
4574 clicked.value === "snooze:custom"
4575 ? await requestCustomSnooze()
4576 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
4577 if (preset) {
4578 // Post-snooze navigation must skip threads snoozing in this same
4579 // batch — they are all leaving the card block together.
4580 const coSnoozingKeys = new Set(threadKeys);
4581 clearSelection();
4582 const outcomes = await Promise.all(
4583 selectedThreads.map(async (thread) => {
4584 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
4585 const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys });
4586 return { outcome, threadRef };
4587 }),
4588 );
4589 const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) =>
4590 outcome.status === "success" ? [threadRef] : [],
4591 );
4592 const failures = outcomes.flatMap(({ outcome }) =>
4593 outcome.status === "failure" ? [outcome.error] : [],
4594 );
4595
4596 if (failures.length > 0) {
4597 const firstError = failures[0];
4598 toastManager.add(
4599 stackedThreadToast({
4600 type: "error",
4601 title:
4602 snoozedThreadRefs.length > 0
4603 ? `Failed to snooze ${failures.length} thread${failures.length === 1 ? "" : "s"}`
4604 : "Failed to snooze threads",
4605 description:
4606 firstError instanceof Error ? firstError.message : "An error occurred.",
4607 }),
4608 );
4609 }
4610 }
4611 return;
4612 }
4613 if (clicked.value === "unpin") {
4614 // Each unpin reports its own failure, like the single-row action.
4615 for (const thread of pinnedSelectedThreads) {
4616 attemptUnpin(scopeThreadRef(thread.environmentId, thread.id));
4617 }
4618 clearSelection();
4619 return;
4620 }
4621 if (clicked.value === "regenerate-title") {
4622 for (const thread of regeneratableTitleThreads) {
4623 const result = await updateThreadMetadata({
4624 environmentId: thread.environmentId,
4625 input: { threadId: thread.id, regenerateTitle: true },
4626 });
4627 if (result._tag === "Success") continue;
4628 if (!isAtomCommandInterrupted(result)) {
4629 const error = squashAtomCommandFailure(result);
4630 toastManager.add(
4631 stackedThreadToast({
4632 type: "error",
4633 title: "Failed to regenerate thread titles",
4634 description: error instanceof Error ? error.message : "An error occurred.",
4635 }),
4636 );
4637 }
4638 return;
4639 }
4640 clearSelection();
4641 return;
4642 }
4643 if (clicked.value === "settle") {
4644 if (
4645 await settleThreads(
4646 actionTargets.map((thread) =>
4647 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4648 ),
4649 )
4650 )
4651 clearSelection();
4652 return;
4653 }
4654 if (clicked.value === "mark-unread") {
4655 for (const threadKey of threadKeys) {
4656 const thread = readSelectedThread(threadKey);
4657 if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id));
4658 }
4659 clearSelection();
4660 return;
4661 }
4662 if (clicked.value === "archive") {
4663 await archiveThreads(selectedThreads);
4664 return;
4665 }
4666 if (clicked.value !== "delete") return;
4667 if (confirmThreadDelete) {
4668 const confirmed = await settlePromise(() =>
4669 api.dialogs.confirm(
4670 [
4671 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
4672 "This permanently clears conversation history for these threads.",
4673 ].join("\n"),
4674 { variant: "destructive" },
4675 ),
4676 );
4677 if (confirmed._tag === "Failure" || !confirmed.value) return;
4678 }
4679 if (
4680 !checkThreadOperations(
4681 threadKeys.flatMap((threadKey) => {
4682 const thread = readSelectedThread(threadKey);
4683 return thread ? [thread] : [];
4684 }),
4685 )
4686 )
4687 return;
4688 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
4689 entries: threadKeys.map((threadKey) => ({ threadKey })),
4690 delete: async ({ threadKey }, deletedThreadKeys) => {
4691 const thread = readSelectedThread(threadKey);
4692 if (!thread) return null;
4693 return deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
4694 deletedThreadKeys,
4695 });
4696 },
4697 });
4698 if (firstFailure !== null) {
4699 const firstError = squashAtomCommandFailure(firstFailure);
4700 toastManager.add(
4701 stackedThreadToast({
4702 type: "error",
4703 title: "Failed to delete threads",
4704 description: firstError instanceof Error ? firstError.message : "An error occurred.",
4705 }),
4706 );
4707 }
4708 removeFromSelection(
4709 getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => {
4710 const threadRef = parseScopedThreadKey(threadKey);
4711 return threadRef !== null && readThreadShell(threadRef) !== null;
4712 }),
4713 );
4714 },
4715 [
4716 archiveThreads,
4717 archivingThreads,
4718 attemptUnpin,
4719 clearSelection,
4720 confirmThreadDelete,
4721 deleteThread,
4722 markThreadUnread,
4723 performSnooze,
4724 removeFromSelection,
4725 environmentCapabilities,
4726 settleThreads,
4727 updateThreadMetadata,
4728 timestampFormat,
4729 ],
4730 );
4731
4732 const handleDraftContextMenu = useCallback(
4733 (draftId: DraftId, position: { x: number; y: number }) => {
4734 void (async () => {
4735 const api = readLocalApi();
4736 const session = useComposerDraftStore.getState().getDraftSession(draftId);
4737 if (!api || !session || session.promotedTo) return;
4738 const projectGroup = projectGroupsRef.current.find((group) =>
4739 group.memberProjectRefs.some(
4740 (ref) =>
4741 ref.environmentId === session.environmentId && ref.projectId === session.projectId,
4742 ),
4743 );
4744 const workspacePath =
4745 session.worktreePath ??
4746 projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot;
4747 const clicked = await settlePromise(() =>
4748 api.contextMenu.show(
4749 buildDraftActionMenuItems({
4750 hasPath: Boolean(workspacePath),
4751 hasBranch: Boolean(session.branch),
4752 hasProject: projectGroup != null,
4753 }),
4754 position,
4755 ),
4756 );
4757 if (clicked._tag === "Failure") return;
4758 switch (clicked.value) {
4759 case "project-settings":
4760 if (projectGroup)
4761 openProjectSettings(projectGroup, {
4762 environmentId: session.environmentId,
4763 projectId: session.projectId,
4764 });
4765 return;
4766 case "copy-path":
4767 if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath });
4768 return;
4769 case "copy-branch":
4770 if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch });
4771 return;
4772 case "discard": {
4773 // The menu can stay open while the draft sends; discarding a
4774 // promoting draft would strand the send.
4775 const current = useComposerDraftStore.getState().getDraftSession(draftId);
4776 if (current && !current.promotedTo) discardComposerDraft(draftId);
4777 return;
4778 }
4779 }
4780 })();
4781 },
4782 [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
4783 );
4784
4785 const handleThreadContextMenu = useCallback(
4786 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
4787 void (async () => {
4788 const api = readLocalApi();
4789 if (!api) return;
4790 const threadKey = scopedThreadKey(threadRef);
4791 const selectionState = useThreadSelectionStore.getState();
4792 if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) {
4793 await handleMultiSelectContextMenu(position);
4794 return;
4795 }
4796 const thread = threadByKeyRef.current.get(threadKey);
4797 if (!thread) return;
4798 const threadWorkspacePath =
4799 thread.worktreePath ??
4800 projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ??
4801 null;
4802 // Un-settle pins the thread active until real activity clears the pin.
4803 // Environments without
4804 // the settlement capability get no lifecycle items at all.
4805 const supportsSettlement =
4806 environmentCapabilities.get(thread.environmentId)?.threadSettlement === true;
4807 const supportsSnooze =
4808 environmentCapabilities.get(thread.environmentId)?.threadSnooze === true;
4809 const supportsPinning =
4810 environmentCapabilities.get(thread.environmentId)?.threadPinning === true;
4811 const supportsAutoSettleOptOut =
4812 environmentCapabilities.get(thread.environmentId)?.threadAutoSettleOptOut === true;
4813 const supportsTitleRegeneration =
4814 environmentCapabilities.get(thread.environmentId)?.threadTitleRegeneration === true;
4815 const isRegeneratingTitle = thread.titleRegeneration != null;
4816 const isSettled = settledThreadKeysRef.current.has(threadKey);
4817 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
4818 const isPinned = thread.pinnedAt != null;
4819 // Presets resolve at menu-open time (same as the popover).
4820 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
4821 const threadProjectGroup =
4822 projectGroupsRef.current.find((project) =>
4823 project.memberProjectRefs.some(
4824 (projectRef) =>
4825 projectRef.environmentId === thread.environmentId &&
4826 projectRef.projectId === thread.projectId,
4827 ),
4828 ) ?? null;
4829 const clicked = await settlePromise(() =>
4830 api.contextMenu.show(
4831 buildThreadActionMenuItems({
4832 canOrganize: readThreadOrganizationPermission(threadRef.environmentId),
4833 canOperate: readEnvironmentScope(
4834 threadRef.environmentId,
4835 AuthOrchestrationOperateScope,
4836 ),
4837 branch: thread.branch ?? null,
4838 projectFilter: threadProjectGroup
4839 ? {
4840 label: threadProjectGroup.displayName,
4841 isActive: projectScopeKey === threadProjectGroup.projectKey,
4842 }
4843 : null,
4844 isPinned,
4845 isSettled,
4846 autoSettleEnabled: thread.autoSettleDisabledAt == null,
4847 isSnoozed,
4848 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
4849 isRegeneratingTitle,
4850 isRunning: !threadRuntimeCanArchive(thread.runtime),
4851 supports: {
4852 settlement: supportsSettlement,
4853 autoSettleOptOut: supportsAutoSettleOptOut,
4854 snooze: supportsSnooze,
4855 pinning: supportsPinning,
4856 titleRegeneration: supportsTitleRegeneration,
4857 },
4858 snoozePresets,
4859 }),
4860 position,
4861 ),
4862 );
4863 if (clicked._tag === "Failure" || clicked.value === null) return;
4864 if (
4865 threadActionRequiresOperate(clicked.value) &&
4866 !checkThreadOperations([thread], threadActionIsOrganization(clicked.value))
4867 )
4868 return;
4869 if (clicked.value?.startsWith("snooze:")) {
4870 const preset =
4871 clicked.value === "snooze:custom"
4872 ? await requestCustomSnooze()
4873 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
4874 if (preset) attemptSnooze(threadRef, preset);
4875 return;
4876 }
4877 switch (clicked.value) {
4878 case "filter-by-project":
4879 // This item is the only scope control here, so picking the
4880 // already-scoped project again is the way back to all projects.
4881 if (threadProjectGroup) {
4882 setProjectScopeKey(
4883 projectScopeKey === threadProjectGroup.projectKey
4884 ? null
4885 : threadProjectGroup.projectKey,
4886 );
4887 }
4888 return;
4889 case "project-settings":
4890 if (threadProjectGroup)
4891 openProjectSettings(threadProjectGroup, {
4892 environmentId: threadRef.environmentId,
4893 projectId: thread.projectId,
4894 });
4895 return;
4896 case "new-thread-on-branch": {
4897 // Explicit branch carry-over: reuse the thread's worktree when it
4898 // has one, otherwise its branch on the local checkout.
4899 const result = await settlePromise(() =>
4900 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
4901 branch: thread.branch,
4902 worktreePath: thread.worktreePath,
4903 envMode: thread.worktreePath ? "worktree" : "local",
4904 startFromOrigin: false,
4905 }),
4906 );
4907 if (result._tag === "Failure") {
4908 const error = squashAtomCommandFailure(result);
4909 toastManager.add(
4910 stackedThreadToast({
4911 type: "error",
4912 title: "Could not create thread",
4913 description: error instanceof Error ? error.message : "An error occurred.",
4914 }),
4915 );
4916 }
4917 return;
4918 }
4919 case "settle":
4920 attemptSettle(threadRef);
4921 return;
4922 case "unsettle":
4923 attemptUnsettle(threadRef);
4924 return;
4925 case "unsnooze":
4926 attemptUnsnooze(threadRef);
4927 return;
4928 case "pin":
4929 attemptPin(threadRef);
4930 return;
4931 case "unpin":
4932 attemptUnpin(threadRef);
4933 return;
4934 case "auto-settle:enabled":
4935 case "auto-settle:disabled": {
4936 const result = await setThreadAutoSettle(
4937 threadRef,
4938 clicked.value === "auto-settle:enabled",
4939 );
4940 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
4941 const error = squashAtomCommandFailure(result);
4942 toastManager.add(
4943 stackedThreadToast({
4944 type: "error",
4945 title: "Failed to update auto-settle",
4946 description: error instanceof Error ? error.message : "An error occurred.",
4947 }),
4948 );
4949 }
4950 return;
4951 }
4952 case "rename":
4953 startThreadRename(threadRef, thread.title);
4954 return;
4955 case "regenerate-title": {
4956 if (isRegeneratingTitle) return;
4957 const result = await updateThreadMetadata({
4958 environmentId: threadRef.environmentId,
4959 input: { threadId: threadRef.threadId, regenerateTitle: true },
4960 });
4961 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
4962 const error = squashAtomCommandFailure(result);
4963 toastManager.add(
4964 stackedThreadToast({
4965 type: "error",
4966 title: "Failed to regenerate thread title",
4967 description: error instanceof Error ? error.message : "An error occurred.",
4968 }),
4969 );
4970 }
4971 return;
4972 }
4973 case "mark-unread":
4974 markThreadUnread(threadRef);
4975 return;
4976 case "copy-path":
4977 if (!threadWorkspacePath) {
4978 toastManager.add(
4979 stackedThreadToast({
4980 type: "error",
4981 title: "Path unavailable",
4982 description: "This thread does not have a workspace path to copy.",
4983 }),
4984 );
4985 return;
4986 }
4987 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
4988 return;
4989 case "copy-branch":
4990 if (thread.branch) {
4991 copyBranchToClipboard(thread.branch, { branch: thread.branch });
4992 }
4993 return;
4994 case "copy-thread-id":
4995 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
4996 return;
4997 case "archive": {
4998 if (confirmThreadArchive) {
4999 const confirmed = await settlePromise(() =>
5000 api.dialogs.confirm(`Archive thread "${thread.title}"?`),
5001 );
5002 if (confirmed._tag === "Failure" || !confirmed.value) return;
5003 }
5004 let didArchive = false;
5005 const result = await archiveThread(threadRef, {
5006 onArchived: () => {
5007 didArchive = true;
5008 },
5009 });
5010 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
5011 const error = squashAtomCommandFailure(result);
5012 toastManager.add(
5013 stackedThreadToast({
5014 type: "error",
5015 title: didArchive
5016 ? "Thread archived, but navigation failed"
5017 : "Failed to archive thread",
5018 description: error instanceof Error ? error.message : "An error occurred.",
5019 }),
5020 );
5021 return;
5022 }
5023 return;
5024 }
5025 case "delete": {
5026 if (confirmThreadDelete) {
5027 const confirmed = await settlePromise(() =>
5028 api.dialogs.confirm(
5029 [
5030 `Delete thread "${thread.title}"?`,
5031 "This permanently clears conversation history for this thread.",
5032 ].join("\n"),
5033 { variant: "destructive" },
5034 ),
5035 );
5036 if (confirmed._tag === "Failure" || !confirmed.value) return;
5037 }
5038 const result = await deleteThread(threadRef);
5039 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
5040 const error = squashAtomCommandFailure(result);
5041 toastManager.add(
5042 stackedThreadToast({
5043 type: "error",
5044 title: "Failed to delete thread",
5045 description: error instanceof Error ? error.message : "An error occurred.",
5046 }),
5047 );
5048 return;
5049 }
5050 return;
5051 }
5052 default:
5053 return;
5054 }
5055 })();
5056 },
5057 [
5058 archiveThread,
5059 attemptPin,
5060 attemptSettle,
5061 attemptSnooze,
5062 attemptUnpin,
5063 attemptUnsettle,
5064 attemptUnsnooze,
5065 confirmThreadArchive,
5066 confirmThreadDelete,
5067 copyBranchToClipboard,
5068 copyPathToClipboard,
5069 copyThreadIdToClipboard,
5070 deleteThread,
5071 handleMultiSelectContextMenu,
5072 markThreadUnread,
5073 openProjectSettings,
5074 projectScopeKey,
5075 projectByKey,
5076 environmentCapabilities,
5077 setProjectScopeKey,
5078 setThreadAutoSettle,
5079 startThreadRename,
5080 updateThreadMetadata,
5081 timestampFormat,
5082 ],
5083 );
5084
5085 // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
5086 // v1 — the keybinding layer is shared, only the ordered list differs.
5087 const routeTerminalOpen = useTerminalUiStateStore((state) =>
5088 routeThreadRef
5089 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
5090 : false,
5091 );
5092 useEffect(() => {
5093 const onWindowKeyDown = (event: KeyboardEvent) => {
5094 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) {
5095 return;
5096 }
5097 const command = resolveShortcutCommand(event, keybindings, {
5098 platform: navigator.platform,
5099 context: {
5100 terminalFocus: isTerminalFocused(),
5101 terminalOpen: routeTerminalOpen,
5102 modelPickerOpen: isModelPickerOpen(),
5103 isWeb: !isElectron,
5104 isDesktop: isElectron,
5105 },
5106 });
5107 const navigateToThreadKey = (targetThreadKey: string | null) => {
5108 if (!targetThreadKey) return false;
5109 const targetThread = threadByKey.get(targetThreadKey);
5110 if (!targetThread) return false;
5111 event.preventDefault();
5112 event.stopPropagation();
5113 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
5114 return true;
5115 };
5116 const traversalDirection = threadTraversalDirectionFromCommand(command);
5117 if (traversalDirection !== null) {
5118 navigateToThreadKey(
5119 resolveAdjacentThreadId({
5120 threadIds: orderedThreadKeys,
5121 currentThreadId: routeThreadKey,
5122 direction: traversalDirection,
5123 }),
5124 );
5125 return;
5126 }
5127 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
5128 if (jumpIndex === null) return;
5129 navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null);
5130 };
5131 window.addEventListener("keydown", onWindowKeyDown);
5132 return () => window.removeEventListener("keydown", onWindowKeyDown);
5133 }, [
5134 keybindings,
5135 navigateToThread,
5136 orderedThreadKeys,
5137 routeTerminalOpen,
5138 routeThreadKey,
5139 threadByKey,
5140 ]);
5141
5142 // Same predicate as v1: hints show only while the held modifiers exactly
5143 // match a thread-jump binding. Adding Shift (screenshots) or Alt no
5144 // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4.
5145 const shortcutModifiers = useShortcutModifierState();
5146 const terminalFocused = useTerminalFocus();
5147 const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
5148 shortcutModifiers,
5149 keybindings,
5150 {
5151 platform: navigator.platform,
5152 context: {
5153 terminalFocus: terminalFocused,
5154 terminalOpen: routeTerminalOpen,
5155 modelPickerOpen: isModelPickerOpen(),
5156 isWeb: !isElectron,
5157 isDesktop: isElectron,
5158 },
5159 },
5160 );
5161 useEffect(() => {
5162 updateThreadJumpHintsVisibility(shouldShowJumpHintsNow);
5163 }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]);
5164
5165 // New thread defaults to the project you're in (active thread's project,
5166 // falling back to the top project) — same resolution the command palette
5167 // uses. The command palette already offers a "New thread in..." submenu
5168 // for multi-project setups.
5169 const handleNewThreadClick = useCallback(
5170 (event?: ReactMouseEvent) => {
5171 // One project: nothing to pick, create immediately. Shift+click creates
5172 // directly in the current project even with several projects, skipping
5173 // the palette picker.
5174 if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {
5175 if (isMobile) setOpenMobile(false);
5176 void startNewThreadFromContext({
5177 activeDraftThread: newThreadContext.activeDraftThread,
5178 activeThread: newThreadContext.activeThread ?? undefined,
5179 defaultProjectRef: newThreadContext.defaultProjectRef,
5180 handleNewThread: newThreadContext.handleNewThread,
5181 });
5182 return;
5183 }
5184 if (isMobile) setOpenMobile(false);
5185 openCommandPalette({ open: "new-thread-in" });
5186 },
5187 [isMobile, newThreadContext, projectGroups.length, setOpenMobile],
5188 );
5189
5190 // The button mirrors chat.new: in multi-project setups both route through
5191 // the command palette's "New thread in..." picker, and in single-project
5192 // setups both create immediately. In multi-project setups the label is only
5193 // the picker's shortcut: falling back to chat.newLocal would advertise the
5194 // same shortcut for both the picker and direct create. In single-project
5195 // setups both commands create directly, so chat.newLocal is a valid
5196 // fallback. The second tooltip line (multi-project only) advertises
5197 // shift+click and its keyboard twin chat.newLocal for direct create.
5198 const newThreadShortcutLabel =
5199 shortcutLabelForCommand(keybindings, "chat.new") ??
5200 (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
5201 const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
5202 return (
5203 <>
5204 <ThreadContextDragGhost />
5205 <SidebarChromeHeader isElectron={isElectron} />
5206 <SidebarContent
5207 className="min-h-full"
5208 fixedHeader={
5209 // Lifted above the stage backdrop, whose fade bleeds below the
5210 // header and would otherwise paint across the search row's outline.
5211 <SidebarGroup className="z-[1]">
5212 <SidebarThreadHeader
5213 searchFieldRef={headerSearchRef}
5214 hasProjects={projectGroups.length > 0}
5215 projectScope={
5216 <Combobox
5217 items={projectScopeItems}
5218 filteredItems={filteredProjectScopeItems}
5219 autoHighlight
5220 itemToStringLabel={(item) => item.label}
5221 isItemEqualToValue={(a, b) => a.value === b.value}
5222 open={projectScopeMenuState.open}
5223 onOpenChange={(open) => {
5224 if (open) suppressNextScopeChangeRef.current = false;
5225 dispatchProjectScopeMenu({ type: "open-changed", open });
5226 }}
5227 onItemHighlighted={(item) => {
5228 highlightedProjectScopeKeyRef.current = item?.value ?? null;
5229 }}
5230 value={selectedProjectScopeItem}
5231 onValueChange={(item) => {
5232 if (suppressNextScopeChangeRef.current) {
5233 suppressNextScopeChangeRef.current = false;
5234 return;
5235 }
5236 if (!item) return;
5237 setProjectScopeKey(item.value === "all" ? null : item.value);
5238 }}
5239 >
5240 <ComboboxTrigger
5241 render={
5242 <SidebarHeaderIconButton
5243 label={
5244 scopedProjectGroup
5245 ? `Filter threads by project: ${scopedProjectGroup.displayName}`
5246 : "Filter threads by project"
5247 }
5248 />
5249 }
5250 >
5251 {scopedProjectGroup ? (
5252 // Wrapped so the button's direct-child svg color rule cannot override
5253 // a project's own icon color.
5254 <span className="flex shrink-0">
5255 <ProjectFavicon project={scopedProjectGroup} className="size-4" />
5256 </span>
5257 ) : (
5258 <ListFilterIcon className="size-4" />
5259 )}
5260 </ComboboxTrigger>
5261 <ComboboxPopup
5262 align="start"
5263 // Anchored to the search field, not the 28px trigger: the
5264 // popup opens under the field, is at least as wide as it,
5265 // and grows to fit project names up to a cap, past which
5266 // the rows truncate.
5267 anchor={headerSearchRef}
5268 className="max-w-[min(18rem,var(--available-width))] overflow-hidden"
5269 >
5270 <ComboboxSearchInput
5271 aria-label="Search projects"
5272 placeholder="Search projects..."
5273 value={projectScopeMenuState.query}
5274 onKeyDown={(event) => {
5275 if (
5276 event.defaultPrevented ||
5277 event.nativeEvent.isComposing ||
5278 event.ctrlKey ||
5279 event.altKey ||
5280 event.metaKey ||
5281 (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
5282 ) {
5283 return;
5284 }
5285 // Combobox items use virtual focus: keyboard events
5286 // stay on this input, not on the highlighted option.
5287 const scopeKey = highlightedProjectScopeKeyRef.current;
5288 const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
5289 if (project) handleProjectSettings(event, project);
5290 }}
5291 onChange={(event) =>
5292 dispatchProjectScopeMenu({
5293 type: "query-changed",
5294 query: event.target.value,
5295 })
5296 }
5297 />
5298 <ComboboxEmpty>No matching projects.</ComboboxEmpty>
5299 <ComboboxList>
5300 {(item: (typeof projectScopeItems)[number]) => {
5301 const project = projectGroupByScopeKey.get(item.value) ?? null;
5302 return (
5303 <ComboboxItem
5304 key={item.value}
5305 hideIndicator
5306 value={item}
5307 onContextMenu={(event) => {
5308 if (project) handleProjectSettings(event, project);
5309 }}
5310 >
5311 {project ? (
5312 <ProjectFavicon project={project} className="size-4 shrink-0" />
5313 ) : (
5314 <FolderIcon className="size-4 shrink-0" />
5315 )}
5316 <span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
5317 {project && showProjectEnvironments ? (
5318 <ProjectEnvironmentBadge
5319 group={project}
5320 primaryEnvironmentId={primaryEnvironmentId}
5321 machineByEnvironmentId={environmentMachineById}
5322 />
5323 ) : null}
5324 {project ? (
5325 <Button
5326 size="icon-xs"
5327 variant="ghost-muted"
5328 tabIndex={-1}
5329 aria-hidden="true"
5330 title={`Project settings for ${project.displayName}`}
5331 className="ml-auto"
5332 onPointerDown={(event) => event.stopPropagation()}
5333 onClick={(event) => {
5334 void handleProjectSettings(event, project);
5335 }}
5336 >
5337 <SettingsIcon className="size-3.5" />
5338 </Button>
5339 ) : null}
5340 </ComboboxItem>
5341 );
5342 }}
5343 </ComboboxList>
5344 </ComboboxPopup>
5345 </Combobox>
5346 }
5347 onNewProject={openAddProjectCommandPalette}
5348 onNewThread={handleNewThreadClick}
5349 newThreadDisabled={projects.length === 0}
5350 newThreadShortcutLabel={newThreadShortcutLabel}
5351 newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
5352 showNewThreadInProjectHint={projectGroups.length > 1}
5353 searchInputRef={threadSearchInputRef}
5354 searchQuery={threadSearchQuery}
5355 onSearchQueryChange={(value) => {
5356 setThreadSearchQuery(value);
5357 setActiveSearchResultIndex(0);
5358 }}
5359 onSearchKeyDown={handleThreadSearchKeyDown}
5360 isSearching={isSearchingThreads}
5361 searchResultCount={threadSearchResults.length}
5362 activeSearchResultIndex={activeSearchResultIndex}
5363 onClearSearch={clearThreadSearch}
5364 />
5365 </SidebarGroup>
5366 }
5367 >
5368 <SidebarGroup className="flex-1" role="presentation">
5369 {isSearchingThreads ? (
5370 threadSearchResults.length > 0 ? (
5371 <TooltipProvider
5372 key="sidebar-thread-search-tooltips-150"
5373 delay={150}
5374 closeDelay={0}
5375 timeout={400}
5376 >
5377 <ul
5378 id="sidebar-thread-search-results"
5379 role="listbox"
5380 aria-label="Thread search results"
5381 className="flex flex-col gap-px"
5382 >
5383 {threadSearchResults.map((thread, index) => {
5384 const threadKey = scopedThreadKey(
5385 scopeThreadRef(thread.environmentId, thread.id),
5386 );
5387 return (
5388 <SidebarSearchResultRow
5389 isSettled={settledThreadKeys.has(threadKey)}
5390 key={threadKey}
5391 thread={thread}
5392 project={
5393 projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
5394 }
5395 projectDisplayName={
5396 projectDisplayNameByKey.get(
5397 `${thread.environmentId}:${thread.projectId}`,
5398 ) ?? null
5399 }
5400 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
5401 environmentMachine={
5402 environmentMachineById.get(thread.environmentId) ?? "server"
5403 }
5404 providerEntryByInstanceId={
5405 providerEntriesByEnvironment.get(thread.environmentId) ??
5406 EMPTY_PROVIDER_ENTRIES
5407 }
5408 isHighlighted={activeSearchResultIndex === index}
5409 isRouteActive={routeThreadKey === threadKey}
5410 resultId={`sidebar-thread-search-result-${index}`}
5411 searchMatch={
5412 threadSearchMatchByKey.get(
5413 threadSearchMatchKey({
5414 environmentId: thread.environmentId,
5415 threadId: thread.id,
5416 }),
5417 ) ?? null
5418 }
5419 searchQuery={threadSearchQuery}
5420 onHighlight={() => setActiveSearchResultIndex(index)}
5421 onSelect={() => selectThreadSearchResult(thread)}
5422 onFileDropThreads={handleThreadFileDrop}
5423 />
5424 );
5425 })}
5426 </ul>
5427 </TooltipProvider>
5428 ) : (
5429 <p
5430 role="status"
5431 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
5432 >
5433 {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}
5434 </p>
5435 )
5436 ) : null}
5437 {!isSearchingThreads ? (
5438 <TooltipProvider
5439 key="sidebar-thread-tooltips-150"
5440 delay={150}
5441 closeDelay={0}
5442 timeout={400}
5443 >
5444 <DndContext
5445 sensors={dndSensors}
5446 autoScroll={!isContextDrag}
5447 collisionDetection={dndCollisionDetection}
5448 modifiers={[
5449 restrictToVerticalAxis,
5450 restrictBelowPins,
5451 restrictToFirstScrollableAncestor,
5452 ]}
5453 onDragStart={handleThreadDragStart}
5454 onDragOver={handleThreadDragOver}
5455 onDragEnd={handleThreadDragEnd}
5456 >
5457 <SidebarDragLifecycle onUnmount={cancelThreadDrag} />
5458 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
5459 <ul
5460 ref={attachListMotionRef}
5461 // VoiceOver treats an exposed list as an interaction boundary,
5462 // which hides its rows from ordinary linear navigation. A
5463 // presentational list also makes its implicit listitems
5464 // presentational while preserving every descendant control.
5465 role="presentation"
5466 className={cn(
5467 "relative flex flex-col gap-px",
5468 sidebarListItems.length > 0 && "flex-1",
5469 // An action sweep owns the pointer: rows it passes over
5470 // neither show hover actions nor open tooltips, even
5471 // controls that opt back in, like the Woke pill.
5472 actionSweep !== null && "**:pointer-events-none",
5473 )}
5474 >
5475 {(() => {
5476 const renderThreadRowInner = (
5477 thread: EnvironmentThreadShell,
5478 section: SidebarSection,
5479 sortable?: SortableThreadRowBag,
5480 ) => {
5481 const threadKey = scopedThreadKey(
5482 scopeThreadRef(thread.environmentId, thread.id),
5483 );
5484 // Settled and snoozed are the ONLY things that collapse a
5485 // row: every other thread is a full card. Density comes
5486 // from users (or the auto rules) actually parking work,
5487 // not from the sidebar second-guessing what still matters.
5488 // Working rows stay cards so their live status shows.
5489 const isCard =
5490 section === "active" || section === "pinned" || section === "working";
5491 const rowVariant = isCard ? "card" : "slim";
5492 return (
5493 <SidebarThreadRow
5494 // Fade between card and compact rows while the outer
5495 // sortable wrapper keeps its identity during a drag.
5496 key={`${threadKey}:${rowVariant}`}
5497 thread={thread}
5498 variant={rowVariant}
5499 // Snoozed rows wake, settled rows un-settle, and cards settle.
5500 variantAction={
5501 section === "snoozed"
5502 ? "unsnooze"
5503 : section === "settled"
5504 ? "unsettle"
5505 : "settle"
5506 }
5507 settlementSupported={
5508 environmentCapabilities.get(thread.environmentId)
5509 ?.threadSettlement === true
5510 }
5511 snoozeSupported={
5512 environmentCapabilities.get(thread.environmentId)?.threadSnooze ===
5513 true
5514 }
5515 pinningSupported={
5516 environmentCapabilities.get(thread.environmentId)?.threadPinning ===
5517 true
5518 }
5519 isPinned={thread.pinnedAt != null}
5520 sortable={sortable}
5521 dropVerb={
5522 dragState?.activeKey === threadKey
5523 ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection)
5524 : null
5525 }
5526 dragOverPinned={
5527 dragState?.activeKey === threadKey && dragTargetSection === "pinned"
5528 }
5529 sweepAction={
5530 actionSweep?.keys.has(threadKey) ? actionSweep.action : null
5531 }
5532 snoozeWakeLabelText={
5533 section === "snoozed" && thread.snoozedUntil != null
5534 ? snoozeWakeLabel(thread.snoozedUntil, {
5535 now: new Date().toISOString(),
5536 })
5537 : null
5538 }
5539 // All sections: a woken thread can classify straight
5540 // into the settled tail (PR merged while snoozed), and
5541 // the wake signal must survive the trip. Still-snoozed
5542 // rows resolve to null on their own.
5543 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
5544 isActive={routeThreadKey === threadKey}
5545 openPullRequestsInRightPanel={routeThreadRef !== null}
5546 jumpLabel={
5547 showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null
5548 }
5549 currentEnvironmentId={primaryEnvironmentId}
5550 environmentLabel={
5551 environmentLabelById.get(thread.environmentId) ?? null
5552 }
5553 environmentMachine={
5554 environmentMachineById.get(thread.environmentId) ?? "server"
5555 }
5556 scratchMachineLabel={scratchMachineLabelFor(thread)}
5557 project={
5558 projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
5559 null
5560 }
5561 projectDisplayName={
5562 projectDisplayNameByKey.get(
5563 `${thread.environmentId}:${thread.projectId}`,
5564 ) ?? null
5565 }
5566 providerEntryByInstanceId={
5567 providerEntriesByEnvironment.get(thread.environmentId) ??
5568 EMPTY_PROVIDER_ENTRIES
5569 }
5570 timestampFormat={timestampFormat}
5571 onThreadClick={handleThreadClick}
5572 onThreadActivate={navigateToThread}
5573 onStartRename={startThreadRename}
5574 onRenameTitleChange={setRenamingTitle}
5575 onCommitRename={commitThreadRename}
5576 onCancelRename={cancelThreadRename}
5577 isRenaming={renamingThreadKey === threadKey}
5578 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
5579 onContextMenu={handleThreadContextMenu}
5580 onSettle={attemptSettle}
5581 onActionSweepStart={startActionSweep}
5582 onUnsettle={attemptUnsettle}
5583 onSnooze={attemptSnooze}
5584 onUnsnooze={attemptUnsnooze}
5585 onUnpin={attemptUnpin}
5586 onAcknowledgeWoke={acknowledgeWoke}
5587 onFileDropThreads={handleThreadFileDrop}
5588 changeRequestSnapshot={
5589 changeRequestSnapshotByKey.get(threadKey) ?? null
5590 }
5591 onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
5592 />
5593 );
5594 };
5595 const renderThreadRow = (
5596 thread: EnvironmentThreadShell,
5597 section: SidebarSection,
5598 ) => {
5599 const threadKey = scopedThreadKey(
5600 scopeThreadRef(thread.environmentId, thread.id),
5601 );
5602 return (
5603 <SortableThreadRow
5604 key={threadKey}
5605 id={threadKey}
5606 contextDrag={isContextDrag}
5607 environmentId={thread.environmentId}
5608 disabled={
5609 renamingThreadKey === threadKey ||
5610 section === "working" ||
5611 !draggableThreadKeys.has(threadKey) ||
5612 optimisticDrop !== null
5613 }
5614 >
5615 {(bag) => renderThreadRowInner(thread, section, bag)}
5616 </SortableThreadRow>
5617 );
5618 };
5619 const from = isContextDrag ? null : (dragState?.activeSection ?? null);
5620 const items: ReactNode[] = [
5621 <SidebarDraftBlock
5622 key="draft-sessions"
5623 projectByKey={projectByKey}
5624 projectDisplayNameByKey={projectDisplayNameByKey}
5625 scopedProjectKeys={scopedProjectKeys}
5626 routeDraftId={routeDraftIdForRows}
5627 onNavigateToDraft={navigateToDraft}
5628 onDraftContextMenu={handleDraftContextMenu}
5629 />,
5630 ];
5631 for (const item of sidebarListItems) {
5632 if (item.kind === "thread") {
5633 items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
5634 continue;
5635 }
5636 switch (item.marker) {
5637 case "pinned-header":
5638 items.push(
5639 <SidebarDragBoundary
5640 key="pinned-header"
5641 marker="pinned-header"
5642 label="Pinned"
5643 visible={from !== null}
5644 isDropTarget={dragTargetSection === "pinned"}
5645 />,
5646 );
5647 break;
5648 case "pinned-divider":
5649 items.push(
5650 <SidebarDragBoundary
5651 key="pinned-divider"
5652 marker="pinned-divider"
5653 label="Active"
5654 visible={from !== null}
5655 isDropTarget={dragTargetSection === "active"}
5656 />,
5657 );
5658 break;
5659 case "active-placeholder":
5660 items.push(
5661 <SidebarSectionPlaceholder
5662 key="active-placeholder"
5663 marker="active-placeholder"
5664 label="Active"
5665 showHint={
5666 from !== null &&
5667 (activeThreads.length === 0 ||
5668 (from === "active" &&
5669 activeThreads.length === 1 &&
5670 dragTargetSection !== null &&
5671 dragTargetSection !== "active"))
5672 }
5673 isDropTarget={dragTargetSection === "active"}
5674 />,
5675 );
5676 break;
5677 case "working-header":
5678 items.push(
5679 <SidebarSectionHeader
5680 key="working-shelf-header"
5681 marker="working-header"
5682 className="mt-auto"
5683 label={
5684 workingShelfExpanded
5685 ? "Working"
5686 : `Working (${workingThreads.length})`
5687 }
5688 toggle={{
5689 expanded: workingShelfExpanded,
5690 onToggle: toggleWorkingShelf,
5691 }}
5692 />,
5693 );
5694 break;
5695 case "snoozed-header":
5696 items.push(
5697 <SidebarSectionHeader
5698 key="snoozed-shelf-header"
5699 marker="snoozed-header"
5700 className={cn(workingThreads.length === 0 && "mt-auto")}
5701 label={
5702 snoozedShelfExpanded
5703 ? "Snoozed"
5704 : `Snoozed (${snoozedThreads.length})`
5705 }
5706 toggle={{
5707 expanded: snoozedShelfExpanded,
5708 onToggle: toggleSnoozedShelf,
5709 }}
5710 />,
5711 );
5712 break;
5713 case "settled-header":
5714 items.push(
5715 <SidebarSectionHeader
5716 key="settled-shelf-header"
5717 marker="settled-header"
5718 className={cn(
5719 workingThreads.length + snoozedThreads.length === 0 && "mt-auto",
5720 )}
5721 label={
5722 settledShelfExpanded
5723 ? "Settled"
5724 : `Settled (${settledThreads.length})`
5725 }
5726 dragging={from !== null}
5727 isDropTarget={dragTargetSection === "settled"}
5728 toggle={{
5729 expanded: settledShelfExpanded,
5730 onToggle: toggleSettledShelf,
5731 }}
5732 onContextMenu={(position) =>
5733 void handleSettledContextMenu(position)
5734 }
5735 />,
5736 );
5737 break;
5738 case "settled-placeholder":
5739 items.push(
5740 <SidebarSectionPlaceholder
5741 key="settled-placeholder"
5742 marker="settled-placeholder"
5743 label="Settled"
5744 showHint={
5745 from !== null &&
5746 (renderedSettledThreads.length === 0 ||
5747 (from === "settled" &&
5748 renderedSettledThreads.length === 1 &&
5749 dragTargetSection !== null &&
5750 dragTargetSection !== "settled"))
5751 }
5752 isDropTarget={dragTargetSection === "settled"}
5753 />,
5754 );
5755 break;
5756 }
5757 }
5758 return items;
5759 })()}
5760 {settledShelfExpanded && hiddenSettledCount > 0 ? (
5761 <li className="list-none">
5762 <button
5763 type="button"
5764 onClick={showMoreSettled}
5765 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
5766 >
5767 <PlusIcon aria-hidden className="size-4 shrink-0" />
5768 Show {hiddenSettledCount} more
5769 </button>
5770 </li>
5771 ) : null}
5772 </ul>
5773 </SortableContext>
5774 </DndContext>
5775 </TooltipProvider>
5776 ) : null}
5777 {!isSearchingThreads &&
5778 visibleDraftSessionCount === 0 &&
5779 pinnedThreads.length +
5780 activeThreads.length +
5781 workingThreads.length +
5782 snoozedThreads.length +
5783 settledThreads.length ===
5784 0 ? (
5785 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
5786 {projects.length === 0 ? (
5787 <>
5788 <span>No projects yet</span>
5789 <button
5790 type="button"
5791 onClick={openAddProjectCommandPalette}
5792 className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-2xs font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
5793 >
5794 <PlusIcon className="-mx-0.5 size-3" />
5795 Add project
5796 </button>
5797 </>
5798 ) : scopedProjectGroup ? (
5799 `No threads in ${scopedProjectGroup.displayName} yet`
5800 ) : (
5801 "No threads yet"
5802 )}
5803 </div>
5804 ) : null}
5805 </SidebarGroup>
5806 </SidebarContent>
5807 <SidebarChromeFooter />
5808 </>
5809 );
5810}