authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 13:27:24-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:27:24-03:00
logca2cc1339bbd1904b932c3a92d23a2c0ffe1d267
tree315a8d16a8b0caf7c0895d36331168d16ab84fd9
parente6286839369a29d829aee3c64dd64ce39c626d2f
signature Signed by PGP key B5690EEEBB952194

feat(web): add optional compact sidebar rail (#11525)

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
1414import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
1515import { cn, isMacPlatform } from "../lib/utils";
1616import { primaryServerKeybindingsAtom } from "../state/server";
17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
17import {
18 useCompactSidebarEnabled,
19 useEnvironmentIdentificationMode,
20 useLegacySidebarEnabled,
21} from "../hooks/useSettings";
1822import {
1923 PanelAnimationSuppressionProvider,
2024 usePanelAnimationSettings,
......@@ -144,6 +148,7 @@ function ProjectProjectionRetention() {
144148export function AppSidebarLayout({ children }: { children: ReactNode }) {
145149 const navigate = useNavigate();
146150 const legacySidebarEnabled = useLegacySidebarEnabled();
151 const compactSidebarEnabled = useCompactSidebarEnabled();
147152 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
148153 usePanelAnimationSettings();
149154 // Settings routes show the settings nav in place of whichever thread
......@@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
229234 <ProjectProjectionRetention />
230235 <Sidebar
231236 side="left"
232 collapsible="offcanvas"
237 collapsible={compactSidebarEnabled ? "icon" : "offcanvas"}
233238 data-app-sidebar=""
234239 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
235240 resizable={{
apps/web/src/components/LegacySidebar.tsx+52-25
......@@ -199,7 +199,11 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom
199199import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
200200import { useIsMobile } from "~/hooks/useMediaQuery";
201201import { CommandDialogTrigger } from "./ui/command";
202import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
202import {
203 useClientSettings,
204 useCompactSidebarEnabled,
205 useUpdateClientSettings,
206} from "~/hooks/useSettings";
203207import { primaryServerKeybindingsAtom } from "../state/server";
204208import {
205209 derivePhysicalProjectKey,
......@@ -1194,7 +1198,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11941198 const router = useRouter();
11951199 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
11961200 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";
11981204 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
11991205 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
12001206 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
......@@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14451451 if (useThreadSelectionStore.getState().hasSelection()) {
14461452 clearSelection();
14471453 }
1448 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1454 setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded);
1455 if (isCompact) setOpen(true);
14491456 },
14501457 [
14511458 clearSelection,
1459 isCompact,
1460 setOpen,
14521461 dragInProgressRef,
14531462 projectExpanded,
14541463 projectPreferenceKeys,
......@@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14651474 if (dragInProgressRef.current) {
14661475 return;
14671476 }
1468 setProjectExpanded(projectPreferenceKeys, !projectExpanded);
1477 setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded);
1478 if (isCompact) setOpen(true);
14691479 },
1470 [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded],
1480 [
1481 dragInProgressRef,
1482 isCompact,
1483 projectExpanded,
1484 projectPreferenceKeys,
1485 setOpen,
1486 setProjectExpanded,
1487 ],
14711488 );
14721489
14731490 const handleProjectButtonPointerDownCapture = useCallback(
......@@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23592376 <>
23602377 <div className="group/project-header relative">
23612378 <SidebarMenuButton
2379 tooltip={project.displayName}
2380 aria-label={project.displayName}
23622381 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 ${
23642383 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : ""
23652384 }`}
23662385 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
......@@ -2370,7 +2389,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23702389 onKeyDown={handleProjectButtonKeyDown}
23712390 onContextMenu={handleProjectButtonContextMenu}
23722391 >
2373 {!projectExpanded && projectStatus ? (
2392 {isCompact ? null : !projectExpanded && projectStatus ? (
23742393 <Tooltip>
23752394 <TooltipTrigger
23762395 render={
......@@ -2401,7 +2420,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
24012420 <span className="flex shrink-0">
24022421 <ProjectFavicon project={project} />
24032422 </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">
24052424 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
24062425 {project.displayName}
24072426 </span>
......@@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
24152434 {/* Environment badge – visible by default, crossfades with the
24162435 "new thread" button on hover using the same pointer-events +
24172436 opacity pattern as the thread row archive/timestamp swap. */}
2418 {project.environmentPresence === "remote-only" && (
2437 {!isCompact && project.environmentPresence === "remote-only" && (
24192438 <Tooltip>
24202439 <TooltipTrigger
24212440 render={
......@@ -2441,7 +2460,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
24412460 <Tooltip>
24422461 <TooltipTrigger
24432462 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">
24452464 <button
24462465 type="button"
24472466 aria-label={`Create new thread in ${project.displayName}`}
......@@ -2674,7 +2693,7 @@ function LocalSecondaryStatus() {
26742693 }
26752694
26762695 return (
2677 <SidebarGroup className="px-2 pt-2 pb-0">
2696 <SidebarGroup className="px-2 pt-2 pb-0 group-data-[collapsible=icon]:hidden">
26782697 {connecting.length > 0 ? (
26792698 <Alert
26802699 variant="default"
......@@ -2978,14 +2997,16 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
29782997 render={
29792998 <SidebarMenuButton
29802999 className="focus-visible:ring-0"
3000 tooltip="Search"
3001 aria-label="Search"
29813002 data-testid="command-palette-trigger"
29823003 />
29833004 }
29843005 >
29853006 <SearchIcon />
2986 <span className="flex-1 truncate">Search</span>
3007 <span className="flex-1 truncate group-data-[collapsible=icon]:hidden">Search</span>
29873008 {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">
29893010 {commandPaletteShortcutLabel}
29903011 </Kbd>
29913012 ) : null}
......@@ -2996,7 +3017,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
29963017 }
29973018 >
29983019 {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">
30003021 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
30013022 <TriangleAlertIcon />
30023023 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
......@@ -3020,17 +3041,21 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
30203041 ) : null}
30213042 <LocalSecondaryStatus />
30223043 <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>
30253048 <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>
30343059 <Tooltip>
30353060 <TooltipTrigger
30363061 render={
......@@ -3127,7 +3152,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
31273152 )}
31283153
31293154 {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>
31313158 )}
31323159 </SidebarGroup>
31333160 </SidebarContent>
apps/web/src/components/Sidebar.drag.ts+9-5
......@@ -103,6 +103,7 @@ export function createSidebarSortingStrategy(input: {
103103 snoozedThreadCount?: number;
104104 cardHeight?: number;
105105 slimHeight?: number;
106 compact?: boolean;
106107 /** Space each pinned boundary opens for its label while dragging. The
107108 * markers stay zero height at rest, so nothing is reserved until pickup. */
108109 boundaryLabelHeight?: number;
......@@ -140,11 +141,14 @@ export function createSidebarSortingStrategy(input: {
140141 else slimHeight ??= rects[index]?.height;
141142 if (item.key !== active.key) groups[item.section].push(item);
142143 }
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;
148152 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
149153 const group = groups[target.section];
150154 const order =
apps/web/src/components/Sidebar.tsx+368-59
......@@ -1,10 +1,12 @@
11import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { useCompactSidebarEnabled } from "../hooks/useSettings";
23import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
34import { useAtomValue } from "@effect/atom-react";
45import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
56import * as Schema from "effect/Schema";
67import {
78 DndContext,
9 DragOverlay,
810 useSensor,
911 useSensors,
1012 type DragEndEvent,
......@@ -72,6 +74,7 @@ import {
7274 type MouseEvent as ReactMouseEvent,
7375 type ReactNode,
7476} from "react";
77import { createPortal } from "react-dom";
7578import { useParams, useRouter } from "@tanstack/react-router";
7679
7780import { useRightPanelStore } from "../rightPanelStore";
......@@ -321,6 +324,7 @@ function SidebarThreadTooltip({
321324 branchMismatch,
322325 terminalStatus,
323326 terminalProcessCount,
327 compactStatus,
324328}: {
325329 thread: SidebarThreadSummary;
326330 project: ProjectFaviconProject | null;
......@@ -337,6 +341,7 @@ function SidebarThreadTooltip({
337341 } | null;
338342 terminalStatus: TerminalStatusIndicator | null;
339343 terminalProcessCount: number;
344 compactStatus?: string | undefined;
340345}) {
341346 const driverKind = providerEntry?.driverKind ?? null;
342347 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
......@@ -353,6 +358,7 @@ function SidebarThreadTooltip({
353358 {thread.title}
354359 </div>
355360 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
361 {compactStatus ? <div>{compactStatus}</div> : null}
356362 {projectDisplayName ? (
357363 <div className="flex min-w-0 items-center gap-2">
358364 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}
......@@ -502,7 +508,7 @@ function SnoozePopoverButton(props: {
502508type SortableThreadRowBag = Pick<
503509 ReturnType<typeof useSortable>,
504510 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
505>;
511> & { hidden?: boolean };
506512
507513function SortableThreadRow(props: {
508514 id: string;
......@@ -583,7 +589,18 @@ function SidebarSectionPlaceholder(props: {
583589 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
584590 )}
585591 >
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 )}
587604 </div>
588605 ) : null}
589606 </SortableSidebarMarker>
......@@ -607,19 +624,30 @@ function SidebarDragBoundary(props: {
607624 className="pointer-events-none relative mx-0.5 -mb-px h-0"
608625 >
609626 {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">
611628 <span
612629 className={cn(
613630 "shrink-0 text-xs font-medium",
614631 props.isDropTarget ? "text-primary" : "text-sidebar-foreground/80",
615632 )}
616633 >
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 )}
618646 </span>
619647 <span
620648 aria-hidden
621649 className={cn(
622 "h-px flex-1",
650 "h-px flex-1 group-data-[collapsible=icon]:hidden",
623651 props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25",
624652 )}
625653 />
......@@ -639,20 +667,32 @@ function SidebarSectionHeader(props: {
639667 isDropTarget?: boolean;
640668 toggle: { expanded: boolean; onToggle: () => void };
641669}) {
670 const compactEnabled = useCompactSidebarEnabled();
671 const { state, isMobile } = useSidebar();
672 const compact = compactEnabled && state === "collapsed" && !isMobile;
642673 const snoozed = props.marker === "snoozed-header";
643674 const className = cn(
644675 "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",
676 compact && "justify-center px-0",
645677 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
646678 props.dragging && "text-sidebar-foreground/80",
647679 props.isDropTarget && "text-primary",
648680 );
649681 const content = (
650682 <>
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}
652691 <span
653692 aria-hidden
654693 className={cn(
655694 "h-px min-w-2 flex-1",
695 compact && "hidden",
656696 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",
657697 props.dragging && "bg-sidebar-foreground/25",
658698 props.isDropTarget && "bg-primary/50",
......@@ -662,6 +702,7 @@ function SidebarSectionHeader(props: {
662702 aria-hidden
663703 className={cn(
664704 "size-3 shrink-0 transition-transform",
705 compact && "hidden",
665706 props.toggle.expanded && "rotate-180",
666707 )}
667708 />
......@@ -673,15 +714,22 @@ function SidebarSectionHeader(props: {
673714 data-testid={`sidebar-${props.marker}`}
674715 className="mx-0.5 h-8"
675716 >
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>
685733 </SortableSidebarMarker>
686734 );
687735}
......@@ -704,6 +752,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
704752 onDiscard: (draftId: DraftId) => void;
705753}) {
706754 const { composer, draftId, onDiscard, onNavigate, session } = props;
755 const compactEnabled = useCompactSidebarEnabled();
756 const { state, isMobile } = useSidebar();
757 const compact = compactEnabled && state === "collapsed" && !isMobile;
707758 const promptPreview =
708759 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
709760 .trim()
......@@ -742,6 +793,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
742793 },
743794 [draftId, onDiscard],
744795 );
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 }
745824 return (
746825 <li className="list-none py-0.5">
747826 <div
......@@ -1031,6 +1110,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10311110 variant,
10321111 variantAction,
10331112 } = props;
1113 const compactEnabled = useCompactSidebarEnabled();
1114 const { state, isMobile } = useSidebar();
1115 const compact = compactEnabled && state === "collapsed" && !isMobile;
10341116 const threadRef = useMemo(
10351117 () => scopeThreadRef(thread.environmentId, thread.id),
10361118 [thread.environmentId, thread.id],
......@@ -1208,6 +1290,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12081290 branchMismatch={branchMismatch}
12091291 terminalStatus={terminalStatus}
12101292 terminalProcessCount={terminalProcessCount}
1293 compactStatus={
1294 compact
1295 ? (topStatus?.label ??
1296 (variantAction === "unsnooze"
1297 ? "Snoozed"
1298 : variantAction === "unsettle"
1299 ? "Settled"
1300 : "Ready"))
1301 : undefined
1302 }
12111303 />
12121304 );
12131305
......@@ -1254,6 +1346,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12541346 [isRenaming, onStartRename, thread.title, threadRef],
12551347 );
12561348 const [isFileDragOver, setIsFileDragOver] = useState(false);
1349 const [tooltipOpen, setTooltipOpen] = useState(false);
12571350 const fileDropHandlers = useMemo(
12581351 () =>
12591352 onFileDropThreads
......@@ -1416,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14161509 // A zero-height boundary also makes dnd-kit scale the source to
14171510 // zero. Only projected peers use scaleY as a visibility sentinel.
14181511 visibility:
1419 !sortable.isDragging && sortable.transform?.scaleY === 0
1512 sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0)
14201513 ? ("hidden" as const)
14211514 : undefined,
14221515 },
......@@ -1557,6 +1650,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15571650 )
15581651 ) : null;
15591652
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
15601724 if (variant === "slim") {
15611725 return (
15621726 <li
......@@ -1569,7 +1733,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15691733 sortable?.isDragging && "relative z-20",
15701734 )}
15711735 >
1572 <Tooltip disabled={sortable?.isDragging}>
1736 <Tooltip
1737 open={!sortable?.isDragging && tooltipOpen}
1738 onOpenChange={setTooltipOpen}
1739 disabled={sortable?.isDragging}
1740 >
15731741 <TooltipTrigger
15741742 render={
15751743 <div
......@@ -1722,7 +1890,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17221890 sortable?.isDragging && "relative z-20",
17231891 )}
17241892 >
1725 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
1893 <Tooltip
1894 open={!snoozeMenuOpen && !sortable?.isDragging && tooltipOpen}
1895 onOpenChange={setTooltipOpen}
1896 disabled={snoozeMenuOpen || sortable?.isDragging}
1897 >
17261898 <TooltipTrigger
17271899 render={
17281900 <div
......@@ -1989,6 +2161,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19892161 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
19902162}) {
19912163 const { thread } = props;
2164 const compactEnabled = useCompactSidebarEnabled();
2165 const { state, isMobile } = useSidebar();
2166 const compact = compactEnabled && state === "collapsed" && !isMobile;
19922167 const threadRef = useMemo(
19932168 () => scopeThreadRef(thread.environmentId, thread.id),
19942169 [thread.environmentId, thread.id],
......@@ -2074,6 +2249,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20742249 onClick={props.onSelect}
20752250 className={cn(
20762251 "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",
20772253 props.isHighlighted || props.isRouteActive
20782254 ? "bg-sidebar-row-active text-sidebar-foreground"
20792255 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
......@@ -2085,9 +2261,15 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20852261 >
20862262 {props.project ? (
20872263 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2264 ) : compact ? (
2265 <SquarePenIcon aria-hidden className="size-4" />
20882266 ) : 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 >
20912273 {threadTimeLabel(thread)}
20922274 </span>
20932275 </TooltipTrigger>
......@@ -2115,7 +2297,10 @@ export default function Sidebar() {
21152297 const projectOrder = useUiStateStore((store) => store.projectOrder);
21162298 const threads = useThreadShells();
21172299 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);
21192304 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
21202305 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
21212306 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
......@@ -2687,6 +2872,7 @@ export default function Sidebar() {
26872872 [setSettledShelfExpanded],
26882873 );
26892874 const renderedSettledThreads = useMemo(() => {
2875 if (compact) return EMPTY_THREADS;
26902876 if (settledShelfExpanded) return visibleSettledThreads;
26912877 if (routeThreadKey === null) return EMPTY_THREADS;
26922878 const routeThread = visibleSettledThreads.find(
......@@ -2694,7 +2880,7 @@ export default function Sidebar() {
26942880 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
26952881 );
26962882 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2697 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
2883 }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
26982884
26992885 // The snoozed shelf is collapsed by default: out of the way, never gone.
27002886 // Collapsed threads don't render (and so don't participate in jump
......@@ -2922,10 +3108,14 @@ export default function Sidebar() {
29223108
29233109 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
29243110 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 );
29293119 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
29303120 const commitThreadRename = useCallback(
29313121 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
......@@ -3087,8 +3277,18 @@ export default function Sidebar() {
30873277 const threadListRef = useRef<HTMLUListElement | null>(null);
30883278 const dragLabelOffsetRef = useRef(0);
30893279 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],
30923292 );
30933293 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
30943294 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
......@@ -3310,7 +3510,7 @@ export default function Sidebar() {
33103510 pinnedThreads.length +
33113511 activeThreads.length +
33123512 snoozedThreads.length +
3313 settledThreads.length ===
3513 (compact ? 0 : settledThreads.length) ===
33143514 0
33153515 ) {
33163516 return [];
......@@ -3326,13 +3526,15 @@ export default function Sidebar() {
33263526 items.push({ kind: "marker", marker: "snoozed-header" });
33273527 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
33283528 }
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 }
33333534 return items;
33343535 }, [
33353536 activeThreads,
3537 compact,
33363538 pinnedThreads,
33373539 renderedSettledThreads,
33383540 settledThreads.length,
......@@ -3402,6 +3604,7 @@ export default function Sidebar() {
34023604 () =>
34033605 createSidebarSortingStrategy({
34043606 items: sidebarListItems,
3607 compact,
34053608 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
34063609 settledOrder: draggedSettledOrder,
34073610 settledExpanded: settledShelfExpanded,
......@@ -3410,6 +3613,7 @@ export default function Sidebar() {
34103613 snoozedThreadCount: snoozedThreads.length,
34113614 }),
34123615 [
3616 compact,
34133617 draggedSettledOrder,
34143618 routeThreadKey,
34153619 settledShelfExpanded,
......@@ -3418,6 +3622,22 @@ export default function Sidebar() {
34183622 snoozedThreads.length,
34193623 ],
34203624 );
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 );
34213641 // Hidden and filtered threads keep their keys. Reserve those slots without
34223642 // including the rows in the visible drop order or writing to them.
34233643 const { pinnedKeysById, activeKeysById } = useMemo(
......@@ -4321,7 +4541,16 @@ export default function Sidebar() {
43214541 <>
43224542 <SidebarChromeHeader isElectron={isElectron} />
43234543 <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 }
43254554 fixedHeader={
43264555 // Lifted above the stage backdrop, whose fade bleeds below the
43274556 // header and would otherwise paint across the search row's outline.
......@@ -4381,8 +4610,12 @@ export default function Sidebar() {
43814610 // popup opens under the field, is at least as wide as it,
43824611 // and grows to fit project names up to a cap, past which
43834612 // 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 )}
43864619 >
43874620 <ComboboxSearchInput
43884621 aria-label="Search projects"
......@@ -4537,7 +4770,10 @@ export default function Sidebar() {
45374770 ) : (
45384771 <p
45394772 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 )}
45414777 >
45424778 No threads found
45434779 </p>
......@@ -4556,14 +4792,17 @@ export default function Sidebar() {
45564792 modifiers={[
45574793 restrictToVerticalAxis,
45584794 restrictBelowPins,
4559 restrictToFirstScrollableAncestor,
4795 ...(compact ? [] : [restrictToFirstScrollableAncestor]),
45604796 ]}
45614797 onDragStart={handleThreadDragStart}
45624798 onDragOver={handleThreadDragOver}
45634799 onDragEnd={handleThreadDragEnd}
45644800 >
45654801 <SidebarDragLifecycle onUnmount={cancelThreadDrag} />
4566 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
4802 <SortableContext
4803 items={sortableIds}
4804 strategy={compact ? compactSidebarSortingStrategy : sidebarSortingStrategy}
4805 >
45674806 <ul
45684807 ref={attachListMotionRef}
45694808 role="list"
......@@ -4693,11 +4932,25 @@ export default function Sidebar() {
46934932 !draggableThreadKeys.has(threadKey) || optimisticDrop !== null
46944933 }
46954934 >
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 }
46974944 </SortableThreadRow>
46984945 );
46994946 };
47004947 const from = dragState?.activeSection ?? null;
4948 const showDragLabels =
4949 from !== null &&
4950 (!compact ||
4951 dragTargetSection === "active" ||
4952 dragTargetSection === "pinned");
4953 const snoozedItems: ReactNode[] = [];
47014954 const items: ReactNode[] = [
47024955 <SidebarDraftBlock
47034956 key="draft-sessions"
......@@ -4709,8 +4962,17 @@ export default function Sidebar() {
47094962 />,
47104963 ];
47114964 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;
47124972 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 );
47144976 continue;
47154977 }
47164978 switch (item.marker) {
......@@ -4720,7 +4982,7 @@ export default function Sidebar() {
47204982 key="pinned-header"
47214983 marker="pinned-header"
47224984 label="Pinned"
4723 visible={from !== null}
4985 visible={showDragLabels}
47244986 isDropTarget={dragTargetSection === "pinned"}
47254987 />,
47264988 );
......@@ -4731,7 +4993,7 @@ export default function Sidebar() {
47314993 key="pinned-divider"
47324994 marker="pinned-divider"
47334995 label="Active"
4734 visible={from !== null}
4996 visible={showDragLabels}
47354997 isDropTarget={dragTargetSection === "active"}
47364998 />,
47374999 );
......@@ -4755,7 +5017,7 @@ export default function Sidebar() {
47555017 );
47565018 break;
47575019 case "snoozed-header":
4758 items.push(
5020 destination.push(
47595021 <SidebarSectionHeader
47605022 key="snoozed-shelf-header"
47615023 marker="snoozed-header"
......@@ -4810,18 +5072,56 @@ export default function Sidebar() {
48105072 break;
48115073 }
48125074 }
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 ];
48145100 })()}
4815 {settledShelfExpanded && hiddenSettledCount > 0 ? (
5101 {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? (
48165102 <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>
48255125 </li>
48265126 ) : null}
48275127 </ul>
......@@ -4836,20 +5136,29 @@ export default function Sidebar() {
48365136 snoozedThreads.length +
48375137 settledThreads.length ===
48385138 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 >
48405145 {projects.length === 0 ? (
48415146 <>
4842 <span>No projects yet</span>
5147 <span className={compact ? "sr-only" : undefined}>No projects yet</span>
48435148 <button
48445149 type="button"
48455150 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 )}
48475156 >
48485157 <PlusIcon className="-mx-0.5 size-3" />
4849 Add project
5158 <span className={compact ? "sr-only" : undefined}>Add project</span>
48505159 </button>
48515160 </>
4852 ) : scopedProjectGroup ? (
5161 ) : compact ? null : scopedProjectGroup ? (
48535162 `No threads in ${scopedProjectGroup.displayName} yet`
48545163 ) : (
48555164 "No threads yet"
apps/web/src/components/settings/CompactSidebarPreview.tsx created+53
......@@ -0,0 +1,53 @@
1import { useState } from "react";
2
3import { cn } from "~/lib/utils";
4
5export 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 {
160160import { searchableSetting } from "./settingsSearch";
161161import { ProjectFavicon } from "../ProjectFavicon";
162162import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
163import { CompactSidebarPreview } from "./CompactSidebarPreview";
163164
164165const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
165166 artwork: "Artwork",
......@@ -501,6 +502,9 @@ export function useSettingsRestore(onRestored?: () => void) {
501502 ...(theme !== "system" ? ["Theme"] : []),
502503 ...(!followSystem ? ["Follow system"] : []),
503504 ...(themeHalves !== null ? ["Theme mix"] : []),
505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled
506 ? ["Compact sidebar"]
507 : []),
504508 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
505509 ? ["Contrast"]
506510 : []),
......@@ -605,6 +609,7 @@ export function useSettingsRestore(onRestored?: () => void) {
605609 settings.browserLinkTarget,
606610 settings.browserAutoShowFloatingPreview,
607611 settings.appearanceContrast,
612 settings.compactSidebarEnabled,
608613 settings.diffColorScheme,
609614 settings.enableAgentBrowserAccess,
610615 settings.confirmQuit,
......@@ -712,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) {
712717 }
713718 updateSettings({
714719 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
720 compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled,
715721 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
716722 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
717723 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
......@@ -1356,6 +1362,27 @@ export function AppearanceSettingsPanel() {
13561362 />
13571363 </SettingsSection>
13581364
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
13591386 <TypographySection />
13601387 </SettingsPageContainer>
13611388 );
apps/web/src/components/settings/SettingsSidebarNav.tsx+26-7
......@@ -24,6 +24,7 @@ import {
2424 XIcon,
2525} from "lucide-react";
2626import { useLocation, useNavigate } from "@tanstack/react-router";
27import { useCompactSidebarEnabled } from "../../hooks/useSettings";
2728
2829import { Button } from "../ui/button";
2930import { Input } from "../ui/input";
......@@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
111112 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
112113 );
113114 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
115 const compactSidebarEnabled = useCompactSidebarEnabled();
114116 const searchInputRef = useRef<HTMLInputElement>(null);
115117 const [query, setQuery] = useState("");
116118 const [activeResultIndex, setActiveResultIndex] = useState(0);
117119 const searchableItems = useAvailableSettingsSearchItems();
118120 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);
120122 const hasResults = results.length > 0;
121123
122124 useEffect(() => {
......@@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
233235 <>
234236 <SidebarContent className="overflow-x-hidden">
235237 <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">
237250 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
238251 <Input
239252 ref={searchInputRef}
......@@ -330,10 +343,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
330343 <SidebarMenuItem key={item.to}>
331344 <SidebarMenuButton
332345 isActive={isActive}
346 aria-label={item.label}
347 tooltip={item.label}
333348 onClick={() => handleSectionClick(item.to)}
334349 >
335350 <Icon />
336 <span className="truncate">{item.label}</span>
351 <span className="truncate group-data-[collapsible=icon]:hidden">
352 {item.label}
353 </span>
337354 </SidebarMenuButton>
338355 </SidebarMenuItem>
339356 );
......@@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
343360 </SidebarGroup>
344361 </SidebarContent>
345362 <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">
350369 <div className="min-w-0 flex-1">
351370 <SidebarUtilityMenu />
352371 </div>
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [
159159 title: "Panel animations",
160160 to: "/settings/appearance",
161161 },
162 {
163 id: "compact-sidebar",
164 title: "Compact sidebar",
165 to: "/settings/appearance",
166 searchTerms: ["collapsed icons rail hover navigation preview"],
167 },
162168 {
163169 id: "environment-identification",
164170 title: "Environment identification",
apps/web/src/components/sidebar/SidebarChrome.tsx+8-6
......@@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
8686 <Link
8787 aria-label="Go to threads"
8888 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",
9090 onBackdrop ? "text-white" : "text-foreground",
9191 )}
9292 to="/"
......@@ -187,12 +187,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
187187 }, [canGoBack, closeMobileSidebar, navigate]);
188188
189189 return (
190 <SidebarMenu className="flex-row items-center">
190 <SidebarMenu className="flex-row items-center group-data-[collapsible=icon]:flex-col">
191191 {currentFooterPage ? (
192192 <SidebarMenuItem className="min-w-0 flex-1">
193 <SidebarMenuButton onClick={handleBackClick}>
193 <SidebarMenuButton onClick={handleBackClick} aria-label="Back" tooltip="Back">
194194 <ArrowLeftIcon />
195 <span>Back</span>
195 <span className="group-data-[collapsible=icon]:hidden">Back</span>
196196 </SidebarMenuButton>
197197 </SidebarMenuItem>
198198 ) : (
......@@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
224224export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
225225 return (
226226 <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>
229231 <SidebarUtilityMenu />
230232 </SidebarFooter>
231233 );
apps/web/src/components/sidebar/SidebarThreadHeader.tsx+27-4
......@@ -20,9 +20,10 @@ import {
2020} from "react";
2121
2222import { cn } from "~/lib/utils";
23import { useCompactSidebarEnabled } from "../../hooks/useSettings";
2324import { Button } from "../ui/button";
2425import { Input } from "../ui/input";
25import { SidebarMenuButton } from "../ui/sidebar";
26import { SidebarMenuButton, useSidebar } from "../ui/sidebar";
2627import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2728
2829export interface SidebarThreadHeaderProps {
......@@ -69,6 +70,9 @@ export function SidebarThreadHeader({
6970 activeSearchResultIndex,
7071 onClearSearch,
7172}: SidebarThreadHeaderProps) {
73 const compactEnabled = useCompactSidebarEnabled();
74 const { state, isMobile, setOpen } = useSidebar();
75 const compact = compactEnabled && state === "collapsed" && !isMobile;
7276 const resultsVisible = isSearching && searchResultCount > 0;
7377 // Results shrink as the query narrows, so the active index can outrun the
7478 // list; pointing aria-activedescendant at a removed option strands the
......@@ -79,10 +83,24 @@ export function SidebarThreadHeader({
7983 : "New thread";
8084
8185 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}
8398 <div
8499 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 )}
86104 >
87105 <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" />
88106 <Input
......@@ -124,7 +142,12 @@ export function SidebarThreadHeader({
124142 </div>
125143 {/* Segmented well: the icons read as one control instead of three loose
126144 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 >
128151 {hasProjects ? (
129152 <>
130153 {projectScope}
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+1-1
......@@ -348,7 +348,7 @@ function SidebarUpdateControl() {
348348 );
349349
350350 return (
351 <SidebarMenuItem className="ml-auto shrink-0">
351 <SidebarMenuItem className="ml-auto shrink-0 group-data-[collapsible=icon]:hidden">
352352 <Popover
353353 handle={releaseNotesPopoverHandle}
354354 onOpenChange={(open, details) => {
apps/web/src/components/ui/sidebar.tsx+3
......@@ -591,9 +591,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
591591function SidebarContent({
592592 className,
593593 fixedHeader,
594 fixedFooter,
594595 ...props
595596}: React.ComponentProps<"div"> & {
596597 fixedHeader?: React.ReactNode;
598 fixedFooter?: React.ReactNode;
597599}) {
598600 return (
599601 <>
......@@ -617,6 +619,7 @@ function SidebarContent({
617619 {...props}
618620 />
619621 </ScrollArea>
622 {fixedFooter ? <div className="w-full shrink-0">{fixedFooter}</div> : null}
620623 </>
621624 );
622625}
apps/web/src/hooks/useSettings.ts+7
......@@ -379,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean {
379379 return settingsHydrated && legacySidebarEnabled;
380380}
381381
382/** Keep the default collapsed sidebar until persisted client settings hydrate. */
383export function useCompactSidebarEnabled(): boolean {
384 const settingsHydrated = useClientSettingsHydrated();
385 const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled;
386 return settingsHydrated && compactSidebarEnabled;
387}
388
382389/** Read current settings for one environment, merged with client-local preferences. */
383390export function useEnvironmentSettings<T = UnifiedSettings>(
384391 environmentId: EnvironmentId,
apps/web/src/workspaceTitlebar.ts+1-1
......@@ -1,2 +1,2 @@
11export 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({
436436 // old keys, so everyone, including prior beta opt-outs, resets to the new
437437 // default sidebar.
438438 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
439 compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
439440 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
440441 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
441442 ),
......@@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({
14901491 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
14911492 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
14921493 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
1494 compactSidebarEnabled: Schema.optionalKey(Schema.Boolean),
14931495 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
14941496 sidebarProjectGroupingOverrides: Schema.optionalKey(
14951497 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),