diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6769586f7fa8..6497df68c8bd 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,11 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { + useCompactSidebarEnabled, + useEnvironmentIdentificationMode, + useLegacySidebarEnabled, +} from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -144,6 +148,7 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + const compactSidebarEnabled = useCompactSidebarEnabled(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread @@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); + const compactSidebarEnabled = useCompactSidebarEnabled(); + const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); + if (isCompact) setOpen(true); }, [ clearSelection, + isCompact, + setOpen, dragInProgressRef, projectExpanded, projectPreferenceKeys, @@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); + if (isCompact) setOpen(true); }, - [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], + [ + dragInProgressRef, + isCompact, + projectExpanded, + projectPreferenceKeys, + setOpen, + setProjectExpanded, + ], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {!projectExpanded && projectStatus ? ( + {isCompact ? null : !projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Environment badge – visible by default, crossfades with the "new thread" button on hover using the same pointer-events + opacity pattern as the thread row archive/timestamp swap. */} - {project.environmentPresence === "remote-only" && ( + {!isCompact && project.environmentPresence === "remote-only" && ( +
+ + + } + > + {content} + + {props.label} + ); } @@ -704,6 +752,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -742,6 +793,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }, [draftId, onDiscard], ); + if (compact) { + return ( +
  • + + + } + > + + + +
    {props.projectDisplayName}
    +
    {preview}
    +
    +
    +
  • + ); + } return (
  • scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -1208,6 +1290,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} + compactStatus={ + compact + ? (topStatus?.label ?? + (variantAction === "unsnooze" + ? "Snoozed" + : variantAction === "unsettle" + ? "Settled" + : "Ready")) + : undefined + } /> ); @@ -1254,6 +1346,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [isRenaming, onStartRename, thread.title, threadRef], ); const [isFileDragOver, setIsFileDragOver] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); const fileDropHandlers = useMemo( () => onFileDropThreads @@ -1416,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 + sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) ? ("hidden" as const) : undefined, }, @@ -1557,6 +1650,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null; + if (compact) { + return ( +
  • + + + } + > + {props.project ? ( + + ) : driverKind ? ( + + ) : ( + + )} + {topStatus ? ( + + ) : hasUnsentDraft ? ( + + ) : null} + {props.jumpLabel ? : null} + + {sortable?.isDragging ? ( + {dragDestination} + ) : ( + detailsTooltip + )} + +
  • + ); + } + if (variant === "slim") { return (
  • - + - + void; }) { const { thread } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2074,6 +2249,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onClick={props.onSelect} className={cn( "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + compact && "justify-center px-0", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2085,9 +2261,15 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( + ) : compact ? ( + ) : null} - {thread.title} - + {thread.title} + {threadTimeLabel(thread)} @@ -2115,7 +2297,10 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); + const compactEnabled = useCompactSidebarEnabled(); + const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; + const [snoozedFooter, setSnoozedFooter] = useState(null); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); @@ -2687,6 +2872,7 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { + if (compact) return EMPTY_THREADS; if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( @@ -2694,7 +2880,7 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -2922,10 +3108,14 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, []); + const startThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string) => { + if (compact) setOpen(true); + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, + [compact, setOpen], + ); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { @@ -3087,8 +3277,18 @@ export default function Sidebar() { const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( - (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), - [], + (args) => + restrictBelowSidebarLabel( + { + ...args, + // The fixed snoozed shelf shares the main list's drag boundary. + containerNodeRect: compact + ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) + : args.containerNodeRect, + }, + dragLabelOffsetRef.current, + ), + [compact], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { @@ -3310,7 +3510,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length === + (compact ? 0 : settledThreads.length) === 0 ) { return []; @@ -3326,13 +3526,15 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } - items.push({ kind: "marker", marker: "settled-header" }); - const settledRows = rowsOf(renderedSettledThreads, "settled"); - items.push({ kind: "marker", marker: "settled-placeholder" }); - items.push(...settledRows); + if (!compact) { + items.push({ kind: "marker", marker: "settled-header" }); + items.push({ kind: "marker", marker: "settled-placeholder" }); + items.push(...rowsOf(renderedSettledThreads, "settled")); + } return items; }, [ activeThreads, + compact, pinnedThreads, renderedSettledThreads, settledThreads.length, @@ -3402,6 +3604,7 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, + compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3410,6 +3613,7 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ + compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3418,6 +3622,22 @@ export default function Sidebar() { snoozedThreads.length, ], ); + const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed"; + const compactSnoozedDragThread = + draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined; + const compactSidebarSortingStrategy = useCallback( + (args) => { + const item = sidebarListItems[args.index]; + // Footer rows stay anchored while the main list previews a reorder. + if ( + item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header" + ) { + return null; + } + return sidebarSortingStrategy(args); + }, + [sidebarListItems, sidebarSortingStrategy], + ); // Hidden and filtered threads keep their keys. Reserve those slots without // including the rows in the visible drop order or writing to them. const { pinnedKeysById, activeKeysById } = useMemo( @@ -4321,7 +4541,16 @@ export default function Sidebar() { <> 0 ? ( +
      + ) : null + } fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. @@ -4381,8 +4610,12 @@ export default function Sidebar() { // popup opens under the field, is at least as wide as it, // and grows to fit project names up to a cap, past which // the rows truncate. - anchor={headerSearchRef} - className="max-w-[min(18rem,var(--available-width))] overflow-hidden" + anchor={compact ? undefined : headerSearchRef} + side={compact ? "right" : "bottom"} + className={cn( + "max-w-[min(18rem,var(--available-width))] overflow-hidden", + compact && "min-w-56", + )} > No threads found

      @@ -4556,14 +4792,17 @@ export default function Sidebar() { modifiers={[ restrictToVerticalAxis, restrictBelowPins, - restrictToFirstScrollableAncestor, + ...(compact ? [] : [restrictToFirstScrollableAncestor]), ]} onDragStart={handleThreadDragStart} onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > - +
        - {(bag) => renderThreadRowInner(thread, section, bag)} + {(bag) => + renderThreadRowInner( + thread, + section, + draggingCompactSnoozed && bag.isDragging + ? { ...bag, hidden: true } + : bag, + ) + } ); }; const from = dragState?.activeSection ?? null; + const showDragLabels = + from !== null && + (!compact || + dragTargetSection === "active" || + dragTargetSection === "pinned"); + const snoozedItems: ReactNode[] = []; const items: ReactNode[] = [ , ]; for (const item of sidebarListItems) { + const destination = + compact && + (item.kind === "thread" + ? item.section === "snoozed" + : item.marker === "snoozed-header") + ? snoozedItems + : items; if (item.kind === "thread") { - items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + destination.push( + renderThreadRow(threadByKey.get(item.key)!, item.section), + ); continue; } switch (item.marker) { @@ -4720,7 +4982,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -4731,7 +4993,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "active"} />, ); @@ -4755,7 +5017,7 @@ export default function Sidebar() { ); break; case "snoozed-header": - items.push( + destination.push( +
          + {renderThreadRowInner(compactSnoozedDragThread, "snoozed", { + isDragging: true, + listeners: undefined, + setNodeRef: () => {}, + transform: null, + transition: undefined, + })} +
        + , + document.body, + "compact-snoozed-drag", + ) + : null, + ]; })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( + {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? (
      • - + + + } + > + + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + + + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + +
      • ) : null}
      @@ -4836,20 +5136,29 @@ export default function Sidebar() { snoozedThreads.length + settledThreads.length === 0 ? ( -
      +
      {projects.length === 0 ? ( <> - No projects yet + No projects yet - ) : scopedProjectGroup ? ( + ) : compact ? null : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx new file mode 100644 index 000000000000..49fb0754e015 --- /dev/null +++ b/apps/web/src/components/settings/CompactSidebarPreview.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; + +import { cn } from "~/lib/utils"; + +export function CompactSidebarPreview() { + const [collapsed, setCollapsed] = useState(false); + + return ( + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c9700496f420..efefc79c32cd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -160,6 +160,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; +import { CompactSidebarPreview } from "./CompactSidebarPreview"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -501,6 +502,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), + ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled + ? ["Compact sidebar"] + : []), ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? ["Contrast"] : []), @@ -605,6 +609,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, + settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -712,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) { } updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, + compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, @@ -1356,6 +1362,27 @@ export function AppearanceSettingsPanel() { /> + + + +
      + + updateSettings({ compactSidebarEnabled: Boolean(checked) }) + } + aria-label="Compact sidebar" + /> +
      +
      + } + /> + + ); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index c29068934efa..c206174a397a 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -24,6 +24,7 @@ import { XIcon, } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), ); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); + const compactSidebarEnabled = useCompactSidebarEnabled(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); - const isSearching = query.trim().length > 0; + const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open); const hasResults = results.length > 0; useEffect(() => { @@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { <> -
      + { + setOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + }} + > + + +
      handleSectionClick(item.to)} > - {item.label} + + {item.label} + ); @@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - - - -
      +
      + + + +
      +
      diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 9a8af52b90f5..be62ad6b25b5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Panel animations", to: "/settings/appearance", }, + { + id: "compact-sidebar", + title: "Compact sidebar", + to: "/settings/appearance", + searchTerms: ["collapsed icons rail hover navigation preview"], + }, { id: "environment-identification", title: "Environment identification", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index afbbf7671dfc..2f65cf8c3697 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + {currentFooterPage ? ( - + - Back + Back ) : ( @@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - +
      + + +
      ); diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 878235615b39..d0718d9856f8 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,9 +20,10 @@ import { } from "react"; import { cn } from "~/lib/utils"; +import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SidebarMenuButton } from "../ui/sidebar"; +import { SidebarMenuButton, useSidebar } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { @@ -69,6 +70,9 @@ export function SidebarThreadHeader({ activeSearchResultIndex, onClearSearch, }: SidebarThreadHeaderProps) { + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile, setOpen } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const resultsVisible = isSearching && searchResultCount > 0; // Results shrink as the query narrows, so the active index can outrun the // list; pointing aria-activedescendant at a removed option strands the @@ -79,10 +83,24 @@ export function SidebarThreadHeader({ : "New thread"; return ( -
      +
      + {compact ? ( + { + setOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + }} + > + + + ) : null}
      {/* Segmented well: the icons read as one control instead of three loose buttons competing with the search field beside them. */} -
      +
      {hasProjects ? ( <> {projectScope} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index a94b7801ecfd..8c04eec6fe7d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -348,7 +348,7 @@ function SidebarUpdateControl() { ); return ( - + { diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index a7abcb3bb550..c8938497cc04 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -690,9 +690,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; + fixedFooter?: React.ReactNode; }) { return ( <> @@ -716,6 +718,7 @@ function SidebarContent({ {...props} /> + {fixedFooter ?
      {fixedFooter}
      : null} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 194cc36c55f4..fa4b8bc8fd31 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } +/** Keep the default collapsed sidebar until persisted client settings hydrate. */ +export function useCompactSidebarEnabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled; + return settingsHydrated && compactSidebarEnabled; +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts index b481221e63aa..aed95897cc55 100644 --- a/apps/web/src/workspaceTitlebar.ts +++ b/apps/web/src/workspaceTitlebar.ts @@ -1,2 +1,2 @@ export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = - "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; + "[[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)))]"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 004a53b320e8..826440556d2c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -436,6 +436,7 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({ proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + compactSidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),