| ... | @@ -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"; |
| 39 | import { | 40 | import { |
| 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 |
| 142 | import { primaryServerProvidersAtom } from "../state/server"; | 144 | import { primaryServerProvidersAtom } from "../state/server"; |
| 143 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; | 145 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 144 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 146 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 145 | import { CommandDialogTrigger } from "./ui/command"; | | |
| 146 | import { Button } from "./ui/button"; | 147 | import { Button } from "./ui/button"; |
| 147 | import { | 148 | import { |
| 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"; |
| 156 | import { Input } from "./ui/input"; | 157 | import { Input } from "./ui/input"; |
| 157 | import { Kbd } from "./ui/kbd"; | | |
| 158 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; | 158 | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; |
| 159 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 159 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; |
| 160 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; | 160 | import { 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 | } |
| 1070 | | 1070 | |
| | 1071 | const 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 | |
| 1071 | export default function SidebarV2() { | 1171 | export 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 | ]); |
| 1522 | | 1622 | |
| | 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 the | 1650 | // 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. Sorted | 1651 | // 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 | ); |
| 1695 | | 1822 | |
| | 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]); |
| 2400 | | 2577 | |
| 2401 | const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); | | |
| 2402 | // Same resolution as v1: prefer the local-thread binding, fall back to | 2578 | // 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 | <CommandDialogTrigger | 2592 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> |
| 2417 | render={ | 2593 | <Input |
| 2418 | <SidebarMenuButton | 2594 | 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 | <TooltipProvider | 2764 | {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 a | 2779 | const threadKey = scopedThreadKey( |
| 2578 | // row: every other thread is a full card. Density comes | 2780 | 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 | <SidebarV2Row | 2786 | 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 fades | 2788 | } |
| 2587 | // in at its settled position instead of one element | 2789 | projectTitle={ |
| 2588 | // FLIP-sliding through every row in between (rows here | 2790 | projectDisplayNameByKey.get( |
| 2589 | // are translucent, so a crossing row reads as text | 2791 | `${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 (explicit | 2796 | isHighlighted={activeSearchResultIndex === index} |
| 2595 | // settles clear the override, auto-settled rows get | 2797 | 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.capabilities | 2807 | <p |
| 2606 | .threadSettlement === true | 2808 | 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.capabilities | 2811 | No threads found |
| 2610 | .threadSnooze === true | 2812 | </p> |
| 2611 | } | 2813 | ) |
| 2612 | snoozeWakeLabelText={ | 2814 | ) : null} |
| 2613 | section === "snoozed" && thread.snoozedUntil != null | 2815 | {!isSearchingThreads ? ( |
| 2614 | ? snoozeWakeLabel(thread.snoozedUntil, { | 2816 | <TooltipProvider |
| 2615 | now: new Date().toISOString(), | 2817 | key="sidebar-thread-tooltips-150" |
| 2616 | }) | 2818 | delay={150} |
| 2617 | : null | 2819 | closeDelay={0} |
| 2618 | } | 2820 | timeout={400} |
| 2619 | // All sections: a woken thread can classify straight | 2821 | > |
| 2620 | // into the settled tail (PR merged while snoozed), and | 2822 | <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px"> |
| 2621 | // the wake signal must survive the trip. Still-snoozed | 2823 | {(() => { |
| 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}`) ?? null | 2831 | // 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 | ) ?? null | 2836 | 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 the | 2915 | // count 0. |
| 2658 | // way, never gone. The header always renders while anything | 2916 | if (snoozedThreads.length > 0) { |
| 2659 | // is snoozed (the count is the whole footprint when | 2917 | items.push( |
| 2660 | // collapsed); rows only when expanded. Vanishes entirely at | 2918 | <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 | <ChevronDownIcon | 2927 | data-testid="sidebar-v2-snoozed-shelf-toggle" |
| 2677 | aria-hidden | 2928 | 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 | <button | 2954 | 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 | <ChevronDownIcon | 2961 | data-testid="sidebar-v2-settled-shelf-toggle" |
| 2705 | aria-hidden | 2962 | 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 | <button | 2979 | ); |
| 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)} more | 2985 | })()} |
| 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 | <> |