authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-14 03:57:25+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 14:57:25-03:00
log77bca8b2d76a1f42552e5eee7d277fcb1160347a
tree0c9832093890167104122f8b74b87b0bb2b7e43f
parent6e5e986f15b3cbec3588646ddf216f00b4301785
signature Signed by PGP key B5690EEEBB952194

feat(web): add compact thread list mode (#9417)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: maria-rcks <maria@kuuro.net>

11 files changed, 373 insertions(+), 102 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -57,6 +57,7 @@ const clientSettings: ClientSettings = {
5757 proactivePanelsEnabled: true,
5858 showSkillsInSlashMenu: false,
5959 providerModelPreferences: {},
60 sidebarCompactThreadRows: false,
6061 sidebarProjectGroupingMode: "repository_path",
6162 sidebarProjectGroupingOverrides: {
6263 "environment-1:/tmp/project-a": "separate",
apps/web/src/components/Sidebar.tsx+163-74
......@@ -124,6 +124,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions";
124124import { useClientSettings } from "../hooks/useSettings";
125125import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
126126import { useLocalStorage } from "../hooks/useLocalStorage";
127import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime";
127128import { useNowMinute } from "../hooks/useNowMinute";
128129import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
129130import {
......@@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
10311032const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10321033 thread: SidebarThreadSummary;
10331034 variant: "card" | "slim";
1035 compact: boolean;
10341036 // Slim rows are either settled (action: un-settle) or merely quiet
10351037 // (seen Ready threads — action: settle).
10361038 variantAction: "settle" | "unsettle" | "unsnooze";
......@@ -1292,7 +1294,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12921294 terminalStatus={terminalStatus}
12931295 terminalProcessCount={terminalProcessCount}
12941296 compactStatus={
1295 compact
1297 compact || (props.compact && variant === "card")
12961298 ? (topStatus?.label ??
12971299 (variantAction === "unsnooze"
12981300 ? "Snoozed"
......@@ -1878,6 +1880,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18781880 );
18791881 }
18801882
1883 const compactRows = props.compact;
1884 const CompactStatusIcon = topStatus
1885 ? {
1886 working: CircleDashedIcon,
1887 monitoring: EyeIcon,
1888 approval: ShieldQuestionIcon,
1889 input: MessageCircleQuestionIcon,
1890 failed: CircleAlertIcon,
1891 woke: AlarmClockIcon,
1892 done: CircleCheckIcon,
1893 }[topStatus.icon]
1894 : null;
1895 const compactCompletedAt =
1896 compactRows && status === "ready" && !isWokeStatus
1897 ? (thread.latestTurn?.completedAt ?? null)
1898 : null;
18811899 const diff = latestTurnDiff(thread);
18821900
18831901 return (
......@@ -1886,8 +1904,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18861904 {...sortableRootProps}
18871905 {...(fileDropHandlers ?? {})}
18881906 className={cn(
1889 // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.
1890 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",
1907 "list-none [content-visibility:auto]",
1908 compactRows
1909 ? "[contain-intrinsic-size:auto_36px]"
1910 : "py-0.5 [contain-intrinsic-size:auto_78px]",
18911911 sortable?.isDragging && "relative z-20",
18921912 )}
18931913 >
......@@ -1902,7 +1922,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19021922 ref={rowRef}
19031923 role="button"
19041924 tabIndex={0}
1905 data-testid="sidebar-row-card"
1925 data-testid={compactRows ? "sidebar-row-compact" : "sidebar-row-card"}
19061926 aria-busy={isRegeneratingTitle || undefined}
19071927 className={rowSurfaceClassName}
19081928 onClick={handleClick}
......@@ -1912,13 +1932,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19121932 />
19131933 }
19141934 >
1915 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
1916 <div className="flex h-5 min-w-0 items-center gap-1.5">
1935 <div
1936 className={cn(
1937 "relative z-10",
1938 compactRows
1939 ? "flex h-9 items-center px-2.5"
1940 : "h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]",
1941 )}
1942 >
1943 <div className="flex h-5 w-full min-w-0 items-center gap-1.5">
19171944 {draftIndicator}
19181945 {props.project ? (
19191946 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
19201947 ) : null}
1921 {props.projectDisplayName ? (
1948 {compactRows ? (
1949 title
1950 ) : props.projectDisplayName ? (
19221951 <span
19231952 className={cn(
19241953 "min-w-0 flex-1 truncate text-secondary-label text-xs",
......@@ -1931,6 +1960,50 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19311960 <span className="flex-1" />
19321961 )}
19331962 {pinIndicator}
1963 {compactRows ? (
1964 <>
1965 {isRemote ? (
1966 <span
1967 role="img"
1968 aria-label={props.environmentLabel ?? "Remote environment"}
1969 className="inline-flex shrink-0 text-sidebar-muted-foreground/70"
1970 >
1971 <EnvironmentMachineIcon
1972 kind={props.environmentMachine}
1973 className="size-3.5"
1974 />
1975 </span>
1976 ) : null}
1977 {terminalStatusIcon}
1978 {topStatus && CompactStatusIcon ? (
1979 isWokeStatus ? (
1980 <button
1981 type="button"
1982 aria-label="Dismiss Woke notification"
1983 onClick={handleAcknowledgeWokeClick}
1984 className={cn(
1985 "shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
1986 topStatus.className,
1987 )}
1988 >
1989 <CompactStatusIcon aria-hidden className="size-3.5" />
1990 <span role="status" className="sr-only">
1991 {topStatus.label}
1992 </span>
1993 </button>
1994 ) : (
1995 <span
1996 role="status"
1997 className={cn("inline-flex shrink-0", topStatus.className)}
1998 >
1999 <CompactStatusIcon aria-hidden className="size-3.5" />
2000 <span className="sr-only">{topStatus.label}</span>
2001 </span>
2002 )
2003 ) : null}
2004 {prBadge}
2005 </>
2006 ) : null}
19342007 {/* The visible state owns this slot's width: status at rest,
19352008 actions on hover/keyboard focus or while the popover is open. Keeping
19362009 the hidden state out of flow lets the project label reclaim
......@@ -1938,20 +2011,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19382011 {sortable?.isDragging ? (
19392012 dragDestination
19402013 ) : (
1941 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
2014 <span
2015 className={cn(
2016 "group/sidebar-status-slot relative ml-auto flex h-5 shrink-0 items-stretch justify-end text-xs",
2017 !compactRows && "min-w-8",
2018 )}
2019 >
19422020 {/* Read-only status labels yield to the hover actions. Woke is
19432021 itself an action, so it stays pointer-enabled and visible
19442022 while the other controls appear beside it. */}
19452023 <span
19462024 className={cn(
1947 isWokeStatus
2025 isWokeStatus && !compactRows
19482026 ? "pointer-events-auto"
19492027 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
19502028 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
19512029 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
19522030 )}
19532031 >
1954 {topStatus ? (
2032 {compactRows ? (
2033 status === "working" ? (
2034 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
2035 ) : compactCompletedAt ? (
2036 <SidebarCompletedTime completedAt={compactCompletedAt} />
2037 ) : (
2038 threadTimeLabel(thread)
2039 )
2040 ) : topStatus ? (
19552041 isWokeStatus ? (
19562042 <Tooltip>
19572043 <TooltipTrigger
......@@ -2057,7 +2143,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
20572143 }
20582144 >
20592145 <CheckIcon className="size-3.5" />
2060 Settle
2146 {compactRows ? null : "Settle"}
20612147 </TooltipTrigger>
20622148 <TooltipPopup>Settle thread</TooltipPopup>
20632149 </Tooltip>
......@@ -2067,68 +2153,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
20672153 </span>
20682154 )}
20692155 </div>
2070 <div className="mt-1 flex min-w-0">
2071 {title}
2072 {isRegeneratingTitle ? (
2073 <span role="status" className="sr-only">
2074 Regenerating title
2075 </span>
2076 ) : null}
2077 </div>
2078 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
2079 {/* Always the branch. The plan step used to take this slot while
2156 {isRegeneratingTitle ? (
2157 <span role="status" className="sr-only">
2158 Regenerating title
2159 </span>
2160 ) : null}
2161 {compactRows ? null : (
2162 <>
2163 <div className="mt-1 flex min-w-0">{title}</div>
2164 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
2165 {/* Always the branch. The plan step used to take this slot while
20802166 working, but it truncated to a half-sentence and dropped the
20812167 branch, so the row lost its most stable identifier. */}
2082 {thread.branch ? (
2083 <>
2084 <ThreadWorktreeIndicator thread={thread} />
2085 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
2086 {thread.branch}
2087 </span>
2088 </>
2089 ) : (
2090 <span className="flex-1" />
2091 )}
2092 {terminalStatusIcon}
2093 {prBadge}
2094 {diff ? (
2095 <span className="shrink-0 font-mono">
2096 <span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
2097 <span className="text-diff-deletion-foreground">−{diff.deletions}</span>
2098 </span>
2099 ) : null}
2100 <span
2101 aria-hidden
2102 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
2103 >
2104 {isRemote ? (
2105 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
2106 <EnvironmentMachineIcon
2107 aria-hidden
2108 kind={props.environmentMachine}
2109 className="size-3.5"
2110 />
2111 </span>
2112 ) : null}
2113 {driverKind ? (
2114 <span className="inline-flex shrink-0 items-center">
2115 <ProviderInstanceIcon
2116 driverKind={driverKind}
2117 displayName={
2118 providerEntry?.displayName ??
2119 thread.session?.providerName ??
2120 modelInstanceId
2121 }
2122 accentColor={providerEntry?.accentColor}
2123 showBadge={showInstanceBadge}
2124 // Glyph dims, badge stays saturated; offset matches the composer trigger.
2125 iconClassName="size-3.5 opacity-60"
2126 badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"
2127 />
2168 {thread.branch ? (
2169 <>
2170 <ThreadWorktreeIndicator thread={thread} />
2171 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
2172 {thread.branch}
2173 </span>
2174 </>
2175 ) : (
2176 <span className="flex-1" />
2177 )}
2178 {terminalStatusIcon}
2179 {prBadge}
2180 {diff ? (
2181 <span className="shrink-0 font-mono">
2182 <span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
2183 <span className="text-diff-deletion-foreground">−{diff.deletions}</span>
2184 </span>
2185 ) : null}
2186 <span
2187 aria-hidden
2188 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
2189 >
2190 {isRemote ? (
2191 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
2192 <EnvironmentMachineIcon
2193 aria-hidden
2194 kind={props.environmentMachine}
2195 className="size-3.5"
2196 />
2197 </span>
2198 ) : null}
2199 {driverKind ? (
2200 <span className="inline-flex shrink-0 items-center">
2201 <ProviderInstanceIcon
2202 driverKind={driverKind}
2203 displayName={
2204 providerEntry?.displayName ??
2205 thread.session?.providerName ??
2206 modelInstanceId
2207 }
2208 accentColor={providerEntry?.accentColor}
2209 showBadge={showInstanceBadge}
2210 // Glyph dims, badge stays saturated; offset matches the composer trigger.
2211 iconClassName="size-3.5 opacity-60"
2212 badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"
2213 />
2214 </span>
2215 ) : null}
21282216 </span>
2129 ) : null}
2130 </span>
2131 </div>
2217 </div>
2218 </>
2219 )}
21322220 </div>
21332221 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
21342222 </TooltipTrigger>
......@@ -2303,6 +2391,7 @@ export default function Sidebar() {
23032391 const compact = compactEnabled && sidebarState === "collapsed" && !isMobile;
23042392 const [snoozedFooter, setSnoozedFooter] = useState<HTMLUListElement | null>(null);
23052393 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2394 const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows);
23062395 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
23072396 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
23082397 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
......@@ -4829,10 +4918,9 @@ export default function Sidebar() {
48294918 const threadKey = scopedThreadKey(
48304919 scopeThreadRef(thread.environmentId, thread.id),
48314920 );
4832 // Settled and snoozed are the ONLY things that collapse a
4833 // row: every other thread is a full card. Density comes
4834 // from users (or the auto rules) actually parking work,
4835 // not from the sidebar second-guessing what still matters.
4921 // Settled and snoozed always use slim rows. Active and
4922 // pinned threads use cards unless the user has explicitly
4923 // enabled the compact thread-list preference.
48364924 const isCard = section === "active" || section === "pinned";
48374925 const rowVariant = isCard ? "card" : "slim";
48384926 return (
......@@ -4842,6 +4930,7 @@ export default function Sidebar() {
48424930 key={`${threadKey}:${rowVariant}`}
48434931 thread={thread}
48444932 variant={rowVariant}
4933 compact={compactThreadRows}
48454934 // Snoozed rows wake, settled rows un-settle, and cards settle.
48464935 variantAction={
48474936 section === "snoozed"
apps/web/src/components/settings/CompactSidebarPreview.tsx+38-9
......@@ -1,9 +1,30 @@
1import { useState } from "react";
1import { useEffect, useRef, useState } from "react";
22
33import { cn } from "~/lib/utils";
44
5export function CompactSidebarPreview() {
5export function CompactSidebarPreview({
6 railEnabled,
7 compactRows,
8}: {
9 railEnabled: boolean;
10 compactRows: boolean;
11}) {
612 const [collapsed, setCollapsed] = useState(false);
13 const sidebarRef = useRef<HTMLSpanElement>(null);
14
15 useEffect(() => {
16 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
17 const animation = sidebarRef.current?.animate(
18 [
19 { width: "36px", offset: 0 },
20 { width: railEnabled ? "12px" : "0px", offset: 0.45 },
21 { width: railEnabled ? "12px" : "0px", offset: 0.6 },
22 { width: "36px", offset: 1 },
23 ],
24 { duration: 800, easing: "ease-in-out" },
25 );
26 return () => animation?.cancel();
27 }, [railEnabled, compactRows]);
728
829 return (
930 <button
......@@ -11,24 +32,31 @@ export function CompactSidebarPreview() {
1132 aria-label="Toggle compact sidebar preview"
1233 aria-pressed={collapsed}
1334 className="group/compact-preview relative flex h-10 w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-background p-1 shadow-xs/5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
14 onClick={() => setCollapsed((value) => !value)}
35 onClick={() => {
36 sidebarRef.current?.getAnimations().forEach((animation) => animation.cancel());
37 setCollapsed((value) => !value);
38 }}
1539 >
1640 <span
41 ref={sidebarRef}
1742 aria-hidden
1843 className={cn(
19 "flex h-full shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-md bg-sidebar px-1 transition-[width] duration-250 ease-out motion-reduce:transition-none",
20 collapsed ? "w-3" : "w-9",
44 "flex h-full shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-md bg-sidebar transition-[width] duration-250 ease-out motion-reduce:transition-none",
45 collapsed ? (railEnabled ? "w-3" : "w-0") : "w-9",
2146 )}
2247 >
23 {[0, 1, 2].map((row) => (
24 <span key={row} className="flex h-1 shrink-0 items-center gap-1">
48 {(compactRows ? [0, 1, 2, 3] : [0, 1, 2]).map((row) => (
49 <span key={row} className="flex shrink-0 items-start gap-1 px-1">
2550 <span className="size-1 shrink-0 rounded-[1px] bg-muted-foreground/50" />
2651 <span
2752 className={cn(
28 "h-0.5 w-4 shrink-0 rounded-full bg-muted-foreground/25 transition-opacity duration-250 ease-out motion-reduce:transition-none",
53 "flex shrink-0 flex-col gap-0.5 transition-opacity duration-250 ease-out motion-reduce:transition-none",
2954 collapsed && "opacity-0",
3055 )}
31 />
56 >
57 <span className="h-0.5 w-4 rounded-full bg-muted-foreground/25" />
58 {!compactRows && <span className="h-0.5 w-2 rounded-full bg-muted-foreground/15" />}
59 </span>
3260 </span>
3361 ))}
3462 </span>
......@@ -42,6 +70,7 @@ export function CompactSidebarPreview() {
4270 className={cn(
4371 "pointer-events-none absolute top-3 left-5 flex w-9 flex-col gap-1 rounded-sm border border-border bg-popover p-1 opacity-0 shadow-xs transition-opacity duration-150 ease-out motion-reduce:transition-none",
4472 collapsed &&
73 railEnabled &&
4574 "group-hover/compact-preview:opacity-100 group-focus-visible/compact-preview:opacity-100",
4675 )}
4776 >
apps/web/src/components/settings/SettingsPanels.tsx+66-13
......@@ -502,7 +502,8 @@ export function useSettingsRestore(onRestored?: () => void) {
502502 ...(theme !== "system" ? ["Theme"] : []),
503503 ...(!followSystem ? ["Follow system"] : []),
504504 ...(themeHalves !== null ? ["Theme mix"] : []),
505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled
505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled ||
506 settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows
506507 ? ["Compact sidebar"]
507508 : []),
508509 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
......@@ -644,6 +645,7 @@ export function useSettingsRestore(onRestored?: () => void) {
644645 settings.continueThreadsAfterServerUpdate,
645646 settings.sidebarAutoSettleAfterDays,
646647 settings.sidebarAutoSettleOnMerge,
648 settings.sidebarCompactThreadRows,
647649 settings.sidebarProjectGroupingMode,
648650 settings.sidebarThreadPreviewCount,
649651 settings.showSkillsInSlashMenu,
......@@ -739,6 +741,7 @@ export function useSettingsRestore(onRestored?: () => void) {
739741 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
740742 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
741743 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
744 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
742745 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
743746 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
744747 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
......@@ -1081,6 +1084,19 @@ export function AppearanceSettingsPanel() {
10811084 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
10821085 const settings = useScopedSettings();
10831086 const updateSettings = useUpdateScopedSettings();
1087 const compactSidebarMode = settings.compactSidebarEnabled
1088 ? settings.sidebarCompactThreadRows
1089 ? "both"
1090 : "rail"
1091 : settings.sidebarCompactThreadRows
1092 ? "threads"
1093 : "off";
1094 const compactSidebarModes = {
1095 off: "Off",
1096 rail: "Rail only",
1097 threads: "Threads only",
1098 both: "Both",
1099 };
10841100 const environmentStageLabel = useEnvironmentStageLabel();
10851101 const showEnvironmentIdentification =
10861102 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
......@@ -1370,19 +1386,56 @@ export function AppearanceSettingsPanel() {
13701386 <SettingsSection id="appearance-sidebar" title="Sidebar">
13711387 <SettingsRow
13721388 {...searchableSetting("compact-sidebar")}
1373 description="Keep an icon rail when the sidebar is collapsed. Click the preview to try it."
1389 description="Choose a collapsed icon rail, denser thread rows, or both. Click the preview to collapse or expand."
1390 resetAction={
1391 settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled ||
1392 settings.sidebarCompactThreadRows !==
1393 DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows ? (
1394 <SettingResetButton
1395 label="compact sidebar"
1396 onClick={() =>
1397 updateSettings({
1398 compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled,
1399 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
1400 })
1401 }
1402 />
1403 ) : null
1404 }
13741405 control={
1375 <div className="grid w-full grid-cols-[5rem_auto] items-center justify-end gap-3 sm:w-auto sm:grid-cols-[7rem_auto] sm:gap-4">
1376 <CompactSidebarPreview />
1377 <div className="flex justify-end">
1378 <Switch
1379 checked={settings.compactSidebarEnabled}
1380 onCheckedChange={(checked) =>
1381 updateSettings({ compactSidebarEnabled: Boolean(checked) })
1382 }
1383 aria-label="Compact sidebar"
1384 />
1385 </div>
1406 <div className="grid w-full grid-cols-[5rem_1fr] items-center gap-3 sm:w-auto sm:grid-cols-[7rem_10rem] sm:gap-4">
1407 <CompactSidebarPreview
1408 key={compactSidebarMode}
1409 railEnabled={settings.compactSidebarEnabled}
1410 compactRows={settings.sidebarCompactThreadRows}
1411 />
1412 <Select
1413 value={compactSidebarMode}
1414 onValueChange={(value) => {
1415 if (
1416 value !== "off" &&
1417 value !== "rail" &&
1418 value !== "threads" &&
1419 value !== "both"
1420 )
1421 return;
1422 updateSettings({
1423 compactSidebarEnabled: value === "rail" || value === "both",
1424 sidebarCompactThreadRows: value === "threads" || value === "both",
1425 });
1426 }}
1427 >
1428 <SelectTrigger size="sm" className="w-full" aria-label="Compact sidebar">
1429 <SelectValue>{compactSidebarModes[compactSidebarMode]}</SelectValue>
1430 </SelectTrigger>
1431 <SelectPopup align="end" alignItemWithTrigger={false}>
1432 {Object.entries(compactSidebarModes).map(([value, label]) => (
1433 <SelectItem key={value} hideIndicator value={value}>
1434 {label}
1435 </SelectItem>
1436 ))}
1437 </SelectPopup>
1438 </Select>
13861439 </div>
13871440 }
13881441 />
apps/web/src/components/settings/settingsSearch.ts+3-1
......@@ -163,7 +163,9 @@ export const SETTINGS_SEARCH_ITEMS = [
163163 id: "compact-sidebar",
164164 title: "Compact sidebar",
165165 to: "/settings/appearance",
166 searchTerms: ["collapsed icons rail hover navigation preview"],
166 searchTerms: [
167 "collapsed icons rail hover navigation preview expanded dense density one line rows chats threads compact thread list",
168 ],
167169 },
168170 {
169171 id: "environment-identification",
apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx created+56
......@@ -0,0 +1,56 @@
1import { act, memo } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5import { SidebarCompletedTime } from "./SidebarCompletedTime";
6
7let renderer: ReactTestRenderer | undefined;
8
9beforeEach(() => {
10 vi.useFakeTimers();
11 vi.setSystemTime(new Date("2026-09-07T01:01:00Z"));
12 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
13 vi.stubGlobal("window", {
14 setTimeout,
15 clearTimeout,
16 setInterval,
17 clearInterval,
18 });
19});
20
21afterEach(async () => {
22 await act(() => renderer?.unmount());
23 renderer = undefined;
24 vi.unstubAllGlobals();
25 vi.useRealTimers();
26});
27
28it("advances visible and accessible completion times without rerendering its memoized row", async () => {
29 const rowRender = vi.fn();
30 const Row = memo(function Row() {
31 rowRender();
32 return <SidebarCompletedTime completedAt="2026-09-07T01:00:00Z" />;
33 });
34 await act(() => {
35 renderer = create(<Row />);
36 });
37 expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z");
38 expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]);
39 expect(
40 renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]),
41 ).toHaveLength(0);
42 expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([
43 "1m",
44 ]);
45
46 await act(() => vi.advanceTimersByTime(60_000));
47
48 expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]);
49 expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([
50 "2m",
51 ]);
52 expect(rowRender).toHaveBeenCalledTimes(1);
53 await act(() => renderer!.unmount());
54 renderer = undefined;
55 expect(vi.getTimerCount()).toBe(0);
56});
apps/web/src/components/sidebar/SidebarCompletedTime.tsx created+16
......@@ -0,0 +1,16 @@
1import { useNowMinute } from "../../hooks/useNowMinute";
2import { formatRelativeTimeLabel } from "../../timestampFormat";
3
4export function SidebarCompletedTime({ completedAt }: { completedAt: string }) {
5 // Subscribe inside the label so time advances even when the row is memoized.
6 const nowMinute = useNowMinute();
7 const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`));
8 const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, "");
9
10 return (
11 <time dateTime={completedAt} className="inline-flex items-center">
12 <span className="sr-only">Completed </span>
13 <span className="text-secondary-label">{label}</span>
14 </time>
15 );
16}
apps/web/src/timestampFormat.test.ts+12
......@@ -228,3 +228,15 @@ describe("formatElapsedDurationLabel", () => {
228228 expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d");
229229 });
230230});
231
232describe("explicit relative-time clock", () => {
233 it("uses the supplied minute instead of the wall clock", () => {
234 const completedAt = "2026-09-07T01:00:00Z";
235 expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:01:00Z"))).toBe("1m ago");
236 expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toBe("2m ago");
237 expect(formatRelativeTime(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toEqual({
238 value: "2m",
239 suffix: "ago",
240 });
241 });
242});
apps/web/src/timestampFormat.ts+4-4
......@@ -196,10 +196,10 @@ export type RelativeTimeState =
196196 | { status: "invalid" }
197197 | { status: "relative"; value: string; suffix: string | null };
198198
199export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
199export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null {
200200 const date = parseTimestampDate(isoDate);
201201 if (!date) return null;
202 const diffMs = Date.now() - date.getTime();
202 const diffMs = nowMs - date.getTime();
203203 if (diffMs < 0) return { value: "just now", suffix: null };
204204 const seconds = Math.floor(diffMs / 1000);
205205 if (seconds < 60) return { value: "just now", suffix: null };
......@@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
211211 return { value: `${days}d`, suffix: "ago" };
212212}
213213
214export function formatRelativeTimeLabel(isoDate: string) {
215 const relative = formatRelativeTime(isoDate);
214export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) {
215 const relative = formatRelativeTime(isoDate, nowMs);
216216 if (!relative) return "";
217217 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
218218}
packages/contracts/src/settings.test.ts+12-1
......@@ -482,7 +482,18 @@ describe("ClientSettings environment identification", () => {
482482
483483describe("ClientSettings sidebar", () => {
484484 it("defaults to the current sidebar", () => {
485 expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false);
485 const settings = decodeClientSettings({});
486 expect(settings.legacySidebarEnabled).toBe(false);
487 expect(settings.sidebarCompactThreadRows).toBe(false);
488 });
489
490 it("preserves an explicit compact thread row preference", () => {
491 expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe(
492 true,
493 );
494 expect(
495 decodeClientSettingsPatch({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows,
496 ).toBe(true);
486497 });
487498
488499 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
packages/contracts/src/settings.ts+2
......@@ -451,6 +451,7 @@ export const ClientSettingsSchema = Schema.Struct({
451451 sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
452452 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),
453453 ),
454 sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
454455 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
455456 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
456457 ),
......@@ -1500,6 +1501,7 @@ export const ClientSettingsPatch = Schema.Struct({
15001501 ),
15011502 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
15021503 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
1504 sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean),
15031505 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
15041506 timestampFormat: Schema.optionalKey(TimestampFormat),
15051507 snapShotEnabled: Schema.optionalKey(Schema.Boolean),