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...@@ -14,7 +14,11 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt
14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";14import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
15import { cn, isMacPlatform } from "../lib/utils";15import { cn, isMacPlatform } from "../lib/utils";
16import { primaryServerKeybindingsAtom } from "../state/server";16import { primaryServerKeybindingsAtom } from "../state/server";
17import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";17import {
18 useCompactSidebarEnabled,
19 useEnvironmentIdentificationMode,
20 useLegacySidebarEnabled,
21} from "../hooks/useSettings";
18import {22import {
19 PanelAnimationSuppressionProvider,23 PanelAnimationSuppressionProvider,
20 usePanelAnimationSettings,24 usePanelAnimationSettings,
...@@ -144,6 +148,7 @@ function ProjectProjectionRetention() {...@@ -144,6 +148,7 @@ function ProjectProjectionRetention() {
144export function AppSidebarLayout({ children }: { children: ReactNode }) {148export function AppSidebarLayout({ children }: { children: ReactNode }) {
145 const navigate = useNavigate();149 const navigate = useNavigate();
146 const legacySidebarEnabled = useLegacySidebarEnabled();150 const legacySidebarEnabled = useLegacySidebarEnabled();
151 const compactSidebarEnabled = useCompactSidebarEnabled();
147 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =152 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
148 usePanelAnimationSettings();153 usePanelAnimationSettings();
149 // Settings routes show the settings nav in place of whichever thread154 // Settings routes show the settings nav in place of whichever thread
...@@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
229 <ProjectProjectionRetention />234 <ProjectProjectionRetention />
230 <Sidebar235 <Sidebar
231 side="left"236 side="left"
232 collapsible="offcanvas"237 collapsible={compactSidebarEnabled ? "icon" : "offcanvas"}
233 data-app-sidebar=""238 data-app-sidebar=""
234 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"239 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
235 resizable={{240 resizable={{
apps/web/src/components/LegacySidebar.tsx+52-25
...@@ -199,7 +199,11 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom...@@ -199,7 +199,11 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom
199import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";199import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
200import { useIsMobile } from "~/hooks/useMediaQuery";200import { useIsMobile } from "~/hooks/useMediaQuery";
201import { CommandDialogTrigger } from "./ui/command";201import { CommandDialogTrigger } from "./ui/command";
202import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";202import {
203 useClientSettings,
204 useCompactSidebarEnabled,
205 useUpdateClientSettings,
206} from "~/hooks/useSettings";
203import { primaryServerKeybindingsAtom } from "../state/server";207import { primaryServerKeybindingsAtom } from "../state/server";
204import {208import {
205 derivePhysicalProjectKey,209 derivePhysicalProjectKey,
...@@ -1194,7 +1198,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1194,7 +1198,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1194 const router = useRouter();1198 const router = useRouter();
1195 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);1199 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
1196 const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);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 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);1204 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
1199 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);1205 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
1200 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);1206 const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
...@@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1445 if (useThreadSelectionStore.getState().hasSelection()) {1451 if (useThreadSelectionStore.getState().hasSelection()) {
1446 clearSelection();1452 clearSelection();
1447 }1453 }
1448 setProjectExpanded(projectPreferenceKeys, !projectExpanded);1454 setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded);
1455 if (isCompact) setOpen(true);
1449 },1456 },
1450 [1457 [
1451 clearSelection,1458 clearSelection,
1459 isCompact,
1460 setOpen,
1452 dragInProgressRef,1461 dragInProgressRef,
1453 projectExpanded,1462 projectExpanded,
1454 projectPreferenceKeys,1463 projectPreferenceKeys,
...@@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1465 if (dragInProgressRef.current) {1474 if (dragInProgressRef.current) {
1466 return;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 );
14721489
1473 const handleProjectButtonPointerDownCapture = useCallback(1490 const handleProjectButtonPointerDownCapture = useCallback(
...@@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2359 <>2376 <>
2360 <div className="group/project-header relative">2377 <div className="group/project-header relative">
2361 <SidebarMenuButton2378 <SidebarMenuButton
2379 tooltip={project.displayName}
2380 aria-label={project.displayName}
2362 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}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 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : ""2383 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : ""
2365 }`}2384 }`}
2366 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}2385 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
...@@ -2370,7 +2389,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2370,7 +2389,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2370 onKeyDown={handleProjectButtonKeyDown}2389 onKeyDown={handleProjectButtonKeyDown}
2371 onContextMenu={handleProjectButtonContextMenu}2390 onContextMenu={handleProjectButtonContextMenu}
2372 >2391 >
2373 {!projectExpanded && projectStatus ? (2392 {isCompact ? null : !projectExpanded && projectStatus ? (
2374 <Tooltip>2393 <Tooltip>
2375 <TooltipTrigger2394 <TooltipTrigger
2376 render={2395 render={
...@@ -2401,7 +2420,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2401,7 +2420,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2401 <span className="flex shrink-0">2420 <span className="flex shrink-0">
2402 <ProjectFavicon project={project} />2421 <ProjectFavicon project={project} />
2403 </span>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 <span className="truncate text-sm font-medium text-sidebar-foreground/90">2424 <span className="truncate text-sm font-medium text-sidebar-foreground/90">
2406 {project.displayName}2425 {project.displayName}
2407 </span>2426 </span>
...@@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2415 {/* Environment badge – visible by default, crossfades with the2434 {/* Environment badge – visible by default, crossfades with the
2416 "new thread" button on hover using the same pointer-events +2435 "new thread" button on hover using the same pointer-events +
2417 opacity pattern as the thread row archive/timestamp swap. */}2436 opacity pattern as the thread row archive/timestamp swap. */}
2418 {project.environmentPresence === "remote-only" && (2437 {!isCompact && project.environmentPresence === "remote-only" && (
2419 <Tooltip>2438 <Tooltip>
2420 <TooltipTrigger2439 <TooltipTrigger
2421 render={2440 render={
...@@ -2441,7 +2460,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2441,7 +2460,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2441 <Tooltip>2460 <Tooltip>
2442 <TooltipTrigger2461 <TooltipTrigger
2443 render={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 <button2464 <button
2446 type="button"2465 type="button"
2447 aria-label={`Create new thread in ${project.displayName}`}2466 aria-label={`Create new thread in ${project.displayName}`}
...@@ -2674,7 +2693,7 @@ function LocalSecondaryStatus() {...@@ -2674,7 +2693,7 @@ function LocalSecondaryStatus() {
2674 }2693 }
26752694
2676 return (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 {connecting.length > 0 ? (2697 {connecting.length > 0 ? (
2679 <Alert2698 <Alert
2680 variant="default"2699 variant="default"
...@@ -2978,14 +2997,16 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2978,14 +2997,16 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2978 render={2997 render={
2979 <SidebarMenuButton2998 <SidebarMenuButton
2980 className="focus-visible:ring-0"2999 className="focus-visible:ring-0"
3000 tooltip="Search"
3001 aria-label="Search"
2981 data-testid="command-palette-trigger"3002 data-testid="command-palette-trigger"
2982 />3003 />
2983 }3004 }
2984 >3005 >
2985 <SearchIcon />3006 <SearchIcon />
2986 <span className="flex-1 truncate">Search</span>3007 <span className="flex-1 truncate group-data-[collapsible=icon]:hidden">Search</span>
2987 {commandPaletteShortcutLabel ? (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 {commandPaletteShortcutLabel}3010 {commandPaletteShortcutLabel}
2990 </Kbd>3011 </Kbd>
2991 ) : null}3012 ) : null}
...@@ -2996,7 +3017,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2996,7 +3017,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2996 }3017 }
2997 >3018 >
2998 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (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 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">3021 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
3001 <TriangleAlertIcon />3022 <TriangleAlertIcon />
3002 <AlertTitle>Intel build on Apple Silicon</AlertTitle>3023 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
...@@ -3020,17 +3041,21 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -3020,17 +3041,21 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
3020 ) : null}3041 ) : null}
3021 <LocalSecondaryStatus />3042 <LocalSecondaryStatus />
3022 <SidebarGroup className="px-2 py-2">3043 <SidebarGroup className="px-2 py-2">
3023 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">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">
3024 <span className="text-xs font-medium text-sidebar-muted-foreground/80">Projects</span>3045 <span className="text-xs font-medium text-sidebar-muted-foreground/80 group-data-[collapsible=icon]:hidden">
3046 Projects
3047 </span>
3025 <div className="flex items-center gap-1">3048 <div className="flex items-center gap-1">
3026 <ProjectSortMenu3049 <div className="group-data-[collapsible=icon]:hidden">
3027 projectSortOrder={projectSortOrder}3050 <ProjectSortMenu
3028 threadSortOrder={threadSortOrder}3051 projectSortOrder={projectSortOrder}
3029 threadPreviewCount={threadPreviewCount}3052 threadSortOrder={threadSortOrder}
3030 onProjectSortOrderChange={handleProjectSortOrderChange}3053 threadPreviewCount={threadPreviewCount}
3031 onThreadSortOrderChange={handleThreadSortOrderChange}3054 onProjectSortOrderChange={handleProjectSortOrderChange}
3032 onThreadPreviewCountChange={handleThreadPreviewCountChange}3055 onThreadSortOrderChange={handleThreadSortOrderChange}
3033 />3056 onThreadPreviewCountChange={handleThreadPreviewCountChange}
3057 />
3058 </div>
3034 <Tooltip>3059 <Tooltip>
3035 <TooltipTrigger3060 <TooltipTrigger
3036 render={3061 render={
...@@ -3127,7 +3152,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -3127,7 +3152,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
3127 )}3152 )}
31283153
3129 {projectsLength === 0 && (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 </SidebarGroup>3159 </SidebarGroup>
3133 </SidebarContent>3160 </SidebarContent>
apps/web/src/components/Sidebar.drag.ts+9-5
...@@ -103,6 +103,7 @@ export function createSidebarSortingStrategy(input: {...@@ -103,6 +103,7 @@ export function createSidebarSortingStrategy(input: {
103 snoozedThreadCount?: number;103 snoozedThreadCount?: number;
104 cardHeight?: number;104 cardHeight?: number;
105 slimHeight?: number;105 slimHeight?: number;
106 compact?: boolean;
106 /** Space each pinned boundary opens for its label while dragging. The107 /** Space each pinned boundary opens for its label while dragging. The
107 * markers stay zero height at rest, so nothing is reserved until pickup. */108 * markers stay zero height at rest, so nothing is reserved until pickup. */
108 boundaryLabelHeight?: number;109 boundaryLabelHeight?: number;
...@@ -140,11 +141,14 @@ export function createSidebarSortingStrategy(input: {...@@ -140,11 +141,14 @@ export function createSidebarSortingStrategy(input: {
140 else slimHeight ??= rects[index]?.height;141 else slimHeight ??= rects[index]?.height;
141 if (item.key !== active.key) groups[item.section].push(item);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 // Compact icons use h-7; expanded cards include their vertical padding.
144 const scale =145 const scale = input.compact
145 slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82);146 ? (cardHeight ?? slimHeight ?? 28) / 28
146 cardHeight ??= 82 * scale;147 : slimHeight !== undefined
147 slimHeight ??= 36 * scale;148 ? slimHeight / 36
149 : (headerScale ?? (cardHeight ?? 82) / 82);
150 cardHeight ??= (input.compact ? 28 : 82) * scale;
151 slimHeight ??= (input.compact ? 28 : 36) * scale;
148 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;152 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
149 const group = groups[target.section];153 const group = groups[target.section];
150 const order =154 const order =
apps/web/src/components/Sidebar.tsx+368-59
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { useCompactSidebarEnabled } from "../hooks/useSettings";
2import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";3import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
3import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
4import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";5import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
6import {7import {
7 DndContext,8 DndContext,
9 DragOverlay,
8 useSensor,10 useSensor,
9 useSensors,11 useSensors,
10 type DragEndEvent,12 type DragEndEvent,
...@@ -72,6 +74,7 @@ import {...@@ -72,6 +74,7 @@ import {
72 type MouseEvent as ReactMouseEvent,74 type MouseEvent as ReactMouseEvent,
73 type ReactNode,75 type ReactNode,
74} from "react";76} from "react";
77import { createPortal } from "react-dom";
75import { useParams, useRouter } from "@tanstack/react-router";78import { useParams, useRouter } from "@tanstack/react-router";
7679
77import { useRightPanelStore } from "../rightPanelStore";80import { useRightPanelStore } from "../rightPanelStore";
...@@ -321,6 +324,7 @@ function SidebarThreadTooltip({...@@ -321,6 +324,7 @@ function SidebarThreadTooltip({
321 branchMismatch,324 branchMismatch,
322 terminalStatus,325 terminalStatus,
323 terminalProcessCount,326 terminalProcessCount,
327 compactStatus,
324}: {328}: {
325 thread: SidebarThreadSummary;329 thread: SidebarThreadSummary;
326 project: ProjectFaviconProject | null;330 project: ProjectFaviconProject | null;
...@@ -337,6 +341,7 @@ function SidebarThreadTooltip({...@@ -337,6 +341,7 @@ function SidebarThreadTooltip({
337 } | null;341 } | null;
338 terminalStatus: TerminalStatusIndicator | null;342 terminalStatus: TerminalStatusIndicator | null;
339 terminalProcessCount: number;343 terminalProcessCount: number;
344 compactStatus?: string | undefined;
340}) {345}) {
341 const driverKind = providerEntry?.driverKind ?? null;346 const driverKind = providerEntry?.driverKind ?? null;
342 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);347 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
...@@ -353,6 +358,7 @@ function SidebarThreadTooltip({...@@ -353,6 +358,7 @@ function SidebarThreadTooltip({
353 {thread.title}358 {thread.title}
354 </div>359 </div>
355 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">360 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
361 {compactStatus ? <div>{compactStatus}</div> : null}
356 {projectDisplayName ? (362 {projectDisplayName ? (
357 <div className="flex min-w-0 items-center gap-2">363 <div className="flex min-w-0 items-center gap-2">
358 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}364 {project ? <ProjectFavicon project={project} className="size-3 shrink-0" /> : null}
...@@ -502,7 +508,7 @@ function SnoozePopoverButton(props: {...@@ -502,7 +508,7 @@ function SnoozePopoverButton(props: {
502type SortableThreadRowBag = Pick<508type SortableThreadRowBag = Pick<
503 ReturnType<typeof useSortable>,509 ReturnType<typeof useSortable>,
504 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"510 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
505>;511> & { hidden?: boolean };
506512
507function SortableThreadRow(props: {513function SortableThreadRow(props: {
508 id: string;514 id: string;
...@@ -583,7 +589,18 @@ function SidebarSectionPlaceholder(props: {...@@ -583,7 +589,18 @@ function SidebarSectionPlaceholder(props: {
583 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",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 </div>604 </div>
588 ) : null}605 ) : null}
589 </SortableSidebarMarker>606 </SortableSidebarMarker>
...@@ -607,19 +624,30 @@ function SidebarDragBoundary(props: {...@@ -607,19 +624,30 @@ function SidebarDragBoundary(props: {
607 className="pointer-events-none relative mx-0.5 -mb-px h-0"624 className="pointer-events-none relative mx-0.5 -mb-px h-0"
608 >625 >
609 {props.visible ? (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 <span628 <span
612 className={cn(629 className={cn(
613 "shrink-0 text-xs font-medium",630 "shrink-0 text-xs font-medium",
614 props.isDropTarget ? "text-primary" : "text-sidebar-foreground/80",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 </span>646 </span>
619 <span647 <span
620 aria-hidden648 aria-hidden
621 className={cn(649 className={cn(
622 "h-px flex-1",650 "h-px flex-1 group-data-[collapsible=icon]:hidden",
623 props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25",651 props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25",
624 )}652 )}
625 />653 />
...@@ -639,20 +667,32 @@ function SidebarSectionHeader(props: {...@@ -639,20 +667,32 @@ function SidebarSectionHeader(props: {
639 isDropTarget?: boolean;667 isDropTarget?: boolean;
640 toggle: { expanded: boolean; onToggle: () => void };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 const snoozed = props.marker === "snoozed-header";673 const snoozed = props.marker === "snoozed-header";
643 const className = cn(674 const className = cn(
644 "flex h-full w-full items-center gap-2 px-2 text-left text-xs font-medium",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 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",677 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
646 props.dragging && "text-sidebar-foreground/80",678 props.dragging && "text-sidebar-foreground/80",
647 props.isDropTarget && "text-primary",679 props.isDropTarget && "text-primary",
648 );680 );
649 const content = (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 <span691 <span
653 aria-hidden692 aria-hidden
654 className={cn(693 className={cn(
655 "h-px min-w-2 flex-1",694 "h-px min-w-2 flex-1",
695 compact && "hidden",
656 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",696 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",
657 props.dragging && "bg-sidebar-foreground/25",697 props.dragging && "bg-sidebar-foreground/25",
658 props.isDropTarget && "bg-primary/50",698 props.isDropTarget && "bg-primary/50",
...@@ -662,6 +702,7 @@ function SidebarSectionHeader(props: {...@@ -662,6 +702,7 @@ function SidebarSectionHeader(props: {
662 aria-hidden702 aria-hidden
663 className={cn(703 className={cn(
664 "size-3 shrink-0 transition-transform",704 "size-3 shrink-0 transition-transform",
705 compact && "hidden",
665 props.toggle.expanded && "rotate-180",706 props.toggle.expanded && "rotate-180",
666 )}707 )}
667 />708 />
...@@ -673,15 +714,22 @@ function SidebarSectionHeader(props: {...@@ -673,15 +714,22 @@ function SidebarSectionHeader(props: {
673 data-testid={`sidebar-${props.marker}`}714 data-testid={`sidebar-${props.marker}`}
674 className="mx-0.5 h-8"715 className="mx-0.5 h-8"
675 >716 >
676 <button717 <Tooltip disabled={!compact}>
677 type="button"718 <TooltipTrigger
678 onClick={props.toggle.onToggle}719 render={
679 aria-expanded={props.toggle.expanded}720 <button
680 data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}721 type="button"
681 className={cn(className, "cursor-pointer")}722 onClick={props.toggle.onToggle}
682 >723 aria-expanded={props.toggle.expanded}
683 {content}724 data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}
684 </button>725 className={cn(className, "cursor-pointer")}
726 />
727 }
728 >
729 {content}
730 </TooltipTrigger>
731 <TooltipPopup side="right">{props.label}</TooltipPopup>
732 </Tooltip>
685 </SortableSidebarMarker>733 </SortableSidebarMarker>
686 );734 );
687}735}
...@@ -704,6 +752,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -704,6 +752,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
704 onDiscard: (draftId: DraftId) => void;752 onDiscard: (draftId: DraftId) => void;
705}) {753}) {
706 const { composer, draftId, onDiscard, onNavigate, session } = props;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 const promptPreview =758 const promptPreview =
708 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)759 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
709 .trim()760 .trim()
...@@ -742,6 +793,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -742,6 +793,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
742 },793 },
743 [draftId, onDiscard],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 return (824 return (
746 <li className="list-none py-0.5">825 <li className="list-none py-0.5">
747 <div826 <div
...@@ -1031,6 +1110,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1031,6 +1110,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1031 variant,1110 variant,
1032 variantAction,1111 variantAction,
1033 } = props;1112 } = props;
1113 const compactEnabled = useCompactSidebarEnabled();
1114 const { state, isMobile } = useSidebar();
1115 const compact = compactEnabled && state === "collapsed" && !isMobile;
1034 const threadRef = useMemo(1116 const threadRef = useMemo(
1035 () => scopeThreadRef(thread.environmentId, thread.id),1117 () => scopeThreadRef(thread.environmentId, thread.id),
1036 [thread.environmentId, thread.id],1118 [thread.environmentId, thread.id],
...@@ -1208,6 +1290,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1208,6 +1290,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1208 branchMismatch={branchMismatch}1290 branchMismatch={branchMismatch}
1209 terminalStatus={terminalStatus}1291 terminalStatus={terminalStatus}
1210 terminalProcessCount={terminalProcessCount}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 );
12131305
...@@ -1254,6 +1346,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1254,6 +1346,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1254 [isRenaming, onStartRename, thread.title, threadRef],1346 [isRenaming, onStartRename, thread.title, threadRef],
1255 );1347 );
1256 const [isFileDragOver, setIsFileDragOver] = useState(false);1348 const [isFileDragOver, setIsFileDragOver] = useState(false);
1349 const [tooltipOpen, setTooltipOpen] = useState(false);
1257 const fileDropHandlers = useMemo(1350 const fileDropHandlers = useMemo(
1258 () =>1351 () =>
1259 onFileDropThreads1352 onFileDropThreads
...@@ -1416,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1416,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1416 // A zero-height boundary also makes dnd-kit scale the source to1509 // A zero-height boundary also makes dnd-kit scale the source to
1417 // zero. Only projected peers use scaleY as a visibility sentinel.1510 // zero. Only projected peers use scaleY as a visibility sentinel.
1418 visibility:1511 visibility:
1419 !sortable.isDragging && sortable.transform?.scaleY === 01512 sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0)
1420 ? ("hidden" as const)1513 ? ("hidden" as const)
1421 : undefined,1514 : undefined,
1422 },1515 },
...@@ -1557,6 +1650,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1557,6 +1650,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1557 )1650 )
1558 ) : null;1651 ) : 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
1560 if (variant === "slim") {1724 if (variant === "slim") {
1561 return (1725 return (
1562 <li1726 <li
...@@ -1569,7 +1733,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1569,7 +1733,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1569 sortable?.isDragging && "relative z-20",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 <TooltipTrigger1741 <TooltipTrigger
1574 render={1742 render={
1575 <div1743 <div
...@@ -1722,7 +1890,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1722,7 +1890,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1722 sortable?.isDragging && "relative z-20",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 <TooltipTrigger1898 <TooltipTrigger
1727 render={1899 render={
1728 <div1900 <div
...@@ -1989,6 +2161,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -1989,6 +2161,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
1989 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;2161 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
1990}) {2162}) {
1991 const { thread } = props;2163 const { thread } = props;
2164 const compactEnabled = useCompactSidebarEnabled();
2165 const { state, isMobile } = useSidebar();
2166 const compact = compactEnabled && state === "collapsed" && !isMobile;
1992 const threadRef = useMemo(2167 const threadRef = useMemo(
1993 () => scopeThreadRef(thread.environmentId, thread.id),2168 () => scopeThreadRef(thread.environmentId, thread.id),
1994 [thread.environmentId, thread.id],2169 [thread.environmentId, thread.id],
...@@ -2074,6 +2249,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2074,6 +2249,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2074 onClick={props.onSelect}2249 onClick={props.onSelect}
2075 className={cn(2250 className={cn(
2076 "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",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 props.isHighlighted || props.isRouteActive2253 props.isHighlighted || props.isRouteActive
2078 ? "bg-sidebar-row-active text-sidebar-foreground"2254 ? "bg-sidebar-row-active text-sidebar-foreground"
2079 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",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,9 +2261,15 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2085 >2261 >
2086 {props.project ? (2262 {props.project ? (
2087 <ProjectFavicon project={props.project} className="size-4 shrink-0" />2263 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2264 ) : compact ? (
2265 <SquarePenIcon aria-hidden className="size-4" />
2088 ) : null}2266 ) : null}
2089 <span className="min-w-0 flex-1 truncate">{thread.title}</span>2267 <span className={compact ? "sr-only" : "min-w-0 flex-1 truncate"}>{thread.title}</span>
2090 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">2268 <span
2269 className={
2270 compact ? "sr-only" : "shrink-0 text-xs text-muted-foreground/55 tabular-nums"
2271 }
2272 >
2091 {threadTimeLabel(thread)}2273 {threadTimeLabel(thread)}
2092 </span>2274 </span>
2093 </TooltipTrigger>2275 </TooltipTrigger>
...@@ -2115,7 +2297,10 @@ export default function Sidebar() {...@@ -2115,7 +2297,10 @@ export default function Sidebar() {
2115 const projectOrder = useUiStateStore((store) => store.projectOrder);2297 const projectOrder = useUiStateStore((store) => store.projectOrder);
2116 const threads = useThreadShells();2298 const threads = useThreadShells();
2117 const router = useRouter();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 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2304 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2120 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);2305 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
2121 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);2306 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
...@@ -2687,6 +2872,7 @@ export default function Sidebar() {...@@ -2687,6 +2872,7 @@ export default function Sidebar() {
2687 [setSettledShelfExpanded],2872 [setSettledShelfExpanded],
2688 );2873 );
2689 const renderedSettledThreads = useMemo(() => {2874 const renderedSettledThreads = useMemo(() => {
2875 if (compact) return EMPTY_THREADS;
2690 if (settledShelfExpanded) return visibleSettledThreads;2876 if (settledShelfExpanded) return visibleSettledThreads;
2691 if (routeThreadKey === null) return EMPTY_THREADS;2877 if (routeThreadKey === null) return EMPTY_THREADS;
2692 const routeThread = visibleSettledThreads.find(2878 const routeThread = visibleSettledThreads.find(
...@@ -2694,7 +2880,7 @@ export default function Sidebar() {...@@ -2694,7 +2880,7 @@ export default function Sidebar() {
2694 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,2880 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
2695 );2881 );
2696 return routeThread === undefined ? EMPTY_THREADS : [routeThread];2882 return routeThread === undefined ? EMPTY_THREADS : [routeThread];
2697 }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);2883 }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
26982884
2699 // The snoozed shelf is collapsed by default: out of the way, never gone.2885 // The snoozed shelf is collapsed by default: out of the way, never gone.
2700 // Collapsed threads don't render (and so don't participate in jump2886 // Collapsed threads don't render (and so don't participate in jump
...@@ -2922,10 +3108,14 @@ export default function Sidebar() {...@@ -2922,10 +3108,14 @@ export default function Sidebar() {
29223108
2923 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);3109 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
2924 const [renamingTitle, setRenamingTitle] = useState("");3110 const [renamingTitle, setRenamingTitle] = useState("");
2925 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {3111 const startThreadRename = useCallback(
2926 setRenamingThreadKey(scopedThreadKey(threadRef));3112 (threadRef: ScopedThreadRef, title: string) => {
2927 setRenamingTitle(title);3113 if (compact) setOpen(true);
2928 }, []);3114 setRenamingThreadKey(scopedThreadKey(threadRef));
3115 setRenamingTitle(title);
3116 },
3117 [compact, setOpen],
3118 );
2929 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);3119 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
2930 const commitThreadRename = useCallback(3120 const commitThreadRename = useCallback(
2931 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {3121 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
...@@ -3087,8 +3277,18 @@ export default function Sidebar() {...@@ -3087,8 +3277,18 @@ export default function Sidebar() {
3087 const threadListRef = useRef<HTMLUListElement | null>(null);3277 const threadListRef = useRef<HTMLUListElement | null>(null);
3088 const dragLabelOffsetRef = useRef(0);3278 const dragLabelOffsetRef = useRef(0);
3089 const restrictBelowPins = useCallback<Modifier>(3279 const restrictBelowPins = useCallback<Modifier>(
3090 (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current),3280 (args) =>
3091 [],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 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);3293 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
3094 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {3294 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
...@@ -3310,7 +3510,7 @@ export default function Sidebar() {...@@ -3310,7 +3510,7 @@ export default function Sidebar() {
3310 pinnedThreads.length +3510 pinnedThreads.length +
3311 activeThreads.length +3511 activeThreads.length +
3312 snoozedThreads.length +3512 snoozedThreads.length +
3313 settledThreads.length ===3513 (compact ? 0 : settledThreads.length) ===
3314 03514 0
3315 ) {3515 ) {
3316 return [];3516 return [];
...@@ -3326,13 +3526,15 @@ export default function Sidebar() {...@@ -3326,13 +3526,15 @@ export default function Sidebar() {
3326 items.push({ kind: "marker", marker: "snoozed-header" });3526 items.push({ kind: "marker", marker: "snoozed-header" });
3327 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));3527 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
3328 }3528 }
3329 items.push({ kind: "marker", marker: "settled-header" });3529 if (!compact) {
3330 const settledRows = rowsOf(renderedSettledThreads, "settled");3530 items.push({ kind: "marker", marker: "settled-header" });
3331 items.push({ kind: "marker", marker: "settled-placeholder" });3531 items.push({ kind: "marker", marker: "settled-placeholder" });
3332 items.push(...settledRows);3532 items.push(...rowsOf(renderedSettledThreads, "settled"));
3533 }
3333 return items;3534 return items;
3334 }, [3535 }, [
3335 activeThreads,3536 activeThreads,
3537 compact,
3336 pinnedThreads,3538 pinnedThreads,
3337 renderedSettledThreads,3539 renderedSettledThreads,
3338 settledThreads.length,3540 settledThreads.length,
...@@ -3402,6 +3604,7 @@ export default function Sidebar() {...@@ -3402,6 +3604,7 @@ export default function Sidebar() {
3402 () =>3604 () =>
3403 createSidebarSortingStrategy({3605 createSidebarSortingStrategy({
3404 items: sidebarListItems,3606 items: sidebarListItems,
3607 compact,
3405 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,3608 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
3406 settledOrder: draggedSettledOrder,3609 settledOrder: draggedSettledOrder,
3407 settledExpanded: settledShelfExpanded,3610 settledExpanded: settledShelfExpanded,
...@@ -3410,6 +3613,7 @@ export default function Sidebar() {...@@ -3410,6 +3613,7 @@ export default function Sidebar() {
3410 snoozedThreadCount: snoozedThreads.length,3613 snoozedThreadCount: snoozedThreads.length,
3411 }),3614 }),
3412 [3615 [
3616 compact,
3413 draggedSettledOrder,3617 draggedSettledOrder,
3414 routeThreadKey,3618 routeThreadKey,
3415 settledShelfExpanded,3619 settledShelfExpanded,
...@@ -3418,6 +3622,22 @@ export default function Sidebar() {...@@ -3418,6 +3622,22 @@ export default function Sidebar() {
3418 snoozedThreads.length,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 // Hidden and filtered threads keep their keys. Reserve those slots without3641 // Hidden and filtered threads keep their keys. Reserve those slots without
3422 // including the rows in the visible drop order or writing to them.3642 // including the rows in the visible drop order or writing to them.
3423 const { pinnedKeysById, activeKeysById } = useMemo(3643 const { pinnedKeysById, activeKeysById } = useMemo(
...@@ -4321,7 +4541,16 @@ export default function Sidebar() {...@@ -4321,7 +4541,16 @@ export default function Sidebar() {
4321 <>4541 <>
4322 <SidebarChromeHeader isElectron={isElectron} />4542 <SidebarChromeHeader isElectron={isElectron} />
4323 <SidebarContent4543 <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 fixedHeader={4554 fixedHeader={
4326 // Lifted above the stage backdrop, whose fade bleeds below the4555 // Lifted above the stage backdrop, whose fade bleeds below the
4327 // header and would otherwise paint across the search row's outline.4556 // header and would otherwise paint across the search row's outline.
...@@ -4381,8 +4610,12 @@ export default function Sidebar() {...@@ -4381,8 +4610,12 @@ export default function Sidebar() {
4381 // popup opens under the field, is at least as wide as it,4610 // popup opens under the field, is at least as wide as it,
4382 // and grows to fit project names up to a cap, past which4611 // and grows to fit project names up to a cap, past which
4383 // the rows truncate.4612 // the rows truncate.
4384 anchor={headerSearchRef}4613 anchor={compact ? undefined : headerSearchRef}
4385 className="max-w-[min(18rem,var(--available-width))] overflow-hidden"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 <ComboboxSearchInput4620 <ComboboxSearchInput
4388 aria-label="Search projects"4621 aria-label="Search projects"
...@@ -4537,7 +4770,10 @@ export default function Sidebar() {...@@ -4537,7 +4770,10 @@ export default function Sidebar() {
4537 ) : (4770 ) : (
4538 <p4771 <p
4539 role="status"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 No threads found4778 No threads found
4543 </p>4779 </p>
...@@ -4556,14 +4792,17 @@ export default function Sidebar() {...@@ -4556,14 +4792,17 @@ export default function Sidebar() {
4556 modifiers={[4792 modifiers={[
4557 restrictToVerticalAxis,4793 restrictToVerticalAxis,
4558 restrictBelowPins,4794 restrictBelowPins,
4559 restrictToFirstScrollableAncestor,4795 ...(compact ? [] : [restrictToFirstScrollableAncestor]),
4560 ]}4796 ]}
4561 onDragStart={handleThreadDragStart}4797 onDragStart={handleThreadDragStart}
4562 onDragOver={handleThreadDragOver}4798 onDragOver={handleThreadDragOver}
4563 onDragEnd={handleThreadDragEnd}4799 onDragEnd={handleThreadDragEnd}
4564 >4800 >
4565 <SidebarDragLifecycle onUnmount={cancelThreadDrag} />4801 <SidebarDragLifecycle onUnmount={cancelThreadDrag} />
4566 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>4802 <SortableContext
4803 items={sortableIds}
4804 strategy={compact ? compactSidebarSortingStrategy : sidebarSortingStrategy}
4805 >
4567 <ul4806 <ul
4568 ref={attachListMotionRef}4807 ref={attachListMotionRef}
4569 role="list"4808 role="list"
...@@ -4693,11 +4932,25 @@ export default function Sidebar() {...@@ -4693,11 +4932,25 @@ export default function Sidebar() {
4693 !draggableThreadKeys.has(threadKey) || optimisticDrop !== null4932 !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 </SortableThreadRow>4944 </SortableThreadRow>
4698 );4945 );
4699 };4946 };
4700 const from = dragState?.activeSection ?? null;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 const items: ReactNode[] = [4954 const items: ReactNode[] = [
4702 <SidebarDraftBlock4955 <SidebarDraftBlock
4703 key="draft-sessions"4956 key="draft-sessions"
...@@ -4709,8 +4962,17 @@ export default function Sidebar() {...@@ -4709,8 +4962,17 @@ export default function Sidebar() {
4709 />,4962 />,
4710 ];4963 ];
4711 for (const item of sidebarListItems) {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 if (item.kind === "thread") {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 continue;4976 continue;
4715 }4977 }
4716 switch (item.marker) {4978 switch (item.marker) {
...@@ -4720,7 +4982,7 @@ export default function Sidebar() {...@@ -4720,7 +4982,7 @@ export default function Sidebar() {
4720 key="pinned-header"4982 key="pinned-header"
4721 marker="pinned-header"4983 marker="pinned-header"
4722 label="Pinned"4984 label="Pinned"
4723 visible={from !== null}4985 visible={showDragLabels}
4724 isDropTarget={dragTargetSection === "pinned"}4986 isDropTarget={dragTargetSection === "pinned"}
4725 />,4987 />,
4726 );4988 );
...@@ -4731,7 +4993,7 @@ export default function Sidebar() {...@@ -4731,7 +4993,7 @@ export default function Sidebar() {
4731 key="pinned-divider"4993 key="pinned-divider"
4732 marker="pinned-divider"4994 marker="pinned-divider"
4733 label="Active"4995 label="Active"
4734 visible={from !== null}4996 visible={showDragLabels}
4735 isDropTarget={dragTargetSection === "active"}4997 isDropTarget={dragTargetSection === "active"}
4736 />,4998 />,
4737 );4999 );
...@@ -4755,7 +5017,7 @@ export default function Sidebar() {...@@ -4755,7 +5017,7 @@ export default function Sidebar() {
4755 );5017 );
4756 break;5018 break;
4757 case "snoozed-header":5019 case "snoozed-header":
4758 items.push(5020 destination.push(
4759 <SidebarSectionHeader5021 <SidebarSectionHeader
4760 key="snoozed-shelf-header"5022 key="snoozed-shelf-header"
4761 marker="snoozed-header"5023 marker="snoozed-header"
...@@ -4810,18 +5072,56 @@ export default function Sidebar() {...@@ -4810,18 +5072,56 @@ export default function Sidebar() {
4810 break;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 <li className="list-none">5102 <li className="list-none">
4817 <button5103 <Tooltip disabled={!compact}>
4818 type="button"5104 <TooltipTrigger
4819 onClick={showMoreSettled}5105 render={
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"5106 <button
4821 >5107 type="button"
4822 <PlusIcon aria-hidden className="size-4 shrink-0" />5108 onClick={showMoreSettled}
4823 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more5109 className={cn(
4824 </button>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 </li>5125 </li>
4826 ) : null}5126 ) : null}
4827 </ul>5127 </ul>
...@@ -4836,20 +5136,29 @@ export default function Sidebar() {...@@ -4836,20 +5136,29 @@ export default function Sidebar() {
4836 snoozedThreads.length +5136 snoozedThreads.length +
4837 settledThreads.length ===5137 settledThreads.length ===
4838 0 ? (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 {projects.length === 0 ? (5145 {projects.length === 0 ? (
4841 <>5146 <>
4842 <span>No projects yet</span>5147 <span className={compact ? "sr-only" : undefined}>No projects yet</span>
4843 <button5148 <button
4844 type="button"5149 type="button"
4845 onClick={openAddProjectCommandPalette}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 <PlusIcon className="-mx-0.5 size-3" />5157 <PlusIcon className="-mx-0.5 size-3" />
4849 Add project5158 <span className={compact ? "sr-only" : undefined}>Add project</span>
4850 </button>5159 </button>
4851 </>5160 </>
4852 ) : scopedProjectGroup ? (5161 ) : compact ? null : scopedProjectGroup ? (
4853 `No threads in ${scopedProjectGroup.displayName} yet`5162 `No threads in ${scopedProjectGroup.displayName} yet`
4854 ) : (5163 ) : (
4855 "No threads yet"5164 "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 {...@@ -160,6 +160,7 @@ import {
160import { searchableSetting } from "./settingsSearch";160import { searchableSetting } from "./settingsSearch";
161import { ProjectFavicon } from "../ProjectFavicon";161import { ProjectFavicon } from "../ProjectFavicon";
162import { PanelAnimationsPreview } from "./PanelAnimationsPreview";162import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
163import { CompactSidebarPreview } from "./CompactSidebarPreview";
163164
164const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {165const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
165 artwork: "Artwork",166 artwork: "Artwork",
...@@ -501,6 +502,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -501,6 +502,9 @@ export function useSettingsRestore(onRestored?: () => void) {
501 ...(theme !== "system" ? ["Theme"] : []),502 ...(theme !== "system" ? ["Theme"] : []),
502 ...(!followSystem ? ["Follow system"] : []),503 ...(!followSystem ? ["Follow system"] : []),
503 ...(themeHalves !== null ? ["Theme mix"] : []),504 ...(themeHalves !== null ? ["Theme mix"] : []),
505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled
506 ? ["Compact sidebar"]
507 : []),
504 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast508 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
505 ? ["Contrast"]509 ? ["Contrast"]
506 : []),510 : []),
...@@ -605,6 +609,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -605,6 +609,7 @@ export function useSettingsRestore(onRestored?: () => void) {
605 settings.browserLinkTarget,609 settings.browserLinkTarget,
606 settings.browserAutoShowFloatingPreview,610 settings.browserAutoShowFloatingPreview,
607 settings.appearanceContrast,611 settings.appearanceContrast,
612 settings.compactSidebarEnabled,
608 settings.diffColorScheme,613 settings.diffColorScheme,
609 settings.enableAgentBrowserAccess,614 settings.enableAgentBrowserAccess,
610 settings.confirmQuit,615 settings.confirmQuit,
...@@ -712,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -712,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) {
712 }717 }
713 updateSettings({718 updateSettings({
714 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,719 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
720 compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled,
715 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,721 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
716 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,722 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
717 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,723 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
...@@ -1356,6 +1362,27 @@ export function AppearanceSettingsPanel() {...@@ -1356,6 +1362,27 @@ export function AppearanceSettingsPanel() {
1356 />1362 />
1357 </SettingsSection>1363 </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
1359 <TypographySection />1386 <TypographySection />
1360 </SettingsPageContainer>1387 </SettingsPageContainer>
1361 );1388 );
apps/web/src/components/settings/SettingsSidebarNav.tsx+26-7
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 XIcon,24 XIcon,
25} from "lucide-react";25} from "lucide-react";
26import { useLocation, useNavigate } from "@tanstack/react-router";26import { useLocation, useNavigate } from "@tanstack/react-router";
27import { useCompactSidebarEnabled } from "../../hooks/useSettings";
2728
28import { Button } from "../ui/button";29import { Button } from "../ui/button";
29import { Input } from "../ui/input";30import { Input } from "../ui/input";
...@@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
111 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),112 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
112 );113 );
113 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();114 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
115 const compactSidebarEnabled = useCompactSidebarEnabled();
114 const searchInputRef = useRef<HTMLInputElement>(null);116 const searchInputRef = useRef<HTMLInputElement>(null);
115 const [query, setQuery] = useState("");117 const [query, setQuery] = useState("");
116 const [activeResultIndex, setActiveResultIndex] = useState(0);118 const [activeResultIndex, setActiveResultIndex] = useState(0);
117 const searchableItems = useAvailableSettingsSearchItems();119 const searchableItems = useAvailableSettingsSearchItems();
118 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);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 const hasResults = results.length > 0;122 const hasResults = results.length > 0;
121123
122 useEffect(() => {124 useEffect(() => {
...@@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
233 <>235 <>
234 <SidebarContent className="overflow-x-hidden">236 <SidebarContent className="overflow-x-hidden">
235 <SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]">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 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />250 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
238 <Input251 <Input
239 ref={searchInputRef}252 ref={searchInputRef}
...@@ -330,10 +343,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -330,10 +343,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
330 <SidebarMenuItem key={item.to}>343 <SidebarMenuItem key={item.to}>
331 <SidebarMenuButton344 <SidebarMenuButton
332 isActive={isActive}345 isActive={isActive}
346 aria-label={item.label}
347 tooltip={item.label}
333 onClick={() => handleSectionClick(item.to)}348 onClick={() => handleSectionClick(item.to)}
334 >349 >
335 <Icon />350 <Icon />
336 <span className="truncate">{item.label}</span>351 <span className="truncate group-data-[collapsible=icon]:hidden">
352 {item.label}
353 </span>
337 </SidebarMenuButton>354 </SidebarMenuButton>
338 </SidebarMenuItem>355 </SidebarMenuItem>
339 );356 );
...@@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
343 </SidebarGroup>360 </SidebarGroup>
344 </SidebarContent>361 </SidebarContent>
345 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">362 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
346 <Suspense fallback={null}>363 <div className="contents group-data-[collapsible=icon]:hidden">
347 <T3ConnectSidebarSignIn />364 <Suspense fallback={null}>
348 </Suspense>365 <T3ConnectSidebarSignIn />
349 <div className="flex items-center gap-1">366 </Suspense>
367 </div>
368 <div className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col">
350 <div className="min-w-0 flex-1">369 <div className="min-w-0 flex-1">
351 <SidebarUtilityMenu />370 <SidebarUtilityMenu />
352 </div>371 </div>
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [
159 title: "Panel animations",159 title: "Panel animations",
160 to: "/settings/appearance",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 id: "environment-identification",169 id: "environment-identification",
164 title: "Environment identification",170 title: "Environment identification",
apps/web/src/components/sidebar/SidebarChrome.tsx+8-6
...@@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {...@@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
86 <Link86 <Link
87 aria-label="Go to threads"87 aria-label="Go to threads"
88 className={cn(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 onBackdrop ? "text-white" : "text-foreground",90 onBackdrop ? "text-white" : "text-foreground",
91 )}91 )}
92 to="/"92 to="/"
...@@ -187,12 +187,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -187,12 +187,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
187 }, [canGoBack, closeMobileSidebar, navigate]);187 }, [canGoBack, closeMobileSidebar, navigate]);
188188
189 return (189 return (
190 <SidebarMenu className="flex-row items-center">190 <SidebarMenu className="flex-row items-center group-data-[collapsible=icon]:flex-col">
191 {currentFooterPage ? (191 {currentFooterPage ? (
192 <SidebarMenuItem className="min-w-0 flex-1">192 <SidebarMenuItem className="min-w-0 flex-1">
193 <SidebarMenuButton onClick={handleBackClick}>193 <SidebarMenuButton onClick={handleBackClick} aria-label="Back" tooltip="Back">
194 <ArrowLeftIcon />194 <ArrowLeftIcon />
195 <span>Back</span>195 <span className="group-data-[collapsible=icon]:hidden">Back</span>
196 </SidebarMenuButton>196 </SidebarMenuButton>
197 </SidebarMenuItem>197 </SidebarMenuItem>
198 ) : (198 ) : (
...@@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
224export const SidebarChromeFooter = memo(function SidebarChromeFooter() {224export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
225 return (225 return (
226 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">226 <SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
227 <SidebarProviderUpdatePill />227 <div className="contents group-data-[collapsible=icon]:hidden">
228 <SidebarUpdateArchitectureWarning />228 <SidebarProviderUpdatePill />
229 <SidebarUpdateArchitectureWarning />
230 </div>
229 <SidebarUtilityMenu />231 <SidebarUtilityMenu />
230 </SidebarFooter>232 </SidebarFooter>
231 );233 );
apps/web/src/components/sidebar/SidebarThreadHeader.tsx+27-4
...@@ -20,9 +20,10 @@ import {...@@ -20,9 +20,10 @@ import {
20} from "react";20} from "react";
2121
22import { cn } from "~/lib/utils";22import { cn } from "~/lib/utils";
23import { useCompactSidebarEnabled } from "../../hooks/useSettings";
23import { Button } from "../ui/button";24import { Button } from "../ui/button";
24import { Input } from "../ui/input";25import { Input } from "../ui/input";
25import { SidebarMenuButton } from "../ui/sidebar";26import { SidebarMenuButton, useSidebar } from "../ui/sidebar";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2728
28export interface SidebarThreadHeaderProps {29export interface SidebarThreadHeaderProps {
...@@ -69,6 +70,9 @@ export function SidebarThreadHeader({...@@ -69,6 +70,9 @@ export function SidebarThreadHeader({
69 activeSearchResultIndex,70 activeSearchResultIndex,
70 onClearSearch,71 onClearSearch,
71}: SidebarThreadHeaderProps) {72}: SidebarThreadHeaderProps) {
73 const compactEnabled = useCompactSidebarEnabled();
74 const { state, isMobile, setOpen } = useSidebar();
75 const compact = compactEnabled && state === "collapsed" && !isMobile;
72 const resultsVisible = isSearching && searchResultCount > 0;76 const resultsVisible = isSearching && searchResultCount > 0;
73 // Results shrink as the query narrows, so the active index can outrun the77 // Results shrink as the query narrows, so the active index can outrun the
74 // list; pointing aria-activedescendant at a removed option strands the78 // list; pointing aria-activedescendant at a removed option strands the
...@@ -79,10 +83,24 @@ export function SidebarThreadHeader({...@@ -79,10 +83,24 @@ export function SidebarThreadHeader({
79 : "New thread";83 : "New thread";
8084
81 return (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 <div98 <div
84 ref={searchFieldRef}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 <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" />105 <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" />
88 <Input106 <Input
...@@ -124,7 +142,12 @@ export function SidebarThreadHeader({...@@ -124,7 +142,12 @@ export function SidebarThreadHeader({
124 </div>142 </div>
125 {/* Segmented well: the icons read as one control instead of three loose143 {/* Segmented well: the icons read as one control instead of three loose
126 buttons competing with the search field beside them. */}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 {hasProjects ? (151 {hasProjects ? (
129 <>152 <>
130 {projectScope}153 {projectScope}
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+1-1
...@@ -348,7 +348,7 @@ function SidebarUpdateControl() {...@@ -348,7 +348,7 @@ function SidebarUpdateControl() {
348 );348 );
349349
350 return (350 return (
351 <SidebarMenuItem className="ml-auto shrink-0">351 <SidebarMenuItem className="ml-auto shrink-0 group-data-[collapsible=icon]:hidden">
352 <Popover352 <Popover
353 handle={releaseNotesPopoverHandle}353 handle={releaseNotesPopoverHandle}
354 onOpenChange={(open, details) => {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,9 +591,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
591function SidebarContent({591function SidebarContent({
592 className,592 className,
593 fixedHeader,593 fixedHeader,
594 fixedFooter,
594 ...props595 ...props
595}: React.ComponentProps<"div"> & {596}: React.ComponentProps<"div"> & {
596 fixedHeader?: React.ReactNode;597 fixedHeader?: React.ReactNode;
598 fixedFooter?: React.ReactNode;
597}) {599}) {
598 return (600 return (
599 <>601 <>
...@@ -617,6 +619,7 @@ function SidebarContent({...@@ -617,6 +619,7 @@ function SidebarContent({
617 {...props}619 {...props}
618 />620 />
619 </ScrollArea>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,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean {
379 return settingsHydrated && legacySidebarEnabled;379 return settingsHydrated && legacySidebarEnabled;
380}380}
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
382/** Read current settings for one environment, merged with client-local preferences. */389/** Read current settings for one environment, merged with client-local preferences. */
383export function useEnvironmentSettings<T = UnifiedSettings>(390export function useEnvironmentSettings<T = UnifiedSettings>(
384 environmentId: EnvironmentId,391 environmentId: EnvironmentId,
apps/web/src/workspaceTitlebar.ts+1-1
...@@ -1,2 +1,2 @@...@@ -1,2 +1,2 @@
1export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS =1export 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,6 +436,7 @@ export const ClientSettingsSchema = Schema.Struct({
436 // old keys, so everyone, including prior beta opt-outs, resets to the new436 // old keys, so everyone, including prior beta opt-outs, resets to the new
437 // default sidebar.437 // default sidebar.
438 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),438 legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
439 compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
439 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(440 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
440 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),441 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
441 ),442 ),
...@@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({
1490 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),1491 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
1491 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1492 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
1492 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),1493 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
1494 compactSidebarEnabled: Schema.optionalKey(Schema.Boolean),
1493 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),1495 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
1494 sidebarProjectGroupingOverrides: Schema.optionalKey(1496 sidebarProjectGroupingOverrides: Schema.optionalKey(
1495 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),1497 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),