From 860e20d8765ae4015e956732da7f414be4136de2 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 13:27:24 -0300 Subject: [PATCH 1/6] feat(web): add optional compact sidebar rail (#11525) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/LegacySidebar.tsx | 77 ++- apps/web/src/components/Sidebar.drag.ts | 14 +- apps/web/src/components/Sidebar.tsx | 456 +++++++++++++++--- .../settings/CompactSidebarPreview.tsx | 53 ++ .../components/settings/SettingsPanels.tsx | 27 ++ .../settings/SettingsSidebarNav.tsx | 35 +- .../src/components/settings/settingsSearch.ts | 6 + .../src/components/sidebar/SidebarChrome.tsx | 18 +- .../sidebar/SidebarThreadHeader.test.tsx | 5 + .../sidebar/SidebarThreadHeader.tsx | 31 +- .../components/sidebar/SidebarUpdatePill.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 3 + apps/web/src/hooks/useSettings.ts | 7 + apps/web/src/workspaceTitlebar.ts | 2 +- packages/contracts/src/settings.ts | 2 + 16 files changed, 621 insertions(+), 126 deletions(-) create mode 100644 apps/web/src/components/settings/CompactSidebarPreview.tsx diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a27044c05f..9b199ec872 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -19,7 +19,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 LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -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 @@ -239,7 +244,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); @@ -1500,10 +1506,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, @@ -1520,9 +1529,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( @@ -2414,8 +2431,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {!projectExpanded && projectStatus ? ( + {isCompact ? null : !projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2471,7 +2490,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} + ); } @@ -718,6 +766,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -756,6 +807,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], @@ -1267,6 +1349,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 + } /> ); @@ -1313,6 +1405,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 @@ -1475,7 +1568,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, }, @@ -1621,6 +1714,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], @@ -2168,6 +2343,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onClick={props.onSelect} className={cn( "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", + compact && "h-9 min-h-0 justify-center px-0 py-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", @@ -2179,24 +2355,33 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( + ) : compact ? ( + ) : null} - - - {thread.title} - - {threadTimeLabel(thread)} + {compact ? ( + <> + {thread.title} + {threadTimeLabel(thread)} + + ) : ( + + + {thread.title} + + {threadTimeLabel(thread)} + + {props.searchMatch ? ( + + ) : null} - {props.searchMatch ? ( - - ) : null} - + )} 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); @@ -2814,6 +3002,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( @@ -2821,7 +3010,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 @@ -3049,10 +3238,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) => { @@ -3214,8 +3407,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) => { @@ -3436,7 +3639,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length === + (compact ? 0 : settledThreads.length) === 0 ) { return []; @@ -3452,13 +3655,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, @@ -3528,6 +3733,7 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, + compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3536,6 +3742,7 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ + compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3544,6 +3751,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( @@ -4448,7 +4671,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. @@ -4512,8 +4744,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", + )} > {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}

      @@ -4696,14 +4935,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) { @@ -4864,7 +5129,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -4875,7 +5140,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "active"} />, ); @@ -4899,7 +5164,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}
      @@ -4980,20 +5283,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 0000000000..49fb0754e0 --- /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 62a95d9f0f..c5cdede9e2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -167,6 +167,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", @@ -486,6 +487,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"] : []), @@ -611,6 +615,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, + settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -727,6 +732,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, desktopNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.desktopNotificationsEnabled, @@ -1380,6 +1386,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 f215d6c25e..c206174a39 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 !== "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/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} + ); @@ -342,11 +359,13 @@ 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 80695efc95..a77d813974 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: "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/settings/appearance", }, + { + id: "compact-sidebar", + title: "Compact sidebar", + to: "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/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 8918bd4688..199a14f71d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -83,7 +83,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + {currentFooterPage ? ( - + - Back + Back ) : ( @@ -219,10 +219,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - - - + +
      + + + +
      ); diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.test.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.test.tsx index a1aeea8f42..a46be3280f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.test.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.test.tsx @@ -9,6 +9,11 @@ vi.mock("../ui/sidebar", () => ({ SidebarMenuButton: ({ size: _size, ...props }: ComponentProps<"button"> & { size?: string }) => ( + ) : ( + + + {topStatus.label} + + ) + ) : null} + {prBadge} + + ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -2018,20 +2093,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {sortable?.isDragging ? ( dragDestination ) : ( - + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - {topStatus ? ( + {compactRows ? ( + status === "working" ? ( + + ) : compactCompletedAt ? ( + + ) : ( + threadTimeLabel(thread) + ) + ) : topStatus ? ( isWokeStatus ? ( - Settle + {compactRows ? null : "Settle"} Settle thread @@ -2158,68 +2246,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
      -
      - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
      -
      - {/* Always the branch. The plan step used to take this slot while + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {compactRows ? null : ( + <> +
      {title}
      +
      + {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - {thread.branch} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - + {thread.branch ? ( + <> + + + {thread.branch} + + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} - ) : null} - -
      +
      + + )}
      {props.jumpLabel ? : null} @@ -2412,6 +2502,7 @@ export default function Sidebar() { const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; const [snoozedFooter, setSnoozedFooter] = useState(null); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -4960,10 +5051,9 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. + // Settled and snoozed always use slim rows. Active and + // pinned threads use cards unless the user has explicitly + // enabled the compact thread-list preference. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( @@ -4973,6 +5063,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} + compact={compactThreadRows} // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx index 49fb0754e0..4d1e861d14 100644 --- a/apps/web/src/components/settings/CompactSidebarPreview.tsx +++ b/apps/web/src/components/settings/CompactSidebarPreview.tsx @@ -1,9 +1,30 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -export function CompactSidebarPreview() { +export function CompactSidebarPreview({ + railEnabled, + compactRows, +}: { + railEnabled: boolean; + compactRows: boolean; +}) { const [collapsed, setCollapsed] = useState(false); + const sidebarRef = useRef(null); + + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const animation = sidebarRef.current?.animate( + [ + { width: "36px", offset: 0 }, + { width: railEnabled ? "12px" : "0px", offset: 0.45 }, + { width: railEnabled ? "12px" : "0px", offset: 0.6 }, + { width: "36px", offset: 1 }, + ], + { duration: 800, easing: "ease-in-out" }, + ); + return () => animation?.cancel(); + }, [railEnabled, compactRows]); return (