| author | |
| committer | |
| log | ca2cc1339bbd1904b932c3a92d23a2c0ffe1d267 |
| tree | 315a8d16a8b0caf7c0895d36331168d16ab84fd9 |
| parent | e6286839369a29d829aee3c64dd64ce39c626d2f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>15 files changed, 597 insertions(+), 110 deletions(-)
apps/web/src/components/AppSidebarLayout.tsx+7-2| ... | ... | @@ -14,7 +14,11 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt |
| 14 | 14 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 15 | 15 | import { cn, isMacPlatform } from "../lib/utils"; |
| 16 | 16 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; | |
| 17 | import { | |
| 18 | useCompactSidebarEnabled, | |
| 19 | useEnvironmentIdentificationMode, | |
| 20 | useLegacySidebarEnabled, | |
| 21 | } from "../hooks/useSettings"; | |
| 18 | 22 | import { |
| 19 | 23 | PanelAnimationSuppressionProvider, |
| 20 | 24 | usePanelAnimationSettings, |
| ... | ... | @@ -144,6 +148,7 @@ function ProjectProjectionRetention() { |
| 144 | 148 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 145 | 149 | const navigate = useNavigate(); |
| 146 | 150 | const legacySidebarEnabled = useLegacySidebarEnabled(); |
| 151 | const compactSidebarEnabled = useCompactSidebarEnabled(); | |
| 147 | 152 | const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = |
| 148 | 153 | usePanelAnimationSettings(); |
| 149 | 154 | // Settings routes show the settings nav in place of whichever thread |
| ... | ... | @@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 229 | 234 | <ProjectProjectionRetention /> |
| 230 | 235 | <Sidebar |
| 231 | 236 | side="left" |
| 232 | collapsible="offcanvas" | |
| 237 | collapsible={compactSidebarEnabled ? "icon" : "offcanvas"} | |
| 233 | 238 | data-app-sidebar="" |
| 234 | 239 | className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" |
| 235 | 240 | resizable={{ |
apps/web/src/components/LegacySidebar.tsx+52-25| ... | ... | @@ -199,7 +199,11 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom |
| 199 | 199 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 200 | 200 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 201 | 201 | import { CommandDialogTrigger } from "./ui/command"; |
| 202 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; | |
| 202 | import { | |
| 203 | useClientSettings, | |
| 204 | useCompactSidebarEnabled, | |
| 205 | useUpdateClientSettings, | |
| 206 | } from "~/hooks/useSettings"; | |
| 203 | 207 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 204 | 208 | import { |
| 205 | 209 | derivePhysicalProjectKey, |
| ... | ... | @@ -1194,7 +1198,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1194 | 1198 | const router = useRouter(); |
| 1195 | 1199 | const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); |
| 1196 | 1200 | const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); |
| 1197 | const { isMobile, setOpenMobile } = useSidebar(); | |
| 1201 | const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); | |
| 1202 | const compactSidebarEnabled = useCompactSidebarEnabled(); | |
| 1203 | const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; | |
| 1198 | 1204 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); |
| 1199 | 1205 | const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); |
| 1200 | 1206 | const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); |
| ... | ... | @@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1445 | 1451 | if (useThreadSelectionStore.getState().hasSelection()) { |
| 1446 | 1452 | clearSelection(); |
| 1447 | 1453 | } |
| 1448 | setProjectExpanded(projectPreferenceKeys, !projectExpanded); | |
| 1454 | setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); | |
| 1455 | if (isCompact) setOpen(true); | |
| 1449 | 1456 | }, |
| 1450 | 1457 | [ |
| 1451 | 1458 | clearSelection, |
| 1459 | isCompact, | |
| 1460 | setOpen, | |
| 1452 | 1461 | dragInProgressRef, |
| 1453 | 1462 | projectExpanded, |
| 1454 | 1463 | projectPreferenceKeys, |
| ... | ... | @@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1465 | 1474 | if (dragInProgressRef.current) { |
| 1466 | 1475 | return; |
| 1467 | 1476 | } |
| 1468 | setProjectExpanded(projectPreferenceKeys, !projectExpanded); | |
| 1477 | setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); | |
| 1478 | if (isCompact) setOpen(true); | |
| 1469 | 1479 | }, |
| 1470 | [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], | |
| 1480 | [ | |
| 1481 | dragInProgressRef, | |
| 1482 | isCompact, | |
| 1483 | projectExpanded, | |
| 1484 | projectPreferenceKeys, | |
| 1485 | setOpen, | |
| 1486 | setProjectExpanded, | |
| 1487 | ], | |
| 1471 | 1488 | ); |
| 1472 | 1489 | |
| 1473 | 1490 | const handleProjectButtonPointerDownCapture = useCallback( |
| ... | ... | @@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2359 | 2376 | <> |
| 2360 | 2377 | <div className="group/project-header relative"> |
| 2361 | 2378 | <SidebarMenuButton |
| 2379 | tooltip={project.displayName} | |
| 2380 | aria-label={project.displayName} | |
| 2362 | 2381 | ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined} |
| 2363 | className={`pr-8 group-hover/project-header:bg-sidebar-row-hover group-hover/project-header:text-sidebar-foreground max-sm:pr-14 ${ | |
| 2382 | className={`pr-8 group-hover/project-header:bg-sidebar-row-hover group-hover/project-header:text-sidebar-foreground max-sm:pr-14 group-data-[collapsible=icon]:justify-center ${ | |
| 2364 | 2383 | isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "" |
| 2365 | 2384 | }`} |
| 2366 | 2385 | {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})} |
| ... | ... | @@ -2370,7 +2389,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2370 | 2389 | onKeyDown={handleProjectButtonKeyDown} |
| 2371 | 2390 | onContextMenu={handleProjectButtonContextMenu} |
| 2372 | 2391 | > |
| 2373 | {!projectExpanded && projectStatus ? ( | |
| 2392 | {isCompact ? null : !projectExpanded && projectStatus ? ( | |
| 2374 | 2393 | <Tooltip> |
| 2375 | 2394 | <TooltipTrigger |
| 2376 | 2395 | render={ |
| ... | ... | @@ -2401,7 +2420,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2401 | 2420 | <span className="flex shrink-0"> |
| 2402 | 2421 | <ProjectFavicon project={project} /> |
| 2403 | 2422 | </span> |
| 2404 | <span className="flex min-w-0 flex-1 items-center gap-2"> | |
| 2423 | <span className="flex min-w-0 flex-1 items-center gap-2 group-data-[collapsible=icon]:hidden"> | |
| 2405 | 2424 | <span className="truncate text-sm font-medium text-sidebar-foreground/90"> |
| 2406 | 2425 | {project.displayName} |
| 2407 | 2426 | </span> |
| ... | ... | @@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2415 | 2434 | {/* Environment badge – visible by default, crossfades with the |
| 2416 | 2435 | "new thread" button on hover using the same pointer-events + |
| 2417 | 2436 | opacity pattern as the thread row archive/timestamp swap. */} |
| 2418 | {project.environmentPresence === "remote-only" && ( | |
| 2437 | {!isCompact && project.environmentPresence === "remote-only" && ( | |
| 2419 | 2438 | <Tooltip> |
| 2420 | 2439 | <TooltipTrigger |
| 2421 | 2440 | render={ |
| ... | ... | @@ -2441,7 +2460,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2441 | 2460 | <Tooltip> |
| 2442 | 2461 | <TooltipTrigger |
| 2443 | 2462 | render={ |
| 2444 | <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> | |
| 2463 | <div className="pointer-events-none absolute top-[calc(50%+1px)] right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-data-[collapsible=icon]:hidden"> | |
| 2445 | 2464 | <button |
| 2446 | 2465 | type="button" |
| 2447 | 2466 | aria-label={`Create new thread in ${project.displayName}`} |
| ... | ... | @@ -2674,7 +2693,7 @@ function LocalSecondaryStatus() { |
| 2674 | 2693 | } |
| 2675 | 2694 | |
| 2676 | 2695 | return ( |
| 2677 | <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 2696 | <SidebarGroup className="px-2 pt-2 pb-0 group-data-[collapsible=icon]:hidden"> | |
| 2678 | 2697 | {connecting.length > 0 ? ( |
| 2679 | 2698 | <Alert |
| 2680 | 2699 | variant="default" |
| ... | ... | @@ -2978,14 +2997,16 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2978 | 2997 | render={ |
| 2979 | 2998 | <SidebarMenuButton |
| 2980 | 2999 | className="focus-visible:ring-0" |
| 3000 | tooltip="Search" | |
| 3001 | aria-label="Search" | |
| 2981 | 3002 | data-testid="command-palette-trigger" |
| 2982 | 3003 | /> |
| 2983 | 3004 | } |
| 2984 | 3005 | > |
| 2985 | 3006 | <SearchIcon /> |
| 2986 | <span className="flex-1 truncate">Search</span> | |
| 3007 | <span className="flex-1 truncate group-data-[collapsible=icon]:hidden">Search</span> | |
| 2987 | 3008 | {commandPaletteShortcutLabel ? ( |
| 2988 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]"> | |
| 3009 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px] group-data-[collapsible=icon]:hidden"> | |
| 2989 | 3010 | {commandPaletteShortcutLabel} |
| 2990 | 3011 | </Kbd> |
| 2991 | 3012 | ) : null} |
| ... | ... | @@ -2996,7 +3017,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2996 | 3017 | } |
| 2997 | 3018 | > |
| 2998 | 3019 | {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( |
| 2999 | <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 3020 | <SidebarGroup className="px-2 pt-2 pb-0 group-data-[collapsible=icon]:hidden"> | |
| 3000 | 3021 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> |
| 3001 | 3022 | <TriangleAlertIcon /> |
| 3002 | 3023 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> |
| ... | ... | @@ -3020,17 +3041,21 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 3020 | 3041 | ) : null} |
| 3021 | 3042 | <LocalSecondaryStatus /> |
| 3022 | 3043 | <SidebarGroup className="px-2 py-2"> |
| 3023 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | |
| 3024 | <span className="text-xs font-medium text-sidebar-muted-foreground/80">Projects</span> | |
| 3044 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"> | |
| 3045 | <span className="text-xs font-medium text-sidebar-muted-foreground/80 group-data-[collapsible=icon]:hidden"> | |
| 3046 | Projects | |
| 3047 | </span> | |
| 3025 | 3048 | <div className="flex items-center gap-1"> |
| 3026 | <ProjectSortMenu | |
| 3027 | projectSortOrder={projectSortOrder} | |
| 3028 | threadSortOrder={threadSortOrder} | |
| 3029 | threadPreviewCount={threadPreviewCount} | |
| 3030 | onProjectSortOrderChange={handleProjectSortOrderChange} | |
| 3031 | onThreadSortOrderChange={handleThreadSortOrderChange} | |
| 3032 | onThreadPreviewCountChange={handleThreadPreviewCountChange} | |
| 3033 | /> | |
| 3049 | <div className="group-data-[collapsible=icon]:hidden"> | |
| 3050 | <ProjectSortMenu | |
| 3051 | projectSortOrder={projectSortOrder} | |
| 3052 | threadSortOrder={threadSortOrder} | |
| 3053 | threadPreviewCount={threadPreviewCount} | |
| 3054 | onProjectSortOrderChange={handleProjectSortOrderChange} | |
| 3055 | onThreadSortOrderChange={handleThreadSortOrderChange} | |
| 3056 | onThreadPreviewCountChange={handleThreadPreviewCountChange} | |
| 3057 | /> | |
| 3058 | </div> | |
| 3034 | 3059 | <Tooltip> |
| 3035 | 3060 | <TooltipTrigger |
| 3036 | 3061 | render={ |
| ... | ... | @@ -3127,7 +3152,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 3127 | 3152 | )} |
| 3128 | 3153 | |
| 3129 | 3154 | {projectsLength === 0 && ( |
| 3130 | <div className="px-2 pt-4 text-center text-secondary-label text-xs">No projects yet</div> | |
| 3155 | <div className="px-2 pt-4 text-center text-secondary-label text-xs group-data-[collapsible=icon]:hidden"> | |
| 3156 | No projects yet | |
| 3157 | </div> | |
| 3131 | 3158 | )} |
| 3132 | 3159 | </SidebarGroup> |
| 3133 | 3160 | </SidebarContent> |
apps/web/src/components/Sidebar.drag.ts+9-5| ... | ... | @@ -103,6 +103,7 @@ export function createSidebarSortingStrategy(input: { |
| 103 | 103 | snoozedThreadCount?: number; |
| 104 | 104 | cardHeight?: number; |
| 105 | 105 | slimHeight?: number; |
| 106 | compact?: boolean; | |
| 106 | 107 | /** Space each pinned boundary opens for its label while dragging. The |
| 107 | 108 | * markers stay zero height at rest, so nothing is reserved until pickup. */ |
| 108 | 109 | boundaryLabelHeight?: number; |
| ... | ... | @@ -140,11 +141,14 @@ export function createSidebarSortingStrategy(input: { |
| 140 | 141 | else slimHeight ??= rects[index]?.height; |
| 141 | 142 | if (item.key !== active.key) groups[item.section].push(item); |
| 142 | 143 | } |
| 143 | // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. | |
| 144 | const scale = | |
| 145 | slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); | |
| 146 | cardHeight ??= 82 * scale; | |
| 147 | slimHeight ??= 36 * scale; | |
| 144 | // Compact icons use h-7; expanded cards include their vertical padding. | |
| 145 | const scale = input.compact | |
| 146 | ? (cardHeight ?? slimHeight ?? 28) / 28 | |
| 147 | : slimHeight !== undefined | |
| 148 | ? slimHeight / 36 | |
| 149 | : (headerScale ?? (cardHeight ?? 82) / 82); | |
| 150 | cardHeight ??= (input.compact ? 28 : 82) * scale; | |
| 151 | slimHeight ??= (input.compact ? 28 : 36) * scale; | |
| 148 | 152 | const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; |
| 149 | 153 | const group = groups[target.section]; |
| 150 | 154 | const order = |
apps/web/src/components/Sidebar.tsx+368-59| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 2 | import { useCompactSidebarEnabled } from "../hooks/useSettings"; | |
| 2 | 3 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| 3 | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 5 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| 5 | 6 | import * as Schema from "effect/Schema"; |
| 6 | 7 | import { |
| 7 | 8 | DndContext, |
| 9 | DragOverlay, | |
| 8 | 10 | useSensor, |
| 9 | 11 | useSensors, |
| 10 | 12 | type DragEndEvent, |
| ... | ... | @@ -72,6 +74,7 @@ import { |
| 72 | 74 | type MouseEvent as ReactMouseEvent, |
| 73 | 75 | type ReactNode, |
| 74 | 76 | } from "react"; |
| 77 | import { createPortal } from "react-dom"; | |
| 75 | 78 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 76 | 79 | |
| 77 | 80 | import { useRightPanelStore } from "../rightPanelStore"; |
| ... | ... | @@ -321,6 +324,7 @@ function SidebarThreadTooltip({ |
| 321 | 324 | branchMismatch, |
| 322 | 325 | terminalStatus, |
| 323 | 326 | terminalProcessCount, |
| 327 | compactStatus, | |
| 324 | 328 | }: { |
| 325 | 329 | thread: SidebarThreadSummary; |
| 326 | 330 | project: ProjectFaviconProject | null; |
| ... | ... | @@ -337,6 +341,7 @@ function SidebarThreadTooltip({ |
| 337 | 341 | } | null; |
| 338 | 342 | terminalStatus: TerminalStatusIndicator | null; |
| 339 | 343 | terminalProcessCount: number; |
| 344 | compactStatus?: string | undefined; | |
| 340 | 345 | }) { |
| 341 | 346 | const driverKind = providerEntry?.driverKind ?? null; |
| 342 | 347 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); |
| ... | ... | @@ -353,6 +358,7 @@ function SidebarThreadTooltip({ |
| 353 | 358 | {thread.title} |
| 354 | 359 | </div> |
| 355 | 360 | <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground"> |
| 361 | {compactStatus ? <div>{compactStatus}</div> : null} | |
| 356 | 362 | {projectDisplayName ? ( |
| 357 | 363 | <div className="flex min-w-0 items-center gap-2"> |
| 358 | 364 | {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null} |
| ... | ... | @@ -502,7 +508,7 @@ function SnoozePopoverButton(props: { |
| 502 | 508 | type SortableThreadRowBag = Pick< |
| 503 | 509 | ReturnType<typeof useSortable>, |
| 504 | 510 | "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" |
| 505 | >; | |
| 511 | > & { hidden?: boolean }; | |
| 506 | 512 | |
| 507 | 513 | function SortableThreadRow(props: { |
| 508 | 514 | id: string; |
| ... | ... | @@ -583,7 +589,18 @@ function SidebarSectionPlaceholder(props: { |
| 583 | 589 | props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", |
| 584 | 590 | )} |
| 585 | 591 | > |
| 586 | {props.label} | |
| 592 | <span className="group-data-[collapsible=icon]:sr-only">{props.label}</span> | |
| 593 | {props.marker === "settled-placeholder" ? ( | |
| 594 | <CheckIcon | |
| 595 | aria-hidden | |
| 596 | className="hidden size-3.5 group-data-[collapsible=icon]:block" | |
| 597 | /> | |
| 598 | ) : ( | |
| 599 | <Undo2Icon | |
| 600 | aria-hidden | |
| 601 | className="hidden size-3.5 group-data-[collapsible=icon]:block" | |
| 602 | /> | |
| 603 | )} | |
| 587 | 604 | </div> |
| 588 | 605 | ) : null} |
| 589 | 606 | </SortableSidebarMarker> |
| ... | ... | @@ -607,19 +624,30 @@ function SidebarDragBoundary(props: { |
| 607 | 624 | className="pointer-events-none relative mx-0.5 -mb-px h-0" |
| 608 | 625 | > |
| 609 | 626 | {props.visible ? ( |
| 610 | <div className="sidebar-drag-boundary-label absolute inset-x-2 top-1 flex h-4 items-center gap-2"> | |
| 627 | <div className="sidebar-drag-boundary-label absolute inset-x-2 top-1 flex h-4 items-center gap-2 group-data-[collapsible=icon]:inset-x-0 group-data-[collapsible=icon]:justify-center"> | |
| 611 | 628 | <span |
| 612 | 629 | className={cn( |
| 613 | 630 | "shrink-0 text-xs font-medium", |
| 614 | 631 | props.isDropTarget ? "text-primary" : "text-sidebar-foreground/80", |
| 615 | 632 | )} |
| 616 | 633 | > |
| 617 | {props.label} | |
| 634 | <span className="group-data-[collapsible=icon]:sr-only">{props.label}</span> | |
| 635 | {props.marker === "pinned-header" ? ( | |
| 636 | <PinIcon | |
| 637 | aria-hidden | |
| 638 | className="hidden size-3.5 group-data-[collapsible=icon]:block" | |
| 639 | /> | |
| 640 | ) : ( | |
| 641 | <Undo2Icon | |
| 642 | aria-hidden | |
| 643 | className="hidden size-3.5 group-data-[collapsible=icon]:block" | |
| 644 | /> | |
| 645 | )} | |
| 618 | 646 | </span> |
| 619 | 647 | <span |
| 620 | 648 | aria-hidden |
| 621 | 649 | className={cn( |
| 622 | "h-px flex-1", | |
| 650 | "h-px flex-1 group-data-[collapsible=icon]:hidden", | |
| 623 | 651 | props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25", |
| 624 | 652 | )} |
| 625 | 653 | /> |
| ... | ... | @@ -639,20 +667,32 @@ function SidebarSectionHeader(props: { |
| 639 | 667 | isDropTarget?: boolean; |
| 640 | 668 | toggle: { expanded: boolean; onToggle: () => void }; |
| 641 | 669 | }) { |
| 670 | const compactEnabled = useCompactSidebarEnabled(); | |
| 671 | const { state, isMobile } = useSidebar(); | |
| 672 | const compact = compactEnabled && state === "collapsed" && !isMobile; | |
| 642 | 673 | const snoozed = props.marker === "snoozed-header"; |
| 643 | 674 | const className = cn( |
| 644 | 675 | "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium", |
| 676 | compact && "justify-center px-0", | |
| 645 | 677 | snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60", |
| 646 | 678 | props.dragging && "text-sidebar-foreground/80", |
| 647 | 679 | props.isDropTarget && "text-primary", |
| 648 | 680 | ); |
| 649 | 681 | const content = ( |
| 650 | 682 | <> |
| 651 | <span className="shrink-0">{props.label}</span> | |
| 683 | <span className={compact ? "sr-only" : "shrink-0"}>{props.label}</span> | |
| 684 | {compact ? ( | |
| 685 | snoozed ? ( | |
| 686 | <AlarmClockIcon aria-hidden className="size-3.5" /> | |
| 687 | ) : ( | |
| 688 | <CheckIcon aria-hidden className="size-3.5" /> | |
| 689 | ) | |
| 690 | ) : null} | |
| 652 | 691 | <span |
| 653 | 692 | aria-hidden |
| 654 | 693 | className={cn( |
| 655 | 694 | "h-px min-w-2 flex-1", |
| 695 | compact && "hidden", | |
| 656 | 696 | snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60", |
| 657 | 697 | props.dragging && "bg-sidebar-foreground/25", |
| 658 | 698 | props.isDropTarget && "bg-primary/50", |
| ... | ... | @@ -662,6 +702,7 @@ function SidebarSectionHeader(props: { |
| 662 | 702 | aria-hidden |
| 663 | 703 | className={cn( |
| 664 | 704 | "size-3 shrink-0 transition-transform", |
| 705 | compact && "hidden", | |
| 665 | 706 | props.toggle.expanded && "rotate-180", |
| 666 | 707 | )} |
| 667 | 708 | /> |
| ... | ... | @@ -673,15 +714,22 @@ function SidebarSectionHeader(props: { |
| 673 | 714 | data-testid={`sidebar-${props.marker}`} |
| 674 | 715 | className="mx-0.5 h-8" |
| 675 | 716 | > |
| 676 | <button | |
| 677 | type="button" | |
| 678 | onClick={props.toggle.onToggle} | |
| 679 | aria-expanded={props.toggle.expanded} | |
| 680 | data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`} | |
| 681 | className={cn(className, "cursor-pointer")} | |
| 682 | > | |
| 683 | {content} | |
| 684 | </button> | |
| 717 | <Tooltip disabled={!compact}> | |
| 718 | <TooltipTrigger | |
| 719 | render={ | |
| 720 | <button | |
| 721 | type="button" | |
| 722 | onClick={props.toggle.onToggle} | |
| 723 | aria-expanded={props.toggle.expanded} | |
| 724 | data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`} | |
| 725 | className={cn(className, "cursor-pointer")} | |
| 726 | /> | |
| 727 | } | |
| 728 | > | |
| 729 | {content} | |
| 730 | </TooltipTrigger> | |
| 731 | <TooltipPopup side="right">{props.label}</TooltipPopup> | |
| 732 | </Tooltip> | |
| 685 | 733 | </SortableSidebarMarker> |
| 686 | 734 | ); |
| 687 | 735 | } |
| ... | ... | @@ -704,6 +752,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 704 | 752 | onDiscard: (draftId: DraftId) => void; |
| 705 | 753 | }) { |
| 706 | 754 | const { composer, draftId, onDiscard, onNavigate, session } = props; |
| 755 | const compactEnabled = useCompactSidebarEnabled(); | |
| 756 | const { state, isMobile } = useSidebar(); | |
| 757 | const compact = compactEnabled && state === "collapsed" && !isMobile; | |
| 707 | 758 | const promptPreview = |
| 708 | 759 | replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) |
| 709 | 760 | .trim() |
| ... | ... | @@ -742,6 +793,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 742 | 793 | }, |
| 743 | 794 | [draftId, onDiscard], |
| 744 | 795 | ); |
| 796 | if (compact) { | |
| 797 | return ( | |
| 798 | <li className="list-none"> | |
| 799 | <Tooltip> | |
| 800 | <TooltipTrigger | |
| 801 | render={ | |
| 802 | <button | |
| 803 | type="button" | |
| 804 | data-testid="sidebar-draft-row" | |
| 805 | aria-label={`Draft: ${preview}`} | |
| 806 | onClick={handleActivate} | |
| 807 | className={cn( | |
| 808 | "relative flex h-7 w-full cursor-pointer items-center justify-center rounded-md outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-ring", | |
| 809 | props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, | |
| 810 | )} | |
| 811 | /> | |
| 812 | } | |
| 813 | > | |
| 814 | <SquarePenIcon aria-hidden className={cn(draftPenClassName, "size-4")} /> | |
| 815 | </TooltipTrigger> | |
| 816 | <TooltipPopup side="right" className="max-w-80 whitespace-normal"> | |
| 817 | <div className="font-medium">{props.projectDisplayName}</div> | |
| 818 | <div>{preview}</div> | |
| 819 | </TooltipPopup> | |
| 820 | </Tooltip> | |
| 821 | </li> | |
| 822 | ); | |
| 823 | } | |
| 745 | 824 | return ( |
| 746 | 825 | <li className="list-none py-0.5"> |
| 747 | 826 | <div |
| ... | ... | @@ -1031,6 +1110,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1031 | 1110 | variant, |
| 1032 | 1111 | variantAction, |
| 1033 | 1112 | } = props; |
| 1113 | const compactEnabled = useCompactSidebarEnabled(); | |
| 1114 | const { state, isMobile } = useSidebar(); | |
| 1115 | const compact = compactEnabled && state === "collapsed" && !isMobile; | |
| 1034 | 1116 | const threadRef = useMemo( |
| 1035 | 1117 | () => scopeThreadRef(thread.environmentId, thread.id), |
| 1036 | 1118 | [thread.environmentId, thread.id], |
| ... | ... | @@ -1208,6 +1290,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1208 | 1290 | branchMismatch={branchMismatch} |
| 1209 | 1291 | terminalStatus={terminalStatus} |
| 1210 | 1292 | terminalProcessCount={terminalProcessCount} |
| 1293 | compactStatus={ | |
| 1294 | compact | |
| 1295 | ? (topStatus?.label ?? | |
| 1296 | (variantAction === "unsnooze" | |
| 1297 | ? "Snoozed" | |
| 1298 | : variantAction === "unsettle" | |
| 1299 | ? "Settled" | |
| 1300 | : "Ready")) | |
| 1301 | : undefined | |
| 1302 | } | |
| 1211 | 1303 | /> |
| 1212 | 1304 | ); |
| 1213 | 1305 | |
| ... | ... | @@ -1254,6 +1346,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1254 | 1346 | [isRenaming, onStartRename, thread.title, threadRef], |
| 1255 | 1347 | ); |
| 1256 | 1348 | const [isFileDragOver, setIsFileDragOver] = useState(false); |
| 1349 | const [tooltipOpen, setTooltipOpen] = useState(false); | |
| 1257 | 1350 | const fileDropHandlers = useMemo( |
| 1258 | 1351 | () => |
| 1259 | 1352 | onFileDropThreads |
| ... | ... | @@ -1416,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1416 | 1509 | // A zero-height boundary also makes dnd-kit scale the source to |
| 1417 | 1510 | // zero. Only projected peers use scaleY as a visibility sentinel. |
| 1418 | 1511 | visibility: |
| 1419 | !sortable.isDragging && sortable.transform?.scaleY === 0 | |
| 1512 | sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) | |
| 1420 | 1513 | ? ("hidden" as const) |
| 1421 | 1514 | : undefined, |
| 1422 | 1515 | }, |
| ... | ... | @@ -1557,6 +1650,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1557 | 1650 | ) |
| 1558 | 1651 | ) : null; |
| 1559 | 1652 | |
| 1653 | if (compact) { | |
| 1654 | return ( | |
| 1655 | <li | |
| 1656 | data-thread-item | |
| 1657 | {...sortableRootProps} | |
| 1658 | {...(fileDropHandlers ?? {})} | |
| 1659 | className={cn("list-none", sortable?.isDragging && "relative z-20")} | |
| 1660 | > | |
| 1661 | <Tooltip | |
| 1662 | open={ | |
| 1663 | sortable?.hidden ? false : sortable?.isDragging ? dragDestination !== null : tooltipOpen | |
| 1664 | } | |
| 1665 | onOpenChange={setTooltipOpen} | |
| 1666 | > | |
| 1667 | <TooltipTrigger | |
| 1668 | render={ | |
| 1669 | <div | |
| 1670 | ref={rowRef} | |
| 1671 | role="button" | |
| 1672 | tabIndex={0} | |
| 1673 | aria-label={`${thread.title}${topStatus ? `, ${topStatus.label}` : ""}`} | |
| 1674 | aria-current={props.isActive ? "page" : undefined} | |
| 1675 | aria-busy={isRegeneratingTitle || undefined} | |
| 1676 | className={cn( | |
| 1677 | rowSurfaceClassName, | |
| 1678 | "flex h-7 items-center justify-center focus-visible:ring-2 focus-visible:ring-ring", | |
| 1679 | )} | |
| 1680 | onClick={handleClick} | |
| 1681 | onDoubleClick={handleDoubleClick} | |
| 1682 | onKeyDown={handleKeyDown} | |
| 1683 | onContextMenu={handleContextMenu} | |
| 1684 | /> | |
| 1685 | } | |
| 1686 | > | |
| 1687 | {props.project ? ( | |
| 1688 | <ProjectFavicon project={props.project} className="size-4 shrink-0" /> | |
| 1689 | ) : driverKind ? ( | |
| 1690 | <ProviderInstanceIcon | |
| 1691 | driverKind={driverKind} | |
| 1692 | displayName={providerEntry?.displayName ?? modelInstanceId} | |
| 1693 | iconClassName="size-4" | |
| 1694 | /> | |
| 1695 | ) : ( | |
| 1696 | <SquarePenIcon aria-hidden className="size-4" /> | |
| 1697 | )} | |
| 1698 | {topStatus ? ( | |
| 1699 | <span | |
| 1700 | aria-hidden | |
| 1701 | className={cn( | |
| 1702 | "absolute right-0.5 bottom-0.5 size-1.5 rounded-full bg-current", | |
| 1703 | topStatus.className, | |
| 1704 | )} | |
| 1705 | /> | |
| 1706 | ) : hasUnsentDraft ? ( | |
| 1707 | <span | |
| 1708 | aria-hidden | |
| 1709 | className="absolute right-0.5 bottom-0.5 size-1.5 rounded-full bg-primary" | |
| 1710 | /> | |
| 1711 | ) : null} | |
| 1712 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} | |
| 1713 | </TooltipTrigger> | |
| 1714 | {sortable?.isDragging ? ( | |
| 1715 | <TooltipPopup side="right">{dragDestination}</TooltipPopup> | |
| 1716 | ) : ( | |
| 1717 | detailsTooltip | |
| 1718 | )} | |
| 1719 | </Tooltip> | |
| 1720 | </li> | |
| 1721 | ); | |
| 1722 | } | |
| 1723 | ||
| 1560 | 1724 | if (variant === "slim") { |
| 1561 | 1725 | return ( |
| 1562 | 1726 | <li |
| ... | ... | @@ -1569,7 +1733,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1569 | 1733 | sortable?.isDragging && "relative z-20", |
| 1570 | 1734 | )} |
| 1571 | 1735 | > |
| 1572 | <Tooltip disabled={sortable?.isDragging}> | |
| 1736 | <Tooltip | |
| 1737 | open={!sortable?.isDragging && tooltipOpen} | |
| 1738 | onOpenChange={setTooltipOpen} | |
| 1739 | disabled={sortable?.isDragging} | |
| 1740 | > | |
| 1573 | 1741 | <TooltipTrigger |
| 1574 | 1742 | render={ |
| 1575 | 1743 | <div |
| ... | ... | @@ -1722,7 +1890,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1722 | 1890 | sortable?.isDragging && "relative z-20", |
| 1723 | 1891 | )} |
| 1724 | 1892 | > |
| 1725 | <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}> | |
| 1893 | <Tooltip | |
| 1894 | open={!snoozeMenuOpen && !sortable?.isDragging && tooltipOpen} | |
| 1895 | onOpenChange={setTooltipOpen} | |
| 1896 | disabled={snoozeMenuOpen || sortable?.isDragging} | |
| 1897 | > | |
| 1726 | 1898 | <TooltipTrigger |
| 1727 | 1899 | render={ |
| 1728 | 1900 | <div |
| ... | ... | @@ -1989,6 +2161,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1989 | 2161 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; |
| 1990 | 2162 | }) { |
| 1991 | 2163 | const { thread } = props; |
| 2164 | const compactEnabled = useCompactSidebarEnabled(); | |
| 2165 | const { state, isMobile } = useSidebar(); | |
| 2166 | const compact = compactEnabled && state === "collapsed" && !isMobile; | |
| 1992 | 2167 | const threadRef = useMemo( |
| 1993 | 2168 | () => scopeThreadRef(thread.environmentId, thread.id), |
| 1994 | 2169 | [thread.environmentId, thread.id], |
| ... | ... | @@ -2074,6 +2249,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2074 | 2249 | onClick={props.onSelect} |
| 2075 | 2250 | className={cn( |
| 2076 | 2251 | "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", |
| 2252 | compact && "justify-center px-0", | |
| 2077 | 2253 | props.isHighlighted || props.isRouteActive |
| 2078 | 2254 | ? "bg-sidebar-row-active text-sidebar-foreground" |
| 2079 | 2255 | : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| ... | ... | @@ -2085,9 +2261,15 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2085 | 2261 | > |
| 2086 | 2262 | {props.project ? ( |
| 2087 | 2263 | <ProjectFavicon project={props.project} className="size-4 shrink-0" /> |
| 2264 | ) : compact ? ( | |
| 2265 | <SquarePenIcon aria-hidden className="size-4" /> | |
| 2088 | 2266 | ) : null} |
| 2089 | <span className="min-w-0 flex-1 truncate">{thread.title}</span> | |
| 2090 | <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums"> | |
| 2267 | <span className={compact ? "sr-only" : "min-w-0 flex-1 truncate"}>{thread.title}</span> | |
| 2268 | <span | |
| 2269 | className={ | |
| 2270 | compact ? "sr-only" : "shrink-0 text-xs text-muted-foreground/55 tabular-nums" | |
| 2271 | } | |
| 2272 | > | |
| 2091 | 2273 | {threadTimeLabel(thread)} |
| 2092 | 2274 | </span> |
| 2093 | 2275 | </TooltipTrigger> |
| ... | ... | @@ -2115,7 +2297,10 @@ export default function Sidebar() { |
| 2115 | 2297 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 2116 | 2298 | const threads = useThreadShells(); |
| 2117 | 2299 | const router = useRouter(); |
| 2118 | const { isMobile, setOpenMobile } = useSidebar(); | |
| 2300 | const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); | |
| 2301 | const compactEnabled = useCompactSidebarEnabled(); | |
| 2302 | const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; | |
| 2303 | const [snoozedFooter, setSnoozedFooter] = useState<HTMLUListElement | null>(null); | |
| 2119 | 2304 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2120 | 2305 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 2121 | 2306 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| ... | ... | @@ -2687,6 +2872,7 @@ export default function Sidebar() { |
| 2687 | 2872 | [setSettledShelfExpanded], |
| 2688 | 2873 | ); |
| 2689 | 2874 | const renderedSettledThreads = useMemo(() => { |
| 2875 | if (compact) return EMPTY_THREADS; | |
| 2690 | 2876 | if (settledShelfExpanded) return visibleSettledThreads; |
| 2691 | 2877 | if (routeThreadKey === null) return EMPTY_THREADS; |
| 2692 | 2878 | const routeThread = visibleSettledThreads.find( |
| ... | ... | @@ -2694,7 +2880,7 @@ export default function Sidebar() { |
| 2694 | 2880 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, |
| 2695 | 2881 | ); |
| 2696 | 2882 | return routeThread === undefined ? EMPTY_THREADS : [routeThread]; |
| 2697 | }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); | |
| 2883 | }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); | |
| 2698 | 2884 | |
| 2699 | 2885 | // The snoozed shelf is collapsed by default: out of the way, never gone. |
| 2700 | 2886 | // Collapsed threads don't render (and so don't participate in jump |
| ... | ... | @@ -2922,10 +3108,14 @@ export default function Sidebar() { |
| 2922 | 3108 | |
| 2923 | 3109 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); |
| 2924 | 3110 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 2925 | const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { | |
| 2926 | setRenamingThreadKey(scopedThreadKey(threadRef)); | |
| 2927 | setRenamingTitle(title); | |
| 2928 | }, []); | |
| 3111 | const startThreadRename = useCallback( | |
| 3112 | (threadRef: ScopedThreadRef, title: string) => { | |
| 3113 | if (compact) setOpen(true); | |
| 3114 | setRenamingThreadKey(scopedThreadKey(threadRef)); | |
| 3115 | setRenamingTitle(title); | |
| 3116 | }, | |
| 3117 | [compact, setOpen], | |
| 3118 | ); | |
| 2929 | 3119 | const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); |
| 2930 | 3120 | const commitThreadRename = useCallback( |
| 2931 | 3121 | (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { |
| ... | ... | @@ -3087,8 +3277,18 @@ export default function Sidebar() { |
| 3087 | 3277 | const threadListRef = useRef<HTMLUListElement | null>(null); |
| 3088 | 3278 | const dragLabelOffsetRef = useRef(0); |
| 3089 | 3279 | const restrictBelowPins = useCallback<Modifier>( |
| 3090 | (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), | |
| 3091 | [], | |
| 3280 | (args) => | |
| 3281 | restrictBelowSidebarLabel( | |
| 3282 | { | |
| 3283 | ...args, | |
| 3284 | // The fixed snoozed shelf shares the main list's drag boundary. | |
| 3285 | containerNodeRect: compact | |
| 3286 | ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) | |
| 3287 | : args.containerNodeRect, | |
| 3288 | }, | |
| 3289 | dragLabelOffsetRef.current, | |
| 3290 | ), | |
| 3291 | [compact], | |
| 3092 | 3292 | ); |
| 3093 | 3293 | const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null); |
| 3094 | 3294 | const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { |
| ... | ... | @@ -3310,7 +3510,7 @@ export default function Sidebar() { |
| 3310 | 3510 | pinnedThreads.length + |
| 3311 | 3511 | activeThreads.length + |
| 3312 | 3512 | snoozedThreads.length + |
| 3313 | settledThreads.length === | |
| 3513 | (compact ? 0 : settledThreads.length) === | |
| 3314 | 3514 | 0 |
| 3315 | 3515 | ) { |
| 3316 | 3516 | return []; |
| ... | ... | @@ -3326,13 +3526,15 @@ export default function Sidebar() { |
| 3326 | 3526 | items.push({ kind: "marker", marker: "snoozed-header" }); |
| 3327 | 3527 | items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); |
| 3328 | 3528 | } |
| 3329 | items.push({ kind: "marker", marker: "settled-header" }); | |
| 3330 | const settledRows = rowsOf(renderedSettledThreads, "settled"); | |
| 3331 | items.push({ kind: "marker", marker: "settled-placeholder" }); | |
| 3332 | items.push(...settledRows); | |
| 3529 | if (!compact) { | |
| 3530 | items.push({ kind: "marker", marker: "settled-header" }); | |
| 3531 | items.push({ kind: "marker", marker: "settled-placeholder" }); | |
| 3532 | items.push(...rowsOf(renderedSettledThreads, "settled")); | |
| 3533 | } | |
| 3333 | 3534 | return items; |
| 3334 | 3535 | }, [ |
| 3335 | 3536 | activeThreads, |
| 3537 | compact, | |
| 3336 | 3538 | pinnedThreads, |
| 3337 | 3539 | renderedSettledThreads, |
| 3338 | 3540 | settledThreads.length, |
| ... | ... | @@ -3402,6 +3604,7 @@ export default function Sidebar() { |
| 3402 | 3604 | () => |
| 3403 | 3605 | createSidebarSortingStrategy({ |
| 3404 | 3606 | items: sidebarListItems, |
| 3607 | compact, | |
| 3405 | 3608 | boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, |
| 3406 | 3609 | settledOrder: draggedSettledOrder, |
| 3407 | 3610 | settledExpanded: settledShelfExpanded, |
| ... | ... | @@ -3410,6 +3613,7 @@ export default function Sidebar() { |
| 3410 | 3613 | snoozedThreadCount: snoozedThreads.length, |
| 3411 | 3614 | }), |
| 3412 | 3615 | [ |
| 3616 | compact, | |
| 3413 | 3617 | draggedSettledOrder, |
| 3414 | 3618 | routeThreadKey, |
| 3415 | 3619 | settledShelfExpanded, |
| ... | ... | @@ -3418,6 +3622,22 @@ export default function Sidebar() { |
| 3418 | 3622 | snoozedThreads.length, |
| 3419 | 3623 | ], |
| 3420 | 3624 | ); |
| 3625 | const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed"; | |
| 3626 | const compactSnoozedDragThread = | |
| 3627 | draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined; | |
| 3628 | const compactSidebarSortingStrategy = useCallback<typeof sidebarSortingStrategy>( | |
| 3629 | (args) => { | |
| 3630 | const item = sidebarListItems[args.index]; | |
| 3631 | // Footer rows stay anchored while the main list previews a reorder. | |
| 3632 | if ( | |
| 3633 | item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header" | |
| 3634 | ) { | |
| 3635 | return null; | |
| 3636 | } | |
| 3637 | return sidebarSortingStrategy(args); | |
| 3638 | }, | |
| 3639 | [sidebarListItems, sidebarSortingStrategy], | |
| 3640 | ); | |
| 3421 | 3641 | // Hidden and filtered threads keep their keys. Reserve those slots without |
| 3422 | 3642 | // including the rows in the visible drop order or writing to them. |
| 3423 | 3643 | const { pinnedKeysById, activeKeysById } = useMemo( |
| ... | ... | @@ -4321,7 +4541,16 @@ export default function Sidebar() { |
| 4321 | 4541 | <> |
| 4322 | 4542 | <SidebarChromeHeader isElectron={isElectron} /> |
| 4323 | 4543 | <SidebarContent |
| 4324 | className="gap-0" | |
| 4544 | className={cn("gap-0", compact && "group-data-[collapsible=icon]:overflow-visible")} | |
| 4545 | fixedFooter={ | |
| 4546 | compact && !isSearchingThreads && snoozedThreads.length > 0 ? ( | |
| 4547 | <ul | |
| 4548 | ref={setSnoozedFooter} | |
| 4549 | aria-label="Snoozed threads" | |
| 4550 | className="relative flex max-h-[min(30vh,16rem)] flex-col gap-px overflow-y-auto px-[var(--sidebar-content-inset)] pb-1" | |
| 4551 | /> | |
| 4552 | ) : null | |
| 4553 | } | |
| 4325 | 4554 | fixedHeader={ |
| 4326 | 4555 | // Lifted above the stage backdrop, whose fade bleeds below the |
| 4327 | 4556 | // header and would otherwise paint across the search row's outline. |
| ... | ... | @@ -4381,8 +4610,12 @@ export default function Sidebar() { |
| 4381 | 4610 | // popup opens under the field, is at least as wide as it, |
| 4382 | 4611 | // and grows to fit project names up to a cap, past which |
| 4383 | 4612 | // the rows truncate. |
| 4384 | anchor={headerSearchRef} | |
| 4385 | className="max-w-[min(18rem,var(--available-width))] overflow-hidden" | |
| 4613 | anchor={compact ? undefined : headerSearchRef} | |
| 4614 | side={compact ? "right" : "bottom"} | |
| 4615 | className={cn( | |
| 4616 | "max-w-[min(18rem,var(--available-width))] overflow-hidden", | |
| 4617 | compact && "min-w-56", | |
| 4618 | )} | |
| 4386 | 4619 | > |
| 4387 | 4620 | <ComboboxSearchInput |
| 4388 | 4621 | aria-label="Search projects" |
| ... | ... | @@ -4537,7 +4770,10 @@ export default function Sidebar() { |
| 4537 | 4770 | ) : ( |
| 4538 | 4771 | <p |
| 4539 | 4772 | role="status" |
| 4540 | className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" | |
| 4773 | className={cn( | |
| 4774 | "px-2 py-6 text-center text-xs text-sidebar-muted-foreground", | |
| 4775 | compact && "sr-only", | |
| 4776 | )} | |
| 4541 | 4777 | > |
| 4542 | 4778 | No threads found |
| 4543 | 4779 | </p> |
| ... | ... | @@ -4556,14 +4792,17 @@ export default function Sidebar() { |
| 4556 | 4792 | modifiers={[ |
| 4557 | 4793 | restrictToVerticalAxis, |
| 4558 | 4794 | restrictBelowPins, |
| 4559 | restrictToFirstScrollableAncestor, | |
| 4795 | ...(compact ? [] : [restrictToFirstScrollableAncestor]), | |
| 4560 | 4796 | ]} |
| 4561 | 4797 | onDragStart={handleThreadDragStart} |
| 4562 | 4798 | onDragOver={handleThreadDragOver} |
| 4563 | 4799 | onDragEnd={handleThreadDragEnd} |
| 4564 | 4800 | > |
| 4565 | 4801 | <SidebarDragLifecycle onUnmount={cancelThreadDrag} /> |
| 4566 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> | |
| 4802 | <SortableContext | |
| 4803 | items={sortableIds} | |
| 4804 | strategy={compact ? compactSidebarSortingStrategy : sidebarSortingStrategy} | |
| 4805 | > | |
| 4567 | 4806 | <ul |
| 4568 | 4807 | ref={attachListMotionRef} |
| 4569 | 4808 | role="list" |
| ... | ... | @@ -4693,11 +4932,25 @@ export default function Sidebar() { |
| 4693 | 4932 | !draggableThreadKeys.has(threadKey) || optimisticDrop !== null |
| 4694 | 4933 | } |
| 4695 | 4934 | > |
| 4696 | {(bag) => renderThreadRowInner(thread, section, bag)} | |
| 4935 | {(bag) => | |
| 4936 | renderThreadRowInner( | |
| 4937 | thread, | |
| 4938 | section, | |
| 4939 | draggingCompactSnoozed && bag.isDragging | |
| 4940 | ? { ...bag, hidden: true } | |
| 4941 | : bag, | |
| 4942 | ) | |
| 4943 | } | |
| 4697 | 4944 | </SortableThreadRow> |
| 4698 | 4945 | ); |
| 4699 | 4946 | }; |
| 4700 | 4947 | const from = dragState?.activeSection ?? null; |
| 4948 | const showDragLabels = | |
| 4949 | from !== null && | |
| 4950 | (!compact || | |
| 4951 | dragTargetSection === "active" || | |
| 4952 | dragTargetSection === "pinned"); | |
| 4953 | const snoozedItems: ReactNode[] = []; | |
| 4701 | 4954 | const items: ReactNode[] = [ |
| 4702 | 4955 | <SidebarDraftBlock |
| 4703 | 4956 | key="draft-sessions" |
| ... | ... | @@ -4709,8 +4962,17 @@ export default function Sidebar() { |
| 4709 | 4962 | />, |
| 4710 | 4963 | ]; |
| 4711 | 4964 | for (const item of sidebarListItems) { |
| 4965 | const destination = | |
| 4966 | compact && | |
| 4967 | (item.kind === "thread" | |
| 4968 | ? item.section === "snoozed" | |
| 4969 | : item.marker === "snoozed-header") | |
| 4970 | ? snoozedItems | |
| 4971 | : items; | |
| 4712 | 4972 | if (item.kind === "thread") { |
| 4713 | items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); | |
| 4973 | destination.push( | |
| 4974 | renderThreadRow(threadByKey.get(item.key)!, item.section), | |
| 4975 | ); | |
| 4714 | 4976 | continue; |
| 4715 | 4977 | } |
| 4716 | 4978 | switch (item.marker) { |
| ... | ... | @@ -4720,7 +4982,7 @@ export default function Sidebar() { |
| 4720 | 4982 | key="pinned-header" |
| 4721 | 4983 | marker="pinned-header" |
| 4722 | 4984 | label="Pinned" |
| 4723 | visible={from !== null} | |
| 4985 | visible={showDragLabels} | |
| 4724 | 4986 | isDropTarget={dragTargetSection === "pinned"} |
| 4725 | 4987 | />, |
| 4726 | 4988 | ); |
| ... | ... | @@ -4731,7 +4993,7 @@ export default function Sidebar() { |
| 4731 | 4993 | key="pinned-divider" |
| 4732 | 4994 | marker="pinned-divider" |
| 4733 | 4995 | label="Active" |
| 4734 | visible={from !== null} | |
| 4996 | visible={showDragLabels} | |
| 4735 | 4997 | isDropTarget={dragTargetSection === "active"} |
| 4736 | 4998 | />, |
| 4737 | 4999 | ); |
| ... | ... | @@ -4755,7 +5017,7 @@ export default function Sidebar() { |
| 4755 | 5017 | ); |
| 4756 | 5018 | break; |
| 4757 | 5019 | case "snoozed-header": |
| 4758 | items.push( | |
| 5020 | destination.push( | |
| 4759 | 5021 | <SidebarSectionHeader |
| 4760 | 5022 | key="snoozed-shelf-header" |
| 4761 | 5023 | marker="snoozed-header" |
| ... | ... | @@ -4810,18 +5072,56 @@ export default function Sidebar() { |
| 4810 | 5072 | break; |
| 4811 | 5073 | } |
| 4812 | 5074 | } |
| 4813 | return items; | |
| 5075 | // Keep the shelf inside this drag context while anchoring | |
| 5076 | // its DOM outside the main thread scroller. | |
| 5077 | return [ | |
| 5078 | ...items, | |
| 5079 | compact && snoozedFooter | |
| 5080 | ? createPortal(snoozedItems, snoozedFooter, "snoozed-footer") | |
| 5081 | : null, | |
| 5082 | compactSnoozedDragThread | |
| 5083 | ? createPortal( | |
| 5084 | <DragOverlay dropAnimation={null}> | |
| 5085 | <ul className="pointer-events-none"> | |
| 5086 | {renderThreadRowInner(compactSnoozedDragThread, "snoozed", { | |
| 5087 | isDragging: true, | |
| 5088 | listeners: undefined, | |
| 5089 | setNodeRef: () => {}, | |
| 5090 | transform: null, | |
| 5091 | transition: undefined, | |
| 5092 | })} | |
| 5093 | </ul> | |
| 5094 | </DragOverlay>, | |
| 5095 | document.body, | |
| 5096 | "compact-snoozed-drag", | |
| 5097 | ) | |
| 5098 | : null, | |
| 5099 | ]; | |
| 4814 | 5100 | })()} |
| 4815 | {settledShelfExpanded && hiddenSettledCount > 0 ? ( | |
| 5101 | {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? ( | |
| 4816 | 5102 | <li className="list-none"> |
| 4817 | <button | |
| 4818 | type="button" | |
| 4819 | onClick={showMoreSettled} | |
| 4820 | 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" | |
| 4821 | > | |
| 4822 | <PlusIcon aria-hidden className="size-4 shrink-0" /> | |
| 4823 | Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more | |
| 4824 | </button> | |
| 5103 | <Tooltip disabled={!compact}> | |
| 5104 | <TooltipTrigger | |
| 5105 | render={ | |
| 5106 | <button | |
| 5107 | type="button" | |
| 5108 | onClick={showMoreSettled} | |
| 5109 | className={cn( | |
| 5110 | "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", | |
| 5111 | compact && "justify-center px-0", | |
| 5112 | )} | |
| 5113 | /> | |
| 5114 | } | |
| 5115 | > | |
| 5116 | <PlusIcon aria-hidden className="size-4 shrink-0" /> | |
| 5117 | <span className={compact ? "sr-only" : undefined}> | |
| 5118 | Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more | |
| 5119 | </span> | |
| 5120 | </TooltipTrigger> | |
| 5121 | <TooltipPopup side="right"> | |
| 5122 | Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more | |
| 5123 | </TooltipPopup> | |
| 5124 | </Tooltip> | |
| 4825 | 5125 | </li> |
| 4826 | 5126 | ) : null} |
| 4827 | 5127 | </ul> |
| ... | ... | @@ -4836,20 +5136,29 @@ export default function Sidebar() { |
| 4836 | 5136 | snoozedThreads.length + |
| 4837 | 5137 | settledThreads.length === |
| 4838 | 5138 | 0 ? ( |
| 4839 | <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60"> | |
| 5139 | <div | |
| 5140 | className={cn( | |
| 5141 | "flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60", | |
| 5142 | compact && "px-0", | |
| 5143 | )} | |
| 5144 | > | |
| 4840 | 5145 | {projects.length === 0 ? ( |
| 4841 | 5146 | <> |
| 4842 | <span>No projects yet</span> | |
| 5147 | <span className={compact ? "sr-only" : undefined}>No projects yet</span> | |
| 4843 | 5148 | <button |
| 4844 | 5149 | type="button" |
| 4845 | 5150 | onClick={openAddProjectCommandPalette} |
| 4846 | className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | |
| 5151 | aria-label="Add project" | |
| 5152 | className={cn( | |
| 5153 | "inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | |
| 5154 | compact && "justify-center px-1.5", | |
| 5155 | )} | |
| 4847 | 5156 | > |
| 4848 | 5157 | <PlusIcon className="-mx-0.5 size-3" /> |
| 4849 | Add project | |
| 5158 | <span className={compact ? "sr-only" : undefined}>Add project</span> | |
| 4850 | 5159 | </button> |
| 4851 | 5160 | </> |
| 4852 | ) : scopedProjectGroup ? ( | |
| 5161 | ) : compact ? null : scopedProjectGroup ? ( | |
| 4853 | 5162 | `No threads in ${scopedProjectGroup.displayName} yet` |
| 4854 | 5163 | ) : ( |
| 4855 | 5164 | "No threads yet" |
apps/web/src/components/settings/CompactSidebarPreview.tsx created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import { useState } from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | export function CompactSidebarPreview() { | |
| 6 | const [collapsed, setCollapsed] = useState(false); | |
| 7 | ||
| 8 | return ( | |
| 9 | <button | |
| 10 | type="button" | |
| 11 | aria-label="Toggle compact sidebar preview" | |
| 12 | aria-pressed={collapsed} | |
| 13 | className="group/compact-preview relative flex h-10 w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-background p-1 shadow-xs/5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | |
| 14 | onClick={() => setCollapsed((value) => !value)} | |
| 15 | > | |
| 16 | <span | |
| 17 | aria-hidden | |
| 18 | className={cn( | |
| 19 | "flex h-full shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-md bg-sidebar px-1 transition-[width] duration-250 ease-out motion-reduce:transition-none", | |
| 20 | collapsed ? "w-3" : "w-9", | |
| 21 | )} | |
| 22 | > | |
| 23 | {[0, 1, 2].map((row) => ( | |
| 24 | <span key={row} className="flex h-1 shrink-0 items-center gap-1"> | |
| 25 | <span className="size-1 shrink-0 rounded-[1px] bg-muted-foreground/50" /> | |
| 26 | <span | |
| 27 | className={cn( | |
| 28 | "h-0.5 w-4 shrink-0 rounded-full bg-muted-foreground/25 transition-opacity duration-250 ease-out motion-reduce:transition-none", | |
| 29 | collapsed && "opacity-0", | |
| 30 | )} | |
| 31 | /> | |
| 32 | </span> | |
| 33 | ))} | |
| 34 | </span> | |
| 35 | <span aria-hidden className="flex min-w-0 flex-1 flex-col gap-1 px-1 pt-1"> | |
| 36 | <span className="h-0.5 w-full rounded-full bg-muted-foreground/25" /> | |
| 37 | <span className="h-0.5 w-4/5 rounded-full bg-muted-foreground/20" /> | |
| 38 | <span className="h-0.5 w-3/5 rounded-full bg-muted-foreground/15" /> | |
| 39 | </span> | |
| 40 | <span | |
| 41 | aria-hidden | |
| 42 | className={cn( | |
| 43 | "pointer-events-none absolute top-3 left-5 flex w-9 flex-col gap-1 rounded-sm border border-border bg-popover p-1 opacity-0 shadow-xs transition-opacity duration-150 ease-out motion-reduce:transition-none", | |
| 44 | collapsed && | |
| 45 | "group-hover/compact-preview:opacity-100 group-focus-visible/compact-preview:opacity-100", | |
| 46 | )} | |
| 47 | > | |
| 48 | <span className="h-0.5 w-full rounded-full bg-popover-foreground/45" /> | |
| 49 | <span className="h-0.5 w-2/3 rounded-full bg-popover-foreground/25" /> | |
| 50 | </span> | |
| 51 | </button> | |
| 52 | ); | |
| 53 | } |
apps/web/src/components/settings/SettingsPanels.tsx+27| ... | ... | @@ -160,6 +160,7 @@ import { |
| 160 | 160 | import { searchableSetting } from "./settingsSearch"; |
| 161 | 161 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 162 | 162 | import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; |
| 163 | import { CompactSidebarPreview } from "./CompactSidebarPreview"; | |
| 163 | 164 | |
| 164 | 165 | const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { |
| 165 | 166 | artwork: "Artwork", |
| ... | ... | @@ -501,6 +502,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 501 | 502 | ...(theme !== "system" ? ["Theme"] : []), |
| 502 | 503 | ...(!followSystem ? ["Follow system"] : []), |
| 503 | 504 | ...(themeHalves !== null ? ["Theme mix"] : []), |
| 505 | ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled | |
| 506 | ? ["Compact sidebar"] | |
| 507 | : []), | |
| 504 | 508 | ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast |
| 505 | 509 | ? ["Contrast"] |
| 506 | 510 | : []), |
| ... | ... | @@ -605,6 +609,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 605 | 609 | settings.browserLinkTarget, |
| 606 | 610 | settings.browserAutoShowFloatingPreview, |
| 607 | 611 | settings.appearanceContrast, |
| 612 | settings.compactSidebarEnabled, | |
| 608 | 613 | settings.diffColorScheme, |
| 609 | 614 | settings.enableAgentBrowserAccess, |
| 610 | 615 | settings.confirmQuit, |
| ... | ... | @@ -712,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 712 | 717 | } |
| 713 | 718 | updateSettings({ |
| 714 | 719 | appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, |
| 720 | compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, | |
| 715 | 721 | diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, |
| 716 | 722 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 717 | 723 | notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, |
| ... | ... | @@ -1356,6 +1362,27 @@ export function AppearanceSettingsPanel() { |
| 1356 | 1362 | /> |
| 1357 | 1363 | </SettingsSection> |
| 1358 | 1364 | |
| 1365 | <SettingsSection id="appearance-sidebar" title="Sidebar"> | |
| 1366 | <SettingsRow | |
| 1367 | {...searchableSetting("compact-sidebar")} | |
| 1368 | description="Keep an icon rail when the sidebar is collapsed. Click the preview to try it." | |
| 1369 | control={ | |
| 1370 | <div className="grid w-full grid-cols-[5rem_auto] items-center justify-end gap-3 sm:w-auto sm:grid-cols-[7rem_auto] sm:gap-4"> | |
| 1371 | <CompactSidebarPreview /> | |
| 1372 | <div className="flex justify-end"> | |
| 1373 | <Switch | |
| 1374 | checked={settings.compactSidebarEnabled} | |
| 1375 | onCheckedChange={(checked) => | |
| 1376 | updateSettings({ compactSidebarEnabled: Boolean(checked) }) | |
| 1377 | } | |
| 1378 | aria-label="Compact sidebar" | |
| 1379 | /> | |
| 1380 | </div> | |
| 1381 | </div> | |
| 1382 | } | |
| 1383 | /> | |
| 1384 | </SettingsSection> | |
| 1385 | ||
| 1359 | 1386 | <TypographySection /> |
| 1360 | 1387 | </SettingsPageContainer> |
| 1361 | 1388 | ); |
apps/web/src/components/settings/SettingsSidebarNav.tsx+26-7| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | XIcon, |
| 25 | 25 | } from "lucide-react"; |
| 26 | 26 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 27 | import { useCompactSidebarEnabled } from "../../hooks/useSettings"; | |
| 27 | 28 | |
| 28 | 29 | import { Button } from "../ui/button"; |
| 29 | 30 | import { Input } from "../ui/input"; |
| ... | ... | @@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 111 | 112 | (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), |
| 112 | 113 | ); |
| 113 | 114 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); |
| 115 | const compactSidebarEnabled = useCompactSidebarEnabled(); | |
| 114 | 116 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 115 | 117 | const [query, setQuery] = useState(""); |
| 116 | 118 | const [activeResultIndex, setActiveResultIndex] = useState(0); |
| 117 | 119 | const searchableItems = useAvailableSettingsSearchItems(); |
| 118 | 120 | const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); |
| 119 | const isSearching = query.trim().length > 0; | |
| 121 | const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open); | |
| 120 | 122 | const hasResults = results.length > 0; |
| 121 | 123 | |
| 122 | 124 | useEffect(() => { |
| ... | ... | @@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 233 | 235 | <> |
| 234 | 236 | <SidebarContent className="overflow-x-hidden"> |
| 235 | 237 | <SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]"> |
| 236 | <div className="flex h-8 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"> | |
| 238 | <SidebarMenuButton | |
| 239 | className="hidden group-data-[collapsible=icon]:flex" | |
| 240 | aria-label="Search settings" | |
| 241 | tooltip="Search settings" | |
| 242 | onClick={() => { | |
| 243 | setOpen(true); | |
| 244 | requestAnimationFrame(() => searchInputRef.current?.focus()); | |
| 245 | }} | |
| 246 | > | |
| 247 | <SearchIcon /> | |
| 248 | </SidebarMenuButton> | |
| 249 | <div className="flex h-8 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 group-data-[collapsible=icon]:hidden"> | |
| 237 | 250 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> |
| 238 | 251 | <Input |
| 239 | 252 | ref={searchInputRef} |
| ... | ... | @@ -330,10 +343,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 330 | 343 | <SidebarMenuItem key={item.to}> |
| 331 | 344 | <SidebarMenuButton |
| 332 | 345 | isActive={isActive} |
| 346 | aria-label={item.label} | |
| 347 | tooltip={item.label} | |
| 333 | 348 | onClick={() => handleSectionClick(item.to)} |
| 334 | 349 | > |
| 335 | 350 | <Icon /> |
| 336 | <span className="truncate">{item.label}</span> | |
| 351 | <span className="truncate group-data-[collapsible=icon]:hidden"> | |
| 352 | {item.label} | |
| 353 | </span> | |
| 337 | 354 | </SidebarMenuButton> |
| 338 | 355 | </SidebarMenuItem> |
| 339 | 356 | ); |
| ... | ... | @@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 343 | 360 | </SidebarGroup> |
| 344 | 361 | </SidebarContent> |
| 345 | 362 | <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1"> |
| 346 | <Suspense fallback={null}> | |
| 347 | <T3ConnectSidebarSignIn /> | |
| 348 | </Suspense> | |
| 349 | <div className="flex items-center gap-1"> | |
| 363 | <div className="contents group-data-[collapsible=icon]:hidden"> | |
| 364 | <Suspense fallback={null}> | |
| 365 | <T3ConnectSidebarSignIn /> | |
| 366 | </Suspense> | |
| 367 | </div> | |
| 368 | <div className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col"> | |
| 350 | 369 | <div className="min-w-0 flex-1"> |
| 351 | 370 | <SidebarUtilityMenu /> |
| 352 | 371 | </div> |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 159 | 159 | title: "Panel animations", |
| 160 | 160 | to: "/settings/appearance", |
| 161 | 161 | }, |
| 162 | { | |
| 163 | id: "compact-sidebar", | |
| 164 | title: "Compact sidebar", | |
| 165 | to: "/settings/appearance", | |
| 166 | searchTerms: ["collapsed icons rail hover navigation preview"], | |
| 167 | }, | |
| 162 | 168 | { |
| 163 | 169 | id: "environment-identification", |
| 164 | 170 | title: "Environment identification", |
apps/web/src/components/sidebar/SidebarChrome.tsx+8-6| ... | ... | @@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 86 | 86 | <Link |
| 87 | 87 | aria-label="Go to threads" |
| 88 | 88 | className={cn( |
| 89 | "relative z-10 ml-[var(--workspace-titlebar-content-left)] hidden h-7 w-fit min-w-0 shrink-0 items-center overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2 md:flex", | |
| 89 | "relative z-10 ml-[var(--workspace-titlebar-content-left)] hidden h-7 w-fit min-w-0 shrink-0 items-center overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2 md:flex group-data-[collapsible=icon]:hidden", | |
| 90 | 90 | onBackdrop ? "text-white" : "text-foreground", |
| 91 | 91 | )} |
| 92 | 92 | to="/" |
| ... | ... | @@ -187,12 +187,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 187 | 187 | }, [canGoBack, closeMobileSidebar, navigate]); |
| 188 | 188 | |
| 189 | 189 | return ( |
| 190 | <SidebarMenu className="flex-row items-center"> | |
| 190 | <SidebarMenu className="flex-row items-center group-data-[collapsible=icon]:flex-col"> | |
| 191 | 191 | {currentFooterPage ? ( |
| 192 | 192 | <SidebarMenuItem className="min-w-0 flex-1"> |
| 193 | <SidebarMenuButton onClick={handleBackClick}> | |
| 193 | <SidebarMenuButton onClick={handleBackClick} aria-label="Back" tooltip="Back"> | |
| 194 | 194 | <ArrowLeftIcon /> |
| 195 | <span>Back</span> | |
| 195 | <span className="group-data-[collapsible=icon]:hidden">Back</span> | |
| 196 | 196 | </SidebarMenuButton> |
| 197 | 197 | </SidebarMenuItem> |
| 198 | 198 | ) : ( |
| ... | ... | @@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 224 | 224 | export const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 225 | 225 | return ( |
| 226 | 226 | <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1"> |
| 227 | <SidebarProviderUpdatePill /> | |
| 228 | <SidebarUpdateArchitectureWarning /> | |
| 227 | <div className="contents group-data-[collapsible=icon]:hidden"> | |
| 228 | <SidebarProviderUpdatePill /> | |
| 229 | <SidebarUpdateArchitectureWarning /> | |
| 230 | </div> | |
| 229 | 231 | <SidebarUtilityMenu /> |
| 230 | 232 | </SidebarFooter> |
| 231 | 233 | ); |
apps/web/src/components/sidebar/SidebarThreadHeader.tsx+27-4| ... | ... | @@ -20,9 +20,10 @@ import { |
| 20 | 20 | } from "react"; |
| 21 | 21 | |
| 22 | 22 | import { cn } from "~/lib/utils"; |
| 23 | import { useCompactSidebarEnabled } from "../../hooks/useSettings"; | |
| 23 | 24 | import { Button } from "../ui/button"; |
| 24 | 25 | import { Input } from "../ui/input"; |
| 25 | import { SidebarMenuButton } from "../ui/sidebar"; | |
| 26 | import { SidebarMenuButton, useSidebar } from "../ui/sidebar"; | |
| 26 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 27 | 28 | |
| 28 | 29 | export interface SidebarThreadHeaderProps { |
| ... | ... | @@ -69,6 +70,9 @@ export function SidebarThreadHeader({ |
| 69 | 70 | activeSearchResultIndex, |
| 70 | 71 | onClearSearch, |
| 71 | 72 | }: SidebarThreadHeaderProps) { |
| 73 | const compactEnabled = useCompactSidebarEnabled(); | |
| 74 | const { state, isMobile, setOpen } = useSidebar(); | |
| 75 | const compact = compactEnabled && state === "collapsed" && !isMobile; | |
| 72 | 76 | const resultsVisible = isSearching && searchResultCount > 0; |
| 73 | 77 | // Results shrink as the query narrows, so the active index can outrun the |
| 74 | 78 | // list; pointing aria-activedescendant at a removed option strands the |
| ... | ... | @@ -79,10 +83,24 @@ export function SidebarThreadHeader({ |
| 79 | 83 | : "New thread"; |
| 80 | 84 | |
| 81 | 85 | return ( |
| 82 | <div className="flex items-center gap-1"> | |
| 86 | <div className={cn("flex items-center gap-1", compact && "flex-col")}> | |
| 87 | {compact ? ( | |
| 88 | <SidebarHeaderIconButton | |
| 89 | label="Search threads" | |
| 90 | onClick={() => { | |
| 91 | setOpen(true); | |
| 92 | requestAnimationFrame(() => searchInputRef.current?.focus()); | |
| 93 | }} | |
| 94 | > | |
| 95 | <SearchIcon /> | |
| 96 | </SidebarHeaderIconButton> | |
| 97 | ) : null} | |
| 83 | 98 | <div |
| 84 | 99 | ref={searchFieldRef} |
| 85 | 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" | |
| 100 | className={cn( | |
| 101 | "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", | |
| 102 | compact && "hidden", | |
| 103 | )} | |
| 86 | 104 | > |
| 87 | 105 | <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" /> |
| 88 | 106 | <Input |
| ... | ... | @@ -124,7 +142,12 @@ export function SidebarThreadHeader({ |
| 124 | 142 | </div> |
| 125 | 143 | {/* Segmented well: the icons read as one control instead of three loose |
| 126 | 144 | buttons competing with the search field beside them. */} |
| 127 | <div className="flex shrink-0 items-center rounded-md bg-sidebar-control-surface/60 p-px"> | |
| 145 | <div | |
| 146 | className={cn( | |
| 147 | "flex shrink-0 items-center rounded-md bg-sidebar-control-surface/60 p-px", | |
| 148 | compact && "flex-col", | |
| 149 | )} | |
| 150 | > | |
| 128 | 151 | {hasProjects ? ( |
| 129 | 152 | <> |
| 130 | 153 | {projectScope} |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+1-1| ... | ... | @@ -348,7 +348,7 @@ function SidebarUpdateControl() { |
| 348 | 348 | ); |
| 349 | 349 | |
| 350 | 350 | return ( |
| 351 | <SidebarMenuItem className="ml-auto shrink-0"> | |
| 351 | <SidebarMenuItem className="ml-auto shrink-0 group-data-[collapsible=icon]:hidden"> | |
| 352 | 352 | <Popover |
| 353 | 353 | handle={releaseNotesPopoverHandle} |
| 354 | 354 | onOpenChange={(open, details) => { |
apps/web/src/components/ui/sidebar.tsx+3| ... | ... | @@ -591,9 +591,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S |
| 591 | 591 | function SidebarContent({ |
| 592 | 592 | className, |
| 593 | 593 | fixedHeader, |
| 594 | fixedFooter, | |
| 594 | 595 | ...props |
| 595 | 596 | }: React.ComponentProps<"div"> & { |
| 596 | 597 | fixedHeader?: React.ReactNode; |
| 598 | fixedFooter?: React.ReactNode; | |
| 597 | 599 | }) { |
| 598 | 600 | return ( |
| 599 | 601 | <> |
| ... | ... | @@ -617,6 +619,7 @@ function SidebarContent({ |
| 617 | 619 | {...props} |
| 618 | 620 | /> |
| 619 | 621 | </ScrollArea> |
| 622 | {fixedFooter ? <div className="w-full shrink-0">{fixedFooter}</div> : null} | |
| 620 | 623 | </> |
| 621 | 624 | ); |
| 622 | 625 | } |
apps/web/src/hooks/useSettings.ts+7| ... | ... | @@ -379,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean { |
| 379 | 379 | return settingsHydrated && legacySidebarEnabled; |
| 380 | 380 | } |
| 381 | 381 | |
| 382 | /** Keep the default collapsed sidebar until persisted client settings hydrate. */ | |
| 383 | export function useCompactSidebarEnabled(): boolean { | |
| 384 | const settingsHydrated = useClientSettingsHydrated(); | |
| 385 | const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled; | |
| 386 | return settingsHydrated && compactSidebarEnabled; | |
| 387 | } | |
| 388 | ||
| 382 | 389 | /** Read current settings for one environment, merged with client-local preferences. */ |
| 383 | 390 | export function useEnvironmentSettings<T = UnifiedSettings>( |
| 384 | 391 | environmentId: EnvironmentId, |
apps/web/src/workspaceTitlebar.ts+1-1| ... | ... | @@ -1,2 +1,2 @@ |
| 1 | 1 | export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = |
| 2 | "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; | |
| 2 | "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)] [[data-sidebar-state=collapsed]:has([data-side=left][data-collapsible=icon])_&]:pl-[max(calc(env(safe-area-inset-left)+1.25rem),calc(var(--workspace-titlebar-content-left)-var(--sidebar-width-icon)))]"; |
packages/contracts/src/settings.ts+2| ... | ... | @@ -436,6 +436,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 436 | 436 | // old keys, so everyone, including prior beta opt-outs, resets to the new |
| 437 | 437 | // default sidebar. |
| 438 | 438 | legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 439 | compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 439 | 440 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 440 | 441 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 441 | 442 | ), |
| ... | ... | @@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1490 | 1491 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1491 | 1492 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 1492 | 1493 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 1494 | compactSidebarEnabled: Schema.optionalKey(Schema.Boolean), | |
| 1493 | 1495 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 1494 | 1496 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 1495 | 1497 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |