authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-08-01 02:22:20+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 16:52:20-04:00
logbfc31507f8483b0ba22cf10f5862947b49b20a53
treedf005101765638d8a9e9e2e3d37dd67b0d40f4bc
parent03adf215ceebedeb9c5e813406154dd8353b36f6
signature Signed by PGP key B5690EEEBB952194

feat(web): search threads from the sidebar (#4769)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 493 insertions(+), 190 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+21
......@@ -20,6 +20,7 @@ import {
2020 resolveSidebarV2Status,
2121 resolveThreadStatusPill,
2222 resolveWorkingStartedAt,
23 searchSidebarThreadsByTitle,
2324 formatWorkingDurationLabel,
2425 shouldNavigateAfterProjectRemoval,
2526 shouldClearThreadSelectionOnMouseDown,
......@@ -691,6 +692,26 @@ describe("resolveSidebarV2Status", () => {
691692 });
692693});
693694
695describe("searchSidebarThreadsByTitle", () => {
696 const threads = [
697 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },
698 { id: "thread-2", title: "Review providers", project: "Workspace" },
699 { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" },
700 ];
701
702 it("matches thread titles case-insensitively and preserves their order", () => {
703 expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]);
704 });
705
706 it("does not match project metadata", () => {
707 expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]);
708 });
709
710 it("returns no results for an empty query", () => {
711 expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]);
712 });
713});
714
694715describe("sortThreadsForSidebarV2", () => {
695716 const sortable = (input: { id: string; createdAt: string }) => ({
696717 id: input.id,
apps/web/src/components/Sidebar.logic.ts+14
......@@ -490,6 +490,20 @@ export function sortThreadsForSidebarV2<
490490 );
491491}
492492
493/**
494 * Search the already-ordered sidebar thread collection by title only.
495 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
496 * remains stable while the user narrows the list.
497 */
498export function searchSidebarThreadsByTitle<T extends { readonly title: string }>(
499 threads: readonly T[],
500 query: string,
501): T[] {
502 const normalizedQuery = query.trim().toLowerCase();
503 if (normalizedQuery.length === 0) return [];
504 return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery));
505}
506
493507type SettledTimestampInput = Pick<
494508 SidebarThreadSummary,
495509 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
apps/web/src/components/SidebarV2.tsx+458-190
......@@ -35,6 +35,7 @@ import {
3535 TerminalIcon,
3636 Trash2Icon,
3737 Undo2Icon,
38 XIcon,
3839} from "lucide-react";
3940import {
4041 memo,
......@@ -115,6 +116,7 @@ import {
115116 resolveAdjacentThreadId,
116117 resolveSettledTimestamp,
117118 resolveSidebarV2Status,
119 searchSidebarThreadsByTitle,
118120 resolveWorkingStartedAt,
119121 shouldNavigateAfterProjectRemoval,
120122 sortLogicalProjectsForSidebar,
......@@ -142,7 +144,6 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr
142144import { primaryServerProvidersAtom } from "../state/server";
143145import { useThreadRunningTerminalIds } from "../state/terminalSessions";
144146import { stackedThreadToast, toastManager } from "./ui/toast";
145import { CommandDialogTrigger } from "./ui/command";
146147import { Button } from "./ui/button";
147148import {
148149 Dialog,
......@@ -154,7 +155,6 @@ import {
154155 DialogTitle,
155156} from "./ui/dialog";
156157import { Input } from "./ui/input";
157import { Kbd } from "./ui/kbd";
158158import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
159159import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
160160import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
......@@ -1068,6 +1068,106 @@ function latestTurnDiff(
10681068 return null;
10691069}
10701070
1071const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: {
1072 thread: SidebarThreadSummary;
1073 projectCwd: string | null;
1074 projectTitle: string | null;
1075 environmentLabel: string | null;
1076 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
1077 isHighlighted: boolean;
1078 isRouteActive: boolean;
1079 resultId: string;
1080 onHighlight: () => void;
1081 onSelect: () => void;
1082}) {
1083 const { thread } = props;
1084 // Same details tooltip as the regular rows: a search hit is still a thread,
1085 // and the hover card is how you disambiguate identically-titled results.
1086 const gitCwd = thread.worktreePath ?? props.projectCwd;
1087 const gitStatus = useEnvironmentQuery(
1088 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
1089 ? vcsEnvironment.status({
1090 environmentId: thread.environmentId,
1091 input: { cwd: gitCwd },
1092 })
1093 : null,
1094 );
1095 const branchMismatch = resolveLocalCheckoutBranchMismatch({
1096 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
1097 activeWorktreePath: thread.worktreePath,
1098 activeThreadBranch: thread.branch,
1099 currentGitBranch: gitStatus.data?.refName ?? null,
1100 });
1101 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
1102 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
1103 const driverKind = providerEntry?.driverKind ?? null;
1104 const selectedModel = providerEntry?.models.find(
1105 (model) => model.slug === thread.modelSelection.model,
1106 );
1107 const modelLabel = selectedModel
1108 ? getTriggerDisplayModelLabel(selectedModel)
1109 : thread.modelSelection.model;
1110 const runningTerminalIds = useThreadRunningTerminalIds({
1111 environmentId: thread.environmentId,
1112 threadId: thread.id,
1113 });
1114 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
1115 return (
1116 <li role="presentation" className="list-none">
1117 <Tooltip>
1118 <TooltipTrigger
1119 render={
1120 <button
1121 id={props.resultId}
1122 type="button"
1123 role="option"
1124 // aria-activedescendant options: focus stays on the search input,
1125 // which owns all keyboard interaction for the listbox.
1126 tabIndex={-1}
1127 aria-selected={props.isHighlighted}
1128 aria-current={props.isRouteActive ? "page" : undefined}
1129 aria-label={
1130 props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title
1131 }
1132 onMouseMove={props.onHighlight}
1133 onClick={props.onSelect}
1134 className={cn(
1135 "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
1136 props.isHighlighted || props.isRouteActive
1137 ? "bg-sidebar-row-active text-sidebar-foreground"
1138 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
1139 )}
1140 />
1141 }
1142 >
1143 <ProjectFavicon
1144 environmentId={thread.environmentId}
1145 cwd={props.projectCwd ?? ""}
1146 className="size-4 shrink-0"
1147 fallbackIcon={MessageSquareIcon}
1148 />
1149 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
1150 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
1151 {threadTimeLabel(thread)}
1152 </span>
1153 </TooltipTrigger>
1154 <SidebarV2ThreadTooltip
1155 thread={thread}
1156 projectTitle={props.projectTitle}
1157 projectCwd={props.projectCwd}
1158 environmentLabel={props.environmentLabel}
1159 driverKind={driverKind}
1160 modelInstanceId={modelInstanceId}
1161 modelLabel={modelLabel}
1162 branchMismatch={branchMismatch}
1163 terminalStatus={terminalStatus}
1164 terminalProcessCount={runningTerminalIds.length}
1165 />
1166 </Tooltip>
1167 </li>
1168 );
1169});
1170
10711171export default function SidebarV2() {
10721172 const projects = useProjects();
10731173 const projectOrder = useUiStateStore((store) => store.projectOrder);
......@@ -1520,6 +1620,33 @@ export default function SidebarV2() {
15201620 threads,
15211621 ]);
15221622
1623 const threadSearchInputRef = useRef<HTMLInputElement>(null);
1624 const [threadSearchQuery, setThreadSearchQuery] = useState("");
1625 const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
1626 const isSearchingThreads = threadSearchQuery.trim().length > 0;
1627 const searchableThreads = useMemo(
1628 () => [...activeThreads, ...snoozedThreads, ...settledThreads],
1629 [activeThreads, settledThreads, snoozedThreads],
1630 );
1631 const threadSearchResults = useMemo(
1632 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
1633 [searchableThreads, threadSearchQuery],
1634 );
1635 const threadSearchResultOrderKey = threadSearchResults
1636 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
1637 .join("\0");
1638
1639 useEffect(() => {
1640 setActiveSearchResultIndex(0);
1641 }, [threadSearchResultOrderKey]);
1642
1643 useEffect(() => {
1644 if (!isSearchingThreads) return;
1645 document
1646 .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`)
1647 ?.scrollIntoView({ block: "nearest" });
1648 }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]);
1649
15231650 // Arm a timeout for the earliest upcoming wake so the shelf empties the
15241651 // moment a snooze expires instead of on the next minute tick. Sorted
15251652 // soonest-first, so entry 0 is the boundary.
......@@ -1693,6 +1820,56 @@ export default function SidebarV2() {
16931820 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
16941821 );
16951822
1823 const clearThreadSearch = useCallback(() => {
1824 setThreadSearchQuery("");
1825 setActiveSearchResultIndex(0);
1826 }, []);
1827 const selectThreadSearchResult = useCallback(
1828 (thread: EnvironmentThreadShell) => {
1829 clearThreadSearch();
1830 navigateToThread(scopeThreadRef(thread.environmentId, thread.id));
1831 },
1832 [clearThreadSearch, navigateToThread],
1833 );
1834 const handleThreadSearchKeyDown = useCallback(
1835 (event: ReactKeyboardEvent<HTMLInputElement>) => {
1836 // IME composition (Japanese/Chinese input) uses the same keys; committing
1837 // a candidate must not move the highlight or navigate away mid-compose.
1838 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
1839 if (event.key === "Escape" && isSearchingThreads) {
1840 event.preventDefault();
1841 event.stopPropagation();
1842 clearThreadSearch();
1843 return;
1844 }
1845 if (threadSearchResults.length === 0) return;
1846 if (event.key === "ArrowDown") {
1847 event.preventDefault();
1848 setActiveSearchResultIndex((index) => (index + 1) % threadSearchResults.length);
1849 return;
1850 }
1851 if (event.key === "ArrowUp") {
1852 event.preventDefault();
1853 setActiveSearchResultIndex(
1854 (index) => (index - 1 + threadSearchResults.length) % threadSearchResults.length,
1855 );
1856 return;
1857 }
1858 if (event.key === "Enter") {
1859 event.preventDefault();
1860 const result = threadSearchResults[activeSearchResultIndex];
1861 if (result) selectThreadSearchResult(result);
1862 }
1863 },
1864 [
1865 activeSearchResultIndex,
1866 clearThreadSearch,
1867 isSearchingThreads,
1868 selectThreadSearchResult,
1869 threadSearchResults,
1870 ],
1871 );
1872
16961873 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
16971874 const [renamingTitle, setRenamingTitle] = useState("");
16981875 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
......@@ -2398,7 +2575,6 @@ export default function SidebarV2() {
23982575 openCommandPalette({ open: "new-thread-in" });
23992576 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
24002577
2401 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
24022578 // Same resolution as v1: prefer the local-thread binding, fall back to
24032579 // chat.new, no platform gating — web users have working shortcuts too.
24042580 const newThreadShortcutLabel =
......@@ -2412,25 +2588,51 @@ export default function SidebarV2() {
24122588 fixedHeader={
24132589 <SidebarGroup className="gap-1 p-[var(--sidebar-content-inset)]">
24142590 <div className="flex items-center gap-1">
2415 <div className="min-w-0 flex-1">
2416 <CommandDialogTrigger
2417 render={
2418 <SidebarMenuButton
2419 type="button"
2420 aria-label="Search threads and commands"
2421 className="focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
2422 data-testid="command-palette-trigger"
2423 />
2591 <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
2592 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
2593 <Input
2594 ref={threadSearchInputRef}
2595 nativeInput
2596 unstyled
2597 type="search"
2598 value={threadSearchQuery}
2599 onChange={(event) => {
2600 setThreadSearchQuery(event.currentTarget.value);
2601 setActiveSearchResultIndex(0);
2602 }}
2603 onKeyDown={handleThreadSearchKeyDown}
2604 placeholder="Search"
2605 aria-label="Search threads"
2606 role="combobox"
2607 aria-autocomplete="list"
2608 aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
2609 aria-controls={
2610 isSearchingThreads && threadSearchResults.length > 0
2611 ? "sidebar-thread-search-results"
2612 : undefined
24242613 }
2425 >
2426 <SearchIcon />
2427 <div className="flex-1 truncate text-left">Search</div>
2428 {commandPaletteShortcutLabel ? (
2429 <Kbd className="mr-px h-4 min-w-0 rounded-sm bg-sidebar-control-surface px-1.5 text-[10px] text-sidebar-muted-foreground ring-1 ring-sidebar-border">
2430 {commandPaletteShortcutLabel}
2431 </Kbd>
2432 ) : null}
2433 </CommandDialogTrigger>
2614 aria-activedescendant={
2615 isSearchingThreads && threadSearchResults[activeSearchResultIndex]
2616 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
2617 : undefined
2618 }
2619 className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground"
2620 />
2621 {isSearchingThreads ? (
2622 <Button
2623 type="button"
2624 size="icon-xs"
2625 variant="ghost"
2626 className="size-5 shrink-0 rounded-sm text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
2627 aria-label="Clear thread search"
2628 onClick={() => {
2629 clearThreadSearch();
2630 threadSearchInputRef.current?.focus();
2631 }}
2632 >
2633 <XIcon className="size-3" />
2634 </Button>
2635 ) : null}
24342636 </div>
24352637 <div className="shrink-0">
24362638 <Tooltip>
......@@ -2559,179 +2761,245 @@ export default function SidebarV2() {
25592761 }
25602762 >
25612763 <SidebarGroup className="px-[var(--sidebar-content-inset)] pb-1 pt-0">
2562 <TooltipProvider
2563 key="sidebar-thread-tooltips-150"
2564 delay={150}
2565 closeDelay={0}
2566 timeout={400}
2567 >
2568 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
2569 {(() => {
2570 const renderThreadRow = (
2571 thread: EnvironmentThreadShell,
2572 section: "active" | "snoozed" | "settled",
2573 ) => {
2574 const threadKey = scopedThreadKey(
2575 scopeThreadRef(thread.environmentId, thread.id),
2576 );
2577 // Settled and snoozed are the ONLY things that collapse a
2578 // row: every other thread is a full card. Density comes
2579 // from users (or the auto rules) actually parking work,
2580 // not from the sidebar second-guessing what still matters.
2581 const isCard = section === "active";
2582 const rowVariant = isCard ? "card" : "slim";
2583 return (
2584 <SidebarV2Row
2585 // Keyed per variant on purpose: when a thread settles,
2586 // the card fades out in place and the slim row fades
2587 // in at its settled position instead of one element
2588 // FLIP-sliding through every row in between (rows here
2589 // are translucent, so a crossing row reads as text
2590 // painted over text).
2591 key={`${threadKey}:${rowVariant}`}
2592 thread={thread}
2593 variant={rowVariant}
2594 // Snoozed rows wake; settled rows un-settle (explicit
2595 // settles clear the override, auto-settled rows get
2596 // pinned active); cards settle.
2597 variantAction={
2598 section === "snoozed"
2599 ? "unsnooze"
2600 : section === "settled"
2601 ? "unsettle"
2602 : "settle"
2603 }
2604 settlementSupported={
2605 serverConfigs.get(thread.environmentId)?.environment.capabilities
2606 .threadSettlement === true
2607 }
2608 snoozeSupported={
2609 serverConfigs.get(thread.environmentId)?.environment.capabilities
2610 .threadSnooze === true
2611 }
2612 snoozeWakeLabelText={
2613 section === "snoozed" && thread.snoozedUntil != null
2614 ? snoozeWakeLabel(thread.snoozedUntil, {
2615 now: new Date().toISOString(),
2616 })
2617 : null
2618 }
2619 // All sections: a woken thread can classify straight
2620 // into the settled tail (PR merged while snoozed), and
2621 // the wake signal must survive the trip. Still-snoozed
2622 // rows resolve to null on their own.
2623 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
2624 isActive={routeThreadKey === threadKey}
2625 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
2626 currentEnvironmentId={primaryEnvironmentId}
2627 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
2628 projectCwd={
2629 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
2630 }
2631 projectTitle={
2632 projectDisplayNameByKey.get(
2633 `${thread.environmentId}:${thread.projectId}`,
2634 ) ?? null
2635 }
2636 providerEntryByInstanceId={providerEntryByInstanceId}
2637 onThreadClick={handleThreadClick}
2638 onThreadActivate={navigateToThread}
2639 onStartRename={startThreadRename}
2640 onRenameTitleChange={setRenamingTitle}
2641 onCommitRename={commitThreadRename}
2642 onCancelRename={cancelThreadRename}
2643 isRenaming={renamingThreadKey === threadKey}
2644 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
2645 onContextMenu={handleThreadContextMenu}
2646 onSettle={attemptSettle}
2647 onUnsettle={attemptUnsettle}
2648 onSnooze={attemptSnooze}
2649 onUnsnooze={attemptUnsnooze}
2650 onChangeRequestState={handleChangeRequestState}
2651 />
2764 {isSearchingThreads ? (
2765 threadSearchResults.length > 0 ? (
2766 <TooltipProvider
2767 key="sidebar-thread-search-tooltips-150"
2768 delay={150}
2769 closeDelay={0}
2770 timeout={400}
2771 >
2772 <ul
2773 id="sidebar-thread-search-results"
2774 role="listbox"
2775 aria-label="Thread search results"
2776 className="flex flex-col gap-px"
2777 >
2778 {threadSearchResults.map((thread, index) => {
2779 const threadKey = scopedThreadKey(
2780 scopeThreadRef(thread.environmentId, thread.id),
2781 );
2782 return (
2783 <SidebarV2SearchResultRow
2784 key={threadKey}
2785 thread={thread}
2786 projectCwd={
2787 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
2788 }
2789 projectTitle={
2790 projectDisplayNameByKey.get(
2791 `${thread.environmentId}:${thread.projectId}`,
2792 ) ?? null
2793 }
2794 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
2795 providerEntryByInstanceId={providerEntryByInstanceId}
2796 isHighlighted={activeSearchResultIndex === index}
2797 isRouteActive={routeThreadKey === threadKey}
2798 resultId={`sidebar-thread-search-result-${index}`}
2799 onHighlight={() => setActiveSearchResultIndex(index)}
2800 onSelect={() => selectThreadSearchResult(thread)}
2801 />
2802 );
2803 })}
2804 </ul>
2805 </TooltipProvider>
2806 ) : (
2807 <p
2808 role="status"
2809 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
2810 >
2811 No threads found
2812 </p>
2813 )
2814 ) : null}
2815 {!isSearchingThreads ? (
2816 <TooltipProvider
2817 key="sidebar-thread-tooltips-150"
2818 delay={150}
2819 closeDelay={0}
2820 timeout={400}
2821 >
2822 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
2823 {(() => {
2824 const renderThreadRow = (
2825 thread: EnvironmentThreadShell,
2826 section: "active" | "snoozed" | "settled",
2827 ) => {
2828 const threadKey = scopedThreadKey(
2829 scopeThreadRef(thread.environmentId, thread.id),
2830 );
2831 // Settled and snoozed are the ONLY things that collapse a
2832 // row: every other thread is a full card. Density comes
2833 // from users (or the auto rules) actually parking work,
2834 // not from the sidebar second-guessing what still matters.
2835 const isCard = section === "active";
2836 const rowVariant = isCard ? "card" : "slim";
2837 return (
2838 <SidebarV2Row
2839 // Keyed per variant on purpose: when a thread settles,
2840 // the card fades out in place and the slim row fades
2841 // in at its settled position instead of one element
2842 // FLIP-sliding through every row in between (rows here
2843 // are translucent, so a crossing row reads as text
2844 // painted over text).
2845 key={`${threadKey}:${rowVariant}`}
2846 thread={thread}
2847 variant={rowVariant}
2848 // Snoozed rows wake; settled rows un-settle (explicit
2849 // settles clear the override, auto-settled rows get
2850 // pinned active); cards settle.
2851 variantAction={
2852 section === "snoozed"
2853 ? "unsnooze"
2854 : section === "settled"
2855 ? "unsettle"
2856 : "settle"
2857 }
2858 settlementSupported={
2859 serverConfigs.get(thread.environmentId)?.environment.capabilities
2860 .threadSettlement === true
2861 }
2862 snoozeSupported={
2863 serverConfigs.get(thread.environmentId)?.environment.capabilities
2864 .threadSnooze === true
2865 }
2866 snoozeWakeLabelText={
2867 section === "snoozed" && thread.snoozedUntil != null
2868 ? snoozeWakeLabel(thread.snoozedUntil, {
2869 now: new Date().toISOString(),
2870 })
2871 : null
2872 }
2873 // All sections: a woken thread can classify straight
2874 // into the settled tail (PR merged while snoozed), and
2875 // the wake signal must survive the trip. Still-snoozed
2876 // rows resolve to null on their own.
2877 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
2878 isActive={routeThreadKey === threadKey}
2879 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
2880 currentEnvironmentId={primaryEnvironmentId}
2881 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
2882 projectCwd={
2883 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
2884 }
2885 projectTitle={
2886 projectDisplayNameByKey.get(
2887 `${thread.environmentId}:${thread.projectId}`,
2888 ) ?? null
2889 }
2890 providerEntryByInstanceId={providerEntryByInstanceId}
2891 onThreadClick={handleThreadClick}
2892 onThreadActivate={navigateToThread}
2893 onStartRename={startThreadRename}
2894 onRenameTitleChange={setRenamingTitle}
2895 onCommitRename={commitThreadRename}
2896 onCancelRename={cancelThreadRename}
2897 isRenaming={renamingThreadKey === threadKey}
2898 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
2899 onContextMenu={handleThreadContextMenu}
2900 onSettle={attemptSettle}
2901 onUnsettle={attemptUnsettle}
2902 onSnooze={attemptSnooze}
2903 onUnsnooze={attemptUnsnooze}
2904 onChangeRequestState={handleChangeRequestState}
2905 />
2906 );
2907 };
2908 const items: ReactNode[] = activeThreads.map((thread) =>
2909 renderThreadRow(thread, "active"),
26522910 );
2653 };
2654 const items: ReactNode[] = activeThreads.map((thread) =>
2655 renderThreadRow(thread, "active"),
2656 );
2657 // Snoozed shelf: between the inbox and Settled — out of the
2658 // way, never gone. The header always renders while anything
2659 // is snoozed (the count is the whole footprint when
2660 // collapsed); rows only when expanded. Vanishes entirely at
2661 // count 0.
2662 if (snoozedThreads.length > 0) {
2663 items.push(
2664 <li key="snoozed-shelf-header" data-thread-selection-safe className="list-none">
2665 <button
2666 type="button"
2667 onClick={toggleSnoozedShelf}
2668 aria-expanded={snoozedShelfExpanded}
2669 data-testid="sidebar-v2-snoozed-shelf-toggle"
2670 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2911 // Snoozed shelf: between the inbox and Settled — out of the
2912 // way, never gone. The header always renders while anything
2913 // is snoozed (the count is the whole footprint when
2914 // collapsed); rows only when expanded. Vanishes entirely at
2915 // count 0.
2916 if (snoozedThreads.length > 0) {
2917 items.push(
2918 <li
2919 key="snoozed-shelf-header"
2920 data-thread-selection-safe
2921 className="list-none"
26712922 >
2672 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
2673 {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`}
2674 </span>
2675 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
2676 <ChevronDownIcon
2677 aria-hidden
2678 className={cn(
2679 "size-3 text-blue-600 transition-transform dark:text-blue-400",
2680 snoozedShelfExpanded && "rotate-180",
2681 )}
2682 />
2683 </button>
2684 </li>,
2685 );
2686 for (const thread of visibleSnoozedThreads) {
2687 items.push(renderThreadRow(thread, "snoozed"));
2923 <button
2924 type="button"
2925 onClick={toggleSnoozedShelf}
2926 aria-expanded={snoozedShelfExpanded}
2927 data-testid="sidebar-v2-snoozed-shelf-toggle"
2928 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2929 >
2930 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
2931 {snoozedShelfExpanded
2932 ? "Snoozed"
2933 : `Snoozed (${snoozedThreads.length})`}
2934 </span>
2935 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
2936 <ChevronDownIcon
2937 aria-hidden
2938 className={cn(
2939 "size-3 text-blue-600 transition-transform dark:text-blue-400",
2940 snoozedShelfExpanded && "rotate-180",
2941 )}
2942 />
2943 </button>
2944 </li>,
2945 );
2946 for (const thread of visibleSnoozedThreads) {
2947 items.push(renderThreadRow(thread, "snoozed"));
2948 }
26882949 }
2689 }
2690 if (settledThreads.length > 0) {
2691 items.push(
2692 <li key="settled-shelf-header" data-thread-selection-safe className="list-none">
2693 <button
2694 type="button"
2695 onClick={toggleSettledShelf}
2696 aria-expanded={settledShelfExpanded}
2697 data-testid="sidebar-v2-settled-shelf-toggle"
2698 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2950 if (settledThreads.length > 0) {
2951 items.push(
2952 <li
2953 key="settled-shelf-header"
2954 data-thread-selection-safe
2955 className="list-none"
26992956 >
2700 <span className="text-xs font-medium text-muted-foreground/50">
2701 {settledShelfExpanded ? "Settled" : `Settled (${settledThreads.length})`}
2702 </span>
2703 <span className="h-px flex-1 bg-sidebar-border/60" />
2704 <ChevronDownIcon
2705 aria-hidden
2706 className={cn(
2707 "size-3 text-muted-foreground/50 transition-transform",
2708 settledShelfExpanded && "rotate-180",
2709 )}
2710 />
2711 </button>
2712 </li>,
2713 );
2714 }
2715 for (const thread of renderedSettledThreads) {
2716 items.push(renderThreadRow(thread, "settled"));
2717 }
2718 return items;
2719 })()}
2720 {settledShelfExpanded && hiddenSettledCount > 0 ? (
2721 <li className="list-none">
2722 <button
2723 type="button"
2724 onClick={showMoreSettled}
2725 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"
2726 >
2727 <PlusIcon aria-hidden className="size-4 shrink-0" />
2728 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
2729 </button>
2730 </li>
2731 ) : null}
2732 </ul>
2733 </TooltipProvider>
2734 {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
2957 <button
2958 type="button"
2959 onClick={toggleSettledShelf}
2960 aria-expanded={settledShelfExpanded}
2961 data-testid="sidebar-v2-settled-shelf-toggle"
2962 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2963 >
2964 <span className="text-xs font-medium text-muted-foreground/50">
2965 {settledShelfExpanded
2966 ? "Settled"
2967 : `Settled (${settledThreads.length})`}
2968 </span>
2969 <span className="h-px flex-1 bg-sidebar-border/60" />
2970 <ChevronDownIcon
2971 aria-hidden
2972 className={cn(
2973 "size-3 text-muted-foreground/50 transition-transform",
2974 settledShelfExpanded && "rotate-180",
2975 )}
2976 />
2977 </button>
2978 </li>,
2979 );
2980 }
2981 for (const thread of renderedSettledThreads) {
2982 items.push(renderThreadRow(thread, "settled"));
2983 }
2984 return items;
2985 })()}
2986 {settledShelfExpanded && hiddenSettledCount > 0 ? (
2987 <li className="list-none">
2988 <button
2989 type="button"
2990 onClick={showMoreSettled}
2991 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"
2992 >
2993 <PlusIcon aria-hidden className="size-4 shrink-0" />
2994 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
2995 </button>
2996 </li>
2997 ) : null}
2998 </ul>
2999 </TooltipProvider>
3000 ) : null}
3001 {!isSearchingThreads &&
3002 activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (
27353003 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
27363004 {projects.length === 0 ? (
27373005 <>