diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5ad23ee70582..53044dcf7e5d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -57,7 +57,6 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, - 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 6497df68c8bd..6769586f7fa8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,11 +14,7 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { - useCompactSidebarEnabled, - useEnvironmentIdentificationMode, - useLegacySidebarEnabled, -} from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -148,7 +144,6 @@ 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 @@ -234,7 +229,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); - const compactSidebarEnabled = useCompactSidebarEnabled(); - const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; + const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1451,13 +1445,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, [ clearSelection, - isCompact, - setOpen, dragInProgressRef, projectExpanded, projectPreferenceKeys, @@ -1474,17 +1465,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, - [ - dragInProgressRef, - isCompact, - projectExpanded, - projectPreferenceKeys, - setOpen, - setProjectExpanded, - ], + [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2376,10 +2359,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {isCompact ? null : !projectExpanded && projectStatus ? ( + {!projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2434,7 +2415,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. */} - {!isCompact && project.environmentPresence === "remote-only" && ( + {project.environmentPresence === "remote-only" && ( +
); } @@ -754,9 +705,6 @@ 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() @@ -795,34 +743,6 @@ 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"; @@ -1113,9 +1032,6 @@ 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], @@ -1293,16 +1209,6 @@ 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 - } /> ); @@ -1349,7 +1255,6 @@ 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 @@ -1512,7 +1417,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.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) + !sortable.isDragging && sortable.transform?.scaleY === 0 ? ("hidden" as const) : undefined, }, @@ -1584,21 +1489,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const renderPrBadge = (iconOnly: boolean, variant: "underline" | "badge" = "underline") => + const prBadge = 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} - {compactRows ? ( - title - ) : props.projectDisplayName ? ( + {props.projectDisplayName ? ( )} {pinIndicator} - {compactRows ? ( - <> - {terminalStatusIcon} - {topStatus && CompactStatusIcon ? ( - isWokeStatus ? ( - - ) : ( - - - {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 @@ -2067,35 +1766,20 @@ 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. */} - {compactRows ? ( - status === "working" ? ( - - - - ) : compactCompletedAt ? ( - - ) : ( - threadTimeLabel(thread) - ) - ) : topStatus ? ( + {topStatus ? ( isWokeStatus ? ( - {compactRows ? null : "Settle"} + Settle Settle thread @@ -2211,70 +1895,68 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
    - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {compactRows ? null : ( - <> -
    {title}
    -
    - {/* Always the branch. The plan step used to take this slot while +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {/* 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 ? ( - - - - ) : null} + {thread.branch ? ( + <> + + + {thread.branch} -
    - - )} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    {props.jumpLabel ? : null} @@ -2308,9 +1990,6 @@ 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], @@ -2396,7 +2075,6 @@ 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", @@ -2408,15 +2086,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( - ) : compact ? ( - ) : null} - {thread.title} - + {thread.title} + {threadTimeLabel(thread)} @@ -2444,12 +2116,8 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); - const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); - const compactEnabled = useCompactSidebarEnabled(); - const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; - const [snoozedFooter, setSnoozedFooter] = useState(null); + const { isMobile, setOpenMobile } = useSidebar(); 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); @@ -3020,7 +2688,6 @@ 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( @@ -3028,7 +2695,7 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; - }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [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 @@ -3256,14 +2923,10 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback( - (threadRef: ScopedThreadRef, title: string) => { - if (compact) setOpen(true); - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, - [compact, setOpen], - ); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { @@ -3425,18 +3088,8 @@ export default function Sidebar() { const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( - (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], + (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), + [], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { @@ -3658,7 +3311,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - (compact ? 0 : settledThreads.length) === + settledThreads.length === 0 ) { return []; @@ -3674,15 +3327,13 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } - if (!compact) { - items.push({ kind: "marker", marker: "settled-header" }); - items.push({ kind: "marker", marker: "settled-placeholder" }); - items.push(...rowsOf(renderedSettledThreads, "settled")); - } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + items.push({ kind: "marker", marker: "settled-placeholder" }); + items.push(...settledRows); return items; }, [ activeThreads, - compact, pinnedThreads, renderedSettledThreads, settledThreads.length, @@ -3752,7 +3403,6 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, - compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3761,7 +3411,6 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ - compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3770,22 +3419,6 @@ 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( @@ -4689,19 +4322,7 @@ export default function Sidebar() { <> 0 ? ( -
      - ) : null - } + className="gap-0 min-h-full" fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. @@ -4761,12 +4382,8 @@ 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={compact ? undefined : headerSearchRef} - side={compact ? "right" : "bottom"} - className={cn( - "max-w-[min(18rem,var(--available-width))] overflow-hidden", - compact && "min-w-56", - )} + anchor={headerSearchRef} + className="max-w-[min(18rem,var(--available-width))] overflow-hidden" > } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( No threads found

      @@ -4948,23 +4557,20 @@ export default function Sidebar() { modifiers={[ restrictToVerticalAxis, restrictBelowPins, - ...(compact ? [] : [restrictToFirstScrollableAncestor]), + restrictToFirstScrollableAncestor, ]} onDragStart={handleThreadDragStart} onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > - +
        0 && "flex-1", + sidebarListItems.length > 0 && "flex-1", )} > {(() => { @@ -4976,9 +4582,10 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - // Settled and snoozed always use slim rows. Active and - // pinned threads use cards unless the user has explicitly - // enabled the compact thread-list preference. + // 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. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( @@ -4988,7 +4595,6 @@ 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" @@ -5091,25 +4697,11 @@ export default function Sidebar() { !draggableThreadKeys.has(threadKey) || optimisticDrop !== null } > - {(bag) => - renderThreadRowInner( - thread, - section, - draggingCompactSnoozed && bag.isDragging - ? { ...bag, hidden: true } - : bag, - ) - } + {(bag) => renderThreadRowInner(thread, section, 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") { - destination.push( - renderThreadRow(threadByKey.get(item.key)!, item.section), - ); + items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } switch (item.marker) { @@ -5141,7 +4724,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={showDragLabels} + visible={from !== null} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -5152,7 +4735,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={showDragLabels} + visible={from !== null} isDropTarget={dragTargetSection === "active"} />, ); @@ -5176,11 +4759,11 @@ export default function Sidebar() { ); break; case "snoozed-header": - destination.push( + items.push( -
          - {renderThreadRowInner(compactSnoozedDragThread, "snoozed", { - isDragging: true, - listeners: undefined, - setNodeRef: () => {}, - transform: null, - transition: undefined, - })} -
        - , - document.body, - "compact-snoozed-drag", - ) - : null, - ]; + return items; })()} - {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? ( + {settledShelfExpanded && hiddenSettledCount > 0 ? (
      • - - - } - > - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - - - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more - - +
      • ) : null}
      @@ -5297,29 +4842,20 @@ export default function Sidebar() { snoozedThreads.length + settledThreads.length === 0 ? ( -
      +
      {projects.length === 0 ? ( <> - No projects yet + No projects yet - ) : compact ? null : scopedProjectGroup ? ( + ) : 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 4b26d0442021..7201779f13f1 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -144,17 +144,14 @@ export function ThreadPullRequestBadgeControl({ number, url, status, - iconOnly = false, onOpenStack, onOpenPullRequest, }: { - variant: "underline" | "ghost" | "badge"; + variant: "underline" | "ghost"; 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; }) { @@ -171,9 +168,7 @@ export function ThreadPullRequestBadgeControl({ const className = cn( variant === "ghost" ? buttonVariants({ variant: "ghost", size: "xs" }) - : 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", + : "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]", @@ -183,11 +178,8 @@ export function ThreadPullRequestBadgeControl({ ); const content = ( <> - - {iconOnly ? null : isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} + + {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); return ( diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx deleted file mode 100644 index 4d1e861d14e7..000000000000 --- a/apps/web/src/components/settings/CompactSidebarPreview.tsx +++ /dev/null @@ -1,82 +0,0 @@ -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 a0d713f58bee..6baa561a91fe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -170,7 +170,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; -import { CompactSidebarPreview } from "./CompactSidebarPreview"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -524,10 +523,6 @@ 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"] : []), @@ -634,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, - settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -666,7 +660,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, - settings.sidebarCompactThreadRows, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -744,7 +737,6 @@ 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, @@ -762,7 +754,6 @@ 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, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, @@ -1143,19 +1134,6 @@ 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; @@ -1442,64 +1420,6 @@ 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 c206174a397a..c29068934efa 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -24,7 +24,6 @@ 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"; @@ -112,13 +111,12 @@ 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 && !(compactSidebarEnabled && !isMobile && !open); + const isSearching = query.trim().length > 0; const hasResults = results.length > 0; useEffect(() => { @@ -235,18 +233,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { <> - { - setOpen(true); - requestAnimationFrame(() => searchInputRef.current?.focus()); - }} - > - - -
      +
      handleSectionClick(item.to)} > - - {item.label} - + {item.label} ); @@ -360,12 +343,10 @@ 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 1fe96d2b4df5..837aa7b47ce3 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -159,14 +159,6 @@ 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 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 2f65cf8c3697..afbbf7671dfc 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,10 +224,8 @@ 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 deleted file mode 100644 index 7327af79e794..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 785b8d9459d2..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index d0718d9856f8..878235615b39 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,10 +20,9 @@ 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, useSidebar } from "../ui/sidebar"; +import { SidebarMenuButton } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { @@ -70,9 +69,6 @@ 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 @@ -83,24 +79,10 @@ 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 8c04eec6fe7d..a94b7801ecfd 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 404295f5f5c1..307feda7abfc 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,11 +591,9 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; - fixedFooter?: React.ReactNode; }) { return ( <> @@ -619,7 +617,6 @@ function SidebarContent({ {...props} /> - {fixedFooter ?
      {fixedFooter}
      : null} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fa4b8bc8fd31..194cc36c55f4 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,13 +379,6 @@ 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/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5169e438bb2d..8c6287010d8f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -228,15 +228,3 @@ describe("formatElapsedDurationLabel", () => { expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); }); }); - -describe("explicit relative-time clock", () => { - it("uses the supplied minute instead of the wall clock", () => { - const completedAt = "2026-09-07T01:00:00Z"; - expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:01:00Z"))).toBe("1m ago"); - expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toBe("2m ago"); - expect(formatRelativeTime(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toEqual({ - value: "2m", - suffix: "ago", - }); - }); -}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 983a9bb8b232..9dd463bb50fa 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -196,10 +196,10 @@ export type RelativeTimeState = | { status: "invalid" } | { status: "relative"; value: string; suffix: string | null }; -export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null { +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); if (!date) return null; - const diffMs = nowMs - date.getTime(); + const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string, nowMs = Date.now()): Relativ return { value: `${days}d`, suffix: "ago" }; } -export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) { - const relative = formatRelativeTime(isoDate, nowMs); +export function formatRelativeTimeLabel(isoDate: string) { + const relative = formatRelativeTime(isoDate); if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts index aed95897cc55..b481221e63aa 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]: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)))]"; + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c8c6922ec020..b090b47fba5b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -482,18 +482,7 @@ describe("ClientSettings environment identification", () => { describe("ClientSettings sidebar", () => { it("defaults to the current sidebar", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarCompactThreadRows).toBe(false); - }); - - it("preserves an explicit compact thread row preference", () => { - expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe( - true, - ); - expect( - decodeClientSettingsPatch({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows, - ).toBe(true); + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -506,6 +495,14 @@ describe("ClientSettings sidebar", () => { expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); + it("drops the retired compact sidebar keys for users who opted in", () => { + const stored = { compactSidebarEnabled: true, sidebarCompactThreadRows: true }; + const decoded = decodeClientSettings(stored); + expect(decoded).not.toHaveProperty("compactSidebarEnabled"); + expect(decoded).not.toHaveProperty("sidebarCompactThreadRows"); + expect(decodeClientSettingsPatch(stored)).toEqual({}); + }); + it("preserves an explicit legacy sidebar opt-in", () => { expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true); expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1262a303ba41..7b3715d704be 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -437,7 +437,6 @@ 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)), ), @@ -451,7 +450,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), - sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), @@ -1504,14 +1502,12 @@ 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), ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), - sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean),