diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f62f35cf3a..1ad8e5d6bb 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,6 +63,8 @@ const clientSettings: ClientSettings = { showSkillsInSlashMenu: false, providerModelPreferences: {}, showProviderUsageInContextPopover: true, + compactSidebarEnabled: false, + sidebarCompactThreadRows: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", 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 +768,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 +809,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }, [draftId, onDiscard], ); + if (compact) { + return ( +
  • + + + } + > + + + +
    {props.projectDisplayName}
    +
    {preview}
    +
    +
    +
  • + ); + } return (
  • = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + compact: boolean; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1052,6 +1134,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variant, variantAction, } = 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], @@ -1267,6 +1352,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} + compactStatus={ + compact || (props.compact && variant === "card") + ? (topStatus?.label ?? + (variantAction === "unsnooze" + ? "Snoozed" + : variantAction === "unsettle" + ? "Settled" + : "Ready")) + : undefined + } /> ); @@ -1313,6 +1408,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 +1571,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, }, @@ -1547,18 +1643,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const prBadge = + const renderPrBadge = (iconOnly: boolean, variant: "underline" | "badge" = "underline") => prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; + const hasPrBadge = prBadgeShape?.kind === "stack" || pr !== null || currentLinkedPr !== null; + const prBadge = renderPrBadge(false); const terminalStatusIcon = terminalStatus ? ( + + + } + > + + {props.project ? ( + + ) : driverKind ? ( + + ) : ( + + )} + {isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + + ) : null} + {hasPrBadge ? ( + + {renderPrBadge(true, "badge")} + + ) : null} + + {topStatus ? ( + + ) : hasUnsentDraft ? ( + + ) : null} + {props.jumpLabel ? : null} + + {sortable?.isDragging ? ( + {dragDestination} + ) : ( + detailsTooltip + )} + +
  • + ); + } + if (variant === "slim") { return (
  • - + - + } > -
    -
    +
    +
    {draftIndicator} {props.project ? ( - + + + {compactRows && isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + + ) : null} + + ) : compactRows && isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + ) : null} - {props.projectDisplayName ? ( + {compactRows ? ( + title + ) : props.projectDisplayName ? ( )} {pinIndicator} + {compactRows ? ( + <> + {terminalStatusIcon} + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {CompactStatusIcon ? ( + + ) : ( + + )} + {topStatus.label} + + ) + ) : null} + {renderPrBadge(true)} + + ) : 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 @@ -1846,20 +2161,35 @@ 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 @@ -1986,68 +2316,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} @@ -2083,6 +2415,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => 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 +2503,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 +2515,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 compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -2814,6 +3163,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 +3171,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 +3399,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 +3568,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 +3800,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length === + (compact ? 0 : settledThreads.length) === 0 ) { return []; @@ -3452,13 +3816,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 +3894,7 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, + compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3536,6 +3903,7 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ + compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3544,6 +3912,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 +4832,19 @@ 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 +4908,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", + )} > } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}

      @@ -4696,18 +5104,24 @@ export default function Sidebar() { modifiers={[ restrictToVerticalAxis, restrictBelowPins, - restrictToFirstScrollableAncestor, + ...(compact ? [] : [restrictToFirstScrollableAncestor]), ]} onDragStart={handleThreadDragStart} onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > - +
        0 && "flex-1", + )} > {(() => { const renderThreadRowInner = ( @@ -4718,10 +5132,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 ( @@ -4731,6 +5144,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" @@ -4837,11 +5251,25 @@ export default function Sidebar() { !draggableThreadKeys.has(threadKey) || optimisticDrop !== null } > - {(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 +5301,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -4875,7 +5312,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "active"} />, ); @@ -4899,10 +5336,11 @@ 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 +5457,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/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index f53e31c633..9407545d33 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -195,14 +195,17 @@ export function ThreadPullRequestBadgeControl({ number, url, status, + iconOnly = false, onOpenStack, onOpenPullRequest, }: { - variant: "underline" | "ghost"; + variant: "underline" | "ghost" | "badge"; badge: ThreadPullRequestBadge | null; number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; + /** Dense rows drop the number/layer count and keep only the state glyph. */ + iconOnly?: boolean; onOpenStack: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { @@ -212,7 +215,9 @@ export function ThreadPullRequestBadgeControl({ const className = cn( variant === "ghost" ? buttonVariants({ variant: "ghost", size: "xs" }) - : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", + : variant === "badge" + ? "inline-flex size-3 shrink-0 cursor-pointer items-center justify-center rounded-full bg-sidebar ring-1 ring-sidebar outline-none focus-visible:ring-2 focus-visible:ring-ring" + : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", "text-xs tabular-nums", variant === "ghost" && "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", @@ -220,8 +225,11 @@ export function ThreadPullRequestBadgeControl({ ); const content = ( <> - - {presentation.text} + + {iconOnly ? null : presentation.text} ); return ( diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx new file mode 100644 index 0000000000..4d1e861d14 --- /dev/null +++ b/apps/web/src/components/settings/CompactSidebarPreview.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +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 ( + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 62a95d9f0f..3a0658cee0 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,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), + ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled || + settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows + ? ["Compact sidebar"] + : []), ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? ["Contrast"] : []), @@ -611,6 +616,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, + settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -643,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, + settings.sidebarCompactThreadRows, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -727,6 +734,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, @@ -751,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, @@ -1094,6 +1103,19 @@ export function AppearanceSettingsPanel() { const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const compactSidebarMode = settings.compactSidebarEnabled + ? settings.sidebarCompactThreadRows + ? "both" + : "rail" + : settings.sidebarCompactThreadRows + ? "threads" + : "off"; + const compactSidebarModes = { + off: "Off", + rail: "Rail only", + threads: "Threads only", + both: "Both", + }; const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1380,6 +1402,64 @@ export function AppearanceSettingsPanel() { /> + + + updateSettings({ + compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, + sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, + }) + } + /> + ) : null + } + control={ +
      + + +
      + } + /> +
      + ); 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..2e760e52d6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -159,6 +159,14 @@ 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 expanded dense density one line rows chats threads compact thread list", + ], + }, { 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/SidebarCompletedTime.test.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx new file mode 100644 index 0000000000..7327af79e7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx @@ -0,0 +1,56 @@ +import { act, memo } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { SidebarCompletedTime } from "./SidebarCompletedTime"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-07T01:01:00Z")); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it("advances visible and accessible completion times without rerendering its memoized row", async () => { + const rowRender = vi.fn(); + const Row = memo(function Row() { + rowRender(); + return ; + }); + await act(() => { + renderer = create(); + }); + expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z"); + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect( + renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]), + ).toHaveLength(0); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "1m", + ]); + + await act(() => vi.advanceTimersByTime(60_000)); + + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "2m", + ]); + expect(rowRender).toHaveBeenCalledTimes(1); + await act(() => renderer!.unmount()); + renderer = undefined; + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx new file mode 100644 index 0000000000..785b8d9459 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx @@ -0,0 +1,16 @@ +import { useNowMinute } from "../../hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; + +export function SidebarCompletedTime({ completedAt }: { completedAt: string }) { + // Subscribe inside the label so time advances even when the row is memoized. + const nowMinute = useNowMinute(); + const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`)); + const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, ""); + + 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 }) => (