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 {...@@ -20,6 +20,7 @@ import {
20 resolveSidebarV2Status,20 resolveSidebarV2Status,
21 resolveThreadStatusPill,21 resolveThreadStatusPill,
22 resolveWorkingStartedAt,22 resolveWorkingStartedAt,
23 searchSidebarThreadsByTitle,
23 formatWorkingDurationLabel,24 formatWorkingDurationLabel,
24 shouldNavigateAfterProjectRemoval,25 shouldNavigateAfterProjectRemoval,
25 shouldClearThreadSelectionOnMouseDown,26 shouldClearThreadSelectionOnMouseDown,
...@@ -691,6 +692,26 @@ describe("resolveSidebarV2Status", () => {...@@ -691,6 +692,26 @@ describe("resolveSidebarV2Status", () => {
691 });692 });
692});693});
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
694describe("sortThreadsForSidebarV2", () => {715describe("sortThreadsForSidebarV2", () => {
695 const sortable = (input: { id: string; createdAt: string }) => ({716 const sortable = (input: { id: string; createdAt: string }) => ({
696 id: input.id,717 id: input.id,
apps/web/src/components/Sidebar.logic.ts+14
...@@ -490,6 +490,20 @@ export function sortThreadsForSidebarV2<...@@ -490,6 +490,20 @@ export function sortThreadsForSidebarV2<
490 );490 );
491}491}
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
493type SettledTimestampInput = Pick<507type SettledTimestampInput = Pick<
494 SidebarThreadSummary,508 SidebarThreadSummary,
495 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"509 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
apps/web/src/components/SidebarV2.tsx+458-190
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 TerminalIcon,35 TerminalIcon,
36 Trash2Icon,36 Trash2Icon,
37 Undo2Icon,37 Undo2Icon,
38 XIcon,
38} from "lucide-react";39} from "lucide-react";
39import {40import {
40 memo,41 memo,
...@@ -115,6 +116,7 @@ import {...@@ -115,6 +116,7 @@ import {
115 resolveAdjacentThreadId,116 resolveAdjacentThreadId,
116 resolveSettledTimestamp,117 resolveSettledTimestamp,
117 resolveSidebarV2Status,118 resolveSidebarV2Status,
119 searchSidebarThreadsByTitle,
118 resolveWorkingStartedAt,120 resolveWorkingStartedAt,
119 shouldNavigateAfterProjectRemoval,121 shouldNavigateAfterProjectRemoval,
120 sortLogicalProjectsForSidebar,122 sortLogicalProjectsForSidebar,
...@@ -142,7 +144,6 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr...@@ -142,7 +144,6 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr
142import { primaryServerProvidersAtom } from "../state/server";144import { primaryServerProvidersAtom } from "../state/server";
143import { useThreadRunningTerminalIds } from "../state/terminalSessions";145import { useThreadRunningTerminalIds } from "../state/terminalSessions";
144import { stackedThreadToast, toastManager } from "./ui/toast";146import { stackedThreadToast, toastManager } from "./ui/toast";
145import { CommandDialogTrigger } from "./ui/command";
146import { Button } from "./ui/button";147import { Button } from "./ui/button";
147import {148import {
148 Dialog,149 Dialog,
...@@ -154,7 +155,6 @@ import {...@@ -154,7 +155,6 @@ import {
154 DialogTitle,155 DialogTitle,
155} from "./ui/dialog";156} from "./ui/dialog";
156import { Input } from "./ui/input";157import { Input } from "./ui/input";
157import { Kbd } from "./ui/kbd";
158import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";158import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
159import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";159import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
160import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";160import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
...@@ -1068,6 +1068,106 @@ function latestTurnDiff(...@@ -1068,6 +1068,106 @@ function latestTurnDiff(
1068 return null;1068 return null;
1069}1069}
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
1071export default function SidebarV2() {1171export default function SidebarV2() {
1072 const projects = useProjects();1172 const projects = useProjects();
1073 const projectOrder = useUiStateStore((store) => store.projectOrder);1173 const projectOrder = useUiStateStore((store) => store.projectOrder);
...@@ -1520,6 +1620,33 @@ export default function SidebarV2() {...@@ -1520,6 +1620,33 @@ export default function SidebarV2() {
1520 threads,1620 threads,
1521 ]);1621 ]);
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
1523 // Arm a timeout for the earliest upcoming wake so the shelf empties the1650 // Arm a timeout for the earliest upcoming wake so the shelf empties the
1524 // moment a snooze expires instead of on the next minute tick. Sorted1651 // moment a snooze expires instead of on the next minute tick. Sorted
1525 // soonest-first, so entry 0 is the boundary.1652 // soonest-first, so entry 0 is the boundary.
...@@ -1693,6 +1820,56 @@ export default function SidebarV2() {...@@ -1693,6 +1820,56 @@ export default function SidebarV2() {
1693 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],1820 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
1694 );1821 );
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
1696 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);1873 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
1697 const [renamingTitle, setRenamingTitle] = useState("");1874 const [renamingTitle, setRenamingTitle] = useState("");
1698 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {1875 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
...@@ -2398,7 +2575,6 @@ export default function SidebarV2() {...@@ -2398,7 +2575,6 @@ export default function SidebarV2() {
2398 openCommandPalette({ open: "new-thread-in" });2575 openCommandPalette({ open: "new-thread-in" });
2399 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);2576 }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]);
24002577
2401 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
2402 // Same resolution as v1: prefer the local-thread binding, fall back to2578 // Same resolution as v1: prefer the local-thread binding, fall back to
2403 // chat.new, no platform gating — web users have working shortcuts too.2579 // chat.new, no platform gating — web users have working shortcuts too.
2404 const newThreadShortcutLabel =2580 const newThreadShortcutLabel =
...@@ -2412,25 +2588,51 @@ export default function SidebarV2() {...@@ -2412,25 +2588,51 @@ export default function SidebarV2() {
2412 fixedHeader={2588 fixedHeader={
2413 <SidebarGroup className="gap-1 p-[var(--sidebar-content-inset)]">2589 <SidebarGroup className="gap-1 p-[var(--sidebar-content-inset)]">
2414 <div className="flex items-center gap-1">2590 <div className="flex items-center gap-1">
2415 <div className="min-w-0 flex-1">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">
2416 <CommandDialogTrigger2592 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
2417 render={2593 <Input
2418 <SidebarMenuButton2594 ref={threadSearchInputRef}
2419 type="button"2595 nativeInput
2420 aria-label="Search threads and commands"2596 unstyled
2421 className="focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"2597 type="search"
2422 data-testid="command-palette-trigger"2598 value={threadSearchQuery}
2423 />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
2424 }2613 }
2425 >2614 aria-activedescendant={
2426 <SearchIcon />2615 isSearchingThreads && threadSearchResults[activeSearchResultIndex]
2427 <div className="flex-1 truncate text-left">Search</div>2616 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
2428 {commandPaletteShortcutLabel ? (2617 : undefined
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">2618 }
2430 {commandPaletteShortcutLabel}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"
2431 </Kbd>2620 />
2432 ) : null}2621 {isSearchingThreads ? (
2433 </CommandDialogTrigger>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}
2434 </div>2636 </div>
2435 <div className="shrink-0">2637 <div className="shrink-0">
2436 <Tooltip>2638 <Tooltip>
...@@ -2559,179 +2761,245 @@ export default function SidebarV2() {...@@ -2559,179 +2761,245 @@ export default function SidebarV2() {
2559 }2761 }
2560 >2762 >
2561 <SidebarGroup className="px-[var(--sidebar-content-inset)] pb-1 pt-0">2763 <SidebarGroup className="px-[var(--sidebar-content-inset)] pb-1 pt-0">
2562 <TooltipProvider2764 {isSearchingThreads ? (
2563 key="sidebar-thread-tooltips-150"2765 threadSearchResults.length > 0 ? (
2564 delay={150}2766 <TooltipProvider
2565 closeDelay={0}2767 key="sidebar-thread-search-tooltips-150"
2566 timeout={400}2768 delay={150}
2567 >2769 closeDelay={0}
2568 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">2770 timeout={400}
2569 {(() => {2771 >
2570 const renderThreadRow = (2772 <ul
2571 thread: EnvironmentThreadShell,2773 id="sidebar-thread-search-results"
2572 section: "active" | "snoozed" | "settled",2774 role="listbox"
2573 ) => {2775 aria-label="Thread search results"
2574 const threadKey = scopedThreadKey(2776 className="flex flex-col gap-px"
2575 scopeThreadRef(thread.environmentId, thread.id),2777 >
2576 );2778 {threadSearchResults.map((thread, index) => {
2577 // Settled and snoozed are the ONLY things that collapse a2779 const threadKey = scopedThreadKey(
2578 // row: every other thread is a full card. Density comes2780 scopeThreadRef(thread.environmentId, thread.id),
2579 // from users (or the auto rules) actually parking work,2781 );
2580 // not from the sidebar second-guessing what still matters.2782 return (
2581 const isCard = section === "active";2783 <SidebarV2SearchResultRow
2582 const rowVariant = isCard ? "card" : "slim";2784 key={threadKey}
2583 return (2785 thread={thread}
2584 <SidebarV2Row2786 projectCwd={
2585 // Keyed per variant on purpose: when a thread settles,2787 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
2586 // the card fades out in place and the slim row fades2788 }
2587 // in at its settled position instead of one element2789 projectTitle={
2588 // FLIP-sliding through every row in between (rows here2790 projectDisplayNameByKey.get(
2589 // are translucent, so a crossing row reads as text2791 `${thread.environmentId}:${thread.projectId}`,
2590 // painted over text).2792 ) ?? null
2591 key={`${threadKey}:${rowVariant}`}2793 }
2592 thread={thread}2794 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
2593 variant={rowVariant}2795 providerEntryByInstanceId={providerEntryByInstanceId}
2594 // Snoozed rows wake; settled rows un-settle (explicit2796 isHighlighted={activeSearchResultIndex === index}
2595 // settles clear the override, auto-settled rows get2797 isRouteActive={routeThreadKey === threadKey}
2596 // pinned active); cards settle.2798 resultId={`sidebar-thread-search-result-${index}`}
2597 variantAction={2799 onHighlight={() => setActiveSearchResultIndex(index)}
2598 section === "snoozed"2800 onSelect={() => selectThreadSearchResult(thread)}
2599 ? "unsnooze"2801 />
2600 : section === "settled"2802 );
2601 ? "unsettle"2803 })}
2602 : "settle"2804 </ul>
2603 }2805 </TooltipProvider>
2604 settlementSupported={2806 ) : (
2605 serverConfigs.get(thread.environmentId)?.environment.capabilities2807 <p
2606 .threadSettlement === true2808 role="status"
2607 }2809 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
2608 snoozeSupported={2810 >
2609 serverConfigs.get(thread.environmentId)?.environment.capabilities2811 No threads found
2610 .threadSnooze === true2812 </p>
2611 }2813 )
2612 snoozeWakeLabelText={2814 ) : null}
2613 section === "snoozed" && thread.snoozedUntil != null2815 {!isSearchingThreads ? (
2614 ? snoozeWakeLabel(thread.snoozedUntil, {2816 <TooltipProvider
2615 now: new Date().toISOString(),2817 key="sidebar-thread-tooltips-150"
2616 })2818 delay={150}
2617 : null2819 closeDelay={0}
2618 }2820 timeout={400}
2619 // All sections: a woken thread can classify straight2821 >
2620 // into the settled tail (PR merged while snoozed), and2822 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
2621 // the wake signal must survive the trip. Still-snoozed2823 {(() => {
2622 // rows resolve to null on their own.2824 const renderThreadRow = (
2623 wokeAt={threadWokeAt(thread, { now: snoozeNow })}2825 thread: EnvironmentThreadShell,
2624 isActive={routeThreadKey === threadKey}2826 section: "active" | "snoozed" | "settled",
2625 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}2827 ) => {
2626 currentEnvironmentId={primaryEnvironmentId}2828 const threadKey = scopedThreadKey(
2627 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}2829 scopeThreadRef(thread.environmentId, thread.id),
2628 projectCwd={2830 );
2629 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null2831 // Settled and snoozed are the ONLY things that collapse a
2630 }2832 // row: every other thread is a full card. Density comes
2631 projectTitle={2833 // from users (or the auto rules) actually parking work,
2632 projectDisplayNameByKey.get(2834 // not from the sidebar second-guessing what still matters.
2633 `${thread.environmentId}:${thread.projectId}`,2835 const isCard = section === "active";
2634 ) ?? null2836 const rowVariant = isCard ? "card" : "slim";
2635 }2837 return (
2636 providerEntryByInstanceId={providerEntryByInstanceId}2838 <SidebarV2Row
2637 onThreadClick={handleThreadClick}2839 // Keyed per variant on purpose: when a thread settles,
2638 onThreadActivate={navigateToThread}2840 // the card fades out in place and the slim row fades
2639 onStartRename={startThreadRename}2841 // in at its settled position instead of one element
2640 onRenameTitleChange={setRenamingTitle}2842 // FLIP-sliding through every row in between (rows here
2641 onCommitRename={commitThreadRename}2843 // are translucent, so a crossing row reads as text
2642 onCancelRename={cancelThreadRename}2844 // painted over text).
2643 isRenaming={renamingThreadKey === threadKey}2845 key={`${threadKey}:${rowVariant}`}
2644 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}2846 thread={thread}
2645 onContextMenu={handleThreadContextMenu}2847 variant={rowVariant}
2646 onSettle={attemptSettle}2848 // Snoozed rows wake; settled rows un-settle (explicit
2647 onUnsettle={attemptUnsettle}2849 // settles clear the override, auto-settled rows get
2648 onSnooze={attemptSnooze}2850 // pinned active); cards settle.
2649 onUnsnooze={attemptUnsnooze}2851 variantAction={
2650 onChangeRequestState={handleChangeRequestState}2852 section === "snoozed"
2651 />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"),
2652 );2910 );
2653 };2911 // Snoozed shelf: between the inbox and Settled — out of the
2654 const items: ReactNode[] = activeThreads.map((thread) =>2912 // way, never gone. The header always renders while anything
2655 renderThreadRow(thread, "active"),2913 // is snoozed (the count is the whole footprint when
2656 );2914 // collapsed); rows only when expanded. Vanishes entirely at
2657 // Snoozed shelf: between the inbox and Settled — out of the2915 // count 0.
2658 // way, never gone. The header always renders while anything2916 if (snoozedThreads.length > 0) {
2659 // is snoozed (the count is the whole footprint when2917 items.push(
2660 // collapsed); rows only when expanded. Vanishes entirely at2918 <li
2661 // count 0.2919 key="snoozed-shelf-header"
2662 if (snoozedThreads.length > 0) {2920 data-thread-selection-safe
2663 items.push(2921 className="list-none"
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"
2671 >2922 >
2672 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">2923 <button
2673 {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`}2924 type="button"
2674 </span>2925 onClick={toggleSnoozedShelf}
2675 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />2926 aria-expanded={snoozedShelfExpanded}
2676 <ChevronDownIcon2927 data-testid="sidebar-v2-snoozed-shelf-toggle"
2677 aria-hidden2928 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2678 className={cn(2929 >
2679 "size-3 text-blue-600 transition-transform dark:text-blue-400",2930 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
2680 snoozedShelfExpanded && "rotate-180",2931 {snoozedShelfExpanded
2681 )}2932 ? "Snoozed"
2682 />2933 : `Snoozed (${snoozedThreads.length})`}
2683 </button>2934 </span>
2684 </li>,2935 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
2685 );2936 <ChevronDownIcon
2686 for (const thread of visibleSnoozedThreads) {2937 aria-hidden
2687 items.push(renderThreadRow(thread, "snoozed"));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 }
2688 }2949 }
2689 }2950 if (settledThreads.length > 0) {
2690 if (settledThreads.length > 0) {2951 items.push(
2691 items.push(2952 <li
2692 <li key="settled-shelf-header" data-thread-selection-safe className="list-none">2953 key="settled-shelf-header"
2693 <button2954 data-thread-selection-safe
2694 type="button"2955 className="list-none"
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"
2699 >2956 >
2700 <span className="text-xs font-medium text-muted-foreground/50">2957 <button
2701 {settledShelfExpanded ? "Settled" : `Settled (${settledThreads.length})`}2958 type="button"
2702 </span>2959 onClick={toggleSettledShelf}
2703 <span className="h-px flex-1 bg-sidebar-border/60" />2960 aria-expanded={settledShelfExpanded}
2704 <ChevronDownIcon2961 data-testid="sidebar-v2-settled-shelf-toggle"
2705 aria-hidden2962 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
2706 className={cn(2963 >
2707 "size-3 text-muted-foreground/50 transition-transform",2964 <span className="text-xs font-medium text-muted-foreground/50">
2708 settledShelfExpanded && "rotate-180",2965 {settledShelfExpanded
2709 )}2966 ? "Settled"
2710 />2967 : `Settled (${settledThreads.length})`}
2711 </button>2968 </span>
2712 </li>,2969 <span className="h-px flex-1 bg-sidebar-border/60" />
2713 );2970 <ChevronDownIcon
2714 }2971 aria-hidden
2715 for (const thread of renderedSettledThreads) {2972 className={cn(
2716 items.push(renderThreadRow(thread, "settled"));2973 "size-3 text-muted-foreground/50 transition-transform",
2717 }2974 settledShelfExpanded && "rotate-180",
2718 return items;2975 )}
2719 })()}2976 />
2720 {settledShelfExpanded && hiddenSettledCount > 0 ? (2977 </button>
2721 <li className="list-none">2978 </li>,
2722 <button2979 );
2723 type="button"2980 }
2724 onClick={showMoreSettled}2981 for (const thread of renderedSettledThreads) {
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"2982 items.push(renderThreadRow(thread, "settled"));
2726 >2983 }
2727 <PlusIcon aria-hidden className="size-4 shrink-0" />2984 return items;
2728 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more2985 })()}
2729 </button>2986 {settledShelfExpanded && hiddenSettledCount > 0 ? (
2730 </li>2987 <li className="list-none">
2731 ) : null}2988 <button
2732 </ul>2989 type="button"
2733 </TooltipProvider>2990 onClick={showMoreSettled}
2734 {activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? (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 ? (
2735 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">3003 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
2736 {projects.length === 0 ? (3004 {projects.length === 0 ? (
2737 <>3005 <>