diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7aa5be8f13c9c..0ae3a75da3c66 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -288,6 +288,7 @@ import { SidebarTabsMenu, } from "./sidebar/SidebarTabsMenu"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; +import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "./ui/preview-card"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { @@ -2388,15 +2389,116 @@ function SidebarTabList(props: { ); } +/** + * The tabs folded behind a group's collapsed "n more" row, in the same order and with the same + * title, status, time, and provider as the list they would join. Clicking one opens it. + */ +function SidebarTabOverflowPreview(props: { + hidden: readonly SidebarThreadSummary[]; + providerEntriesByEnvironment: ReadonlyMap>; + tabSortOrder: SidebarTabSortOrder; + openedAtByThreadKey: Readonly>; + onOpenTab: (threadRef: ScopedThreadRef) => void; +}) { + const lastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + return ( + + ); +} + /** * Stands in for a group's tabs past the limit. It names what it hides that needs you, and the - * providers behind them, so folding tabs away never hides work in progress. + * providers behind them, so folding tabs away never hides work in progress. Hovering the + * collapsed row lists those tabs. */ function SidebarTabOverflowRow(props: { hidden: readonly SidebarThreadSummary[]; expanded: boolean; providerEntriesByEnvironment: ReadonlyMap>; + tabSortOrder: SidebarTabSortOrder; + openedAtByThreadKey: Readonly>; onToggle: () => void; + onOpenTab: (threadRef: ScopedThreadRef) => void; + onPreviewOpenChange?: ((open: boolean) => void) | undefined; }) { const { hidden, providerEntriesByEnvironment } = props; const summary = useMemo(() => { @@ -2430,46 +2532,112 @@ function SidebarTabOverflowRow(props: { }; }, [hidden, providerEntriesByEnvironment]); const label = props.expanded ? "Show less" : `${hidden.length} more`; + const onPreviewOpenChangeRef = useRef(props.onPreviewOpenChange); + onPreviewOpenChangeRef.current = props.onPreviewOpenChange; + const previewOpenRef = useRef(false); + // The card unmounts when the row expands or the group leaves the list. Release the sort hold + // if that happens while the preview is open and the pointer is no longer over the list. + useEffect(() => { + if (props.expanded && previewOpenRef.current) { + previewOpenRef.current = false; + onPreviewOpenChangeRef.current?.(false); + } + }, [props.expanded]); + useEffect( + () => () => { + if (!previewOpenRef.current) return; + previewOpenRef.current = false; + onPreviewOpenChangeRef.current?.(false); + }, + [], + ); + const buttonClassName = + "flex h-7 w-full cursor-pointer items-center gap-1.5 rounded-md px-2 text-left text-secondary-label text-xs outline-none hover:bg-sidebar-row-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; + const buttonBody = ( + <> + {props.expanded ? ( + + ) : ( + + )} + {label} + {!props.expanded && summary.topStatus ? ( + + + {summary.statusCount} {summary.topStatus.label.toLowerCase()} + + ) : null} + {!props.expanded && summary.providers.length > 0 ? ( + + {summary.providers.map((provider) => ( + + ))} + + ) : null} + + ); + const showPreview = !props.expanded && hidden.length > 0; return (
  • - + } + > + {buttonBody} + + + + + ) : ( + + )}
  • ); } @@ -5424,8 +5592,14 @@ export default function Sidebar() { }, []); const tabSortOrderRef = useRef(tabSortOrder); tabSortOrderRef.current = tabSortOrder; - const handleTabPointerRest = useCallback((rowKey: string, resting: boolean) => { - if (!resting) { + // The list's pointer and the portaled "n more" preview both count as resting, so a live + // sort does not reshuffle the tabs while you are reading or clicking the preview. + const tabListPointerRowRef = useRef(null); + const tabOverflowPreviewRowRef = useRef(null); + const syncTabOrderHold = useCallback((rowKey: string) => { + const holding = + tabListPointerRowRef.current === rowKey || tabOverflowPreviewRowRef.current === rowKey; + if (!holding) { setHeldTabOrder((held) => (held?.rowKey === rowKey ? null : held)); return; } @@ -5434,6 +5608,24 @@ export default function Sidebar() { const ordered = tabLayoutByRowKeyRef.current.get(rowKey)?.ordered; if (ordered) setHeldTabOrder({ rowKey, keys: ordered.map(sidebarThreadKey) }); }, []); + const handleTabPointerRest = useCallback( + (rowKey: string, resting: boolean) => { + if (resting) tabListPointerRowRef.current = rowKey; + else if (tabListPointerRowRef.current === rowKey) tabListPointerRowRef.current = null; + syncTabOrderHold(rowKey); + }, + [syncTabOrderHold], + ); + const handleTabOverflowPreview = useCallback( + (rowKey: string, open: boolean) => { + const previous = tabOverflowPreviewRowRef.current; + if (open) tabOverflowPreviewRowRef.current = rowKey; + else if (previous === rowKey) tabOverflowPreviewRowRef.current = null; + if (previous !== null && previous !== rowKey) syncTabOrderHold(previous); + syncTabOrderHold(rowKey); + }, + [syncTabOrderHold], + ); const threadsRef = useRef(threads); threadsRef.current = threads; // A drop in a timed order switches to Manual, starting from the order you were looking at. @@ -6119,7 +6311,13 @@ export default function Sidebar() { hidden={rowTabLayout.hidden} expanded={rowTabLayout.expanded} providerEntriesByEnvironment={providerEntriesByEnvironment} + tabSortOrder={tabSortOrder} + openedAtByThreadKey={openedAtByThreadKey} onToggle={() => toggleTabOverflow(threadKey)} + onOpenTab={navigateToThread} + onPreviewOpenChange={(open) => + handleTabOverflowPreview(threadKey, open) + } /> ) : null} diff --git a/docs/fork-differences.md b/docs/fork-differences.md index be267cfbb0714..b87c01b61fa06 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -189,7 +189,8 @@ is its own conversation and provider. `sidebar/SidebarTabsMenu.tsx`), the command palette, and `Cmd+Option+T` on macOS or `Ctrl+Alt+T` on Windows and Linux list each tab under that row: all of them, or up to a chosen number with the rest behind a **more** row that keeps the open tab - listed. **Sort tabs by** orders them by latest response, creation, or last opened, either way, or + listed. Hovering the collapsed row lists those tabs, and clicking one opens it. **Sort tabs by** + orders them by latest response, creation, or last opened, either way, or manually by dragging, which switches to Manual. Manual order is client-local, because the server's tab positions also pick the group's row. The row's tab count opens or folds just that group; those choices stay in the browser and reset when the setting changes. The row's hover diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f4530fd9ff3a2..96730c076ea81 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -32,7 +32,8 @@ On web and desktop, press `Cmd+Option+T` on macOS or `Ctrl+Alt+T` on Windows and each tab under its chat instead, with its own status, provider, and time. Clicking the tabs button in the sidebar header does the same. Right-click it for a menu that hides tabs, lists every tab, or lists up to a number of them per chat. Past that number the rest fold behind a **more** row, which also shows when a hidden tab is -working or needs you. Expand it to see them all; it folds again when you open a tab or thread. The +working or needs you. Hover the collapsed row to see those tabs, and click one to open it. Expand +the row to list them in the sidebar; it folds again when you open a tab or thread. The tab you have open always stays listed. Click a listed tab to switch to it. The same shortcut switches between hiding tabs and your last choice, and the command palette and **Settings → General → Tabs in sidebar** set it too.