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 = {...@@ -57,6 +57,7 @@ const clientSettings: ClientSettings = {
57 proactivePanelsEnabled: true,57 proactivePanelsEnabled: true,
58 showSkillsInSlashMenu: false,58 showSkillsInSlashMenu: false,
59 providerModelPreferences: {},59 providerModelPreferences: {},
60 sidebarCompactThreadRows: false,
60 sidebarProjectGroupingMode: "repository_path",61 sidebarProjectGroupingMode: "repository_path",
61 sidebarProjectGroupingOverrides: {62 sidebarProjectGroupingOverrides: {
62 "environment-1:/tmp/project-a": "separate",63 "environment-1:/tmp/project-a": "separate",
apps/web/src/components/Sidebar.tsx+163-74
...@@ -124,6 +124,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions";...@@ -124,6 +124,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions";
124import { useClientSettings } from "../hooks/useSettings";124import { useClientSettings } from "../hooks/useSettings";
125import { useCopyToClipboard } from "../hooks/useCopyToClipboard";125import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
126import { useLocalStorage } from "../hooks/useLocalStorage";126import { useLocalStorage } from "../hooks/useLocalStorage";
127import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime";
127import { useNowMinute } from "../hooks/useNowMinute";128import { useNowMinute } from "../hooks/useNowMinute";
128import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";129import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
129import {130import {
...@@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {...@@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
1031const SidebarThreadRow = memo(function SidebarThreadRow(props: {1032const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1032 thread: SidebarThreadSummary;1033 thread: SidebarThreadSummary;
1033 variant: "card" | "slim";1034 variant: "card" | "slim";
1035 compact: boolean;
1034 // Slim rows are either settled (action: un-settle) or merely quiet1036 // Slim rows are either settled (action: un-settle) or merely quiet
1035 // (seen Ready threads — action: settle).1037 // (seen Ready threads — action: settle).
1036 variantAction: "settle" | "unsettle" | "unsnooze";1038 variantAction: "settle" | "unsettle" | "unsnooze";
...@@ -1292,7 +1294,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1292,7 +1294,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1292 terminalStatus={terminalStatus}1294 terminalStatus={terminalStatus}
1293 terminalProcessCount={terminalProcessCount}1295 terminalProcessCount={terminalProcessCount}
1294 compactStatus={1296 compactStatus={
1295 compact1297 compact || (props.compact && variant === "card")
1296 ? (topStatus?.label ??1298 ? (topStatus?.label ??
1297 (variantAction === "unsnooze"1299 (variantAction === "unsnooze"
1298 ? "Snoozed"1300 ? "Snoozed"
...@@ -1878,6 +1880,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1878,6 +1880,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1878 );1880 );
1879 }1881 }
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;
1881 const diff = latestTurnDiff(thread);1899 const diff = latestTurnDiff(thread);
18821900
1883 return (1901 return (
...@@ -1886,8 +1904,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1886,8 +1904,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1886 {...sortableRootProps}1904 {...sortableRootProps}
1887 {...(fileDropHandlers ?? {})}1905 {...(fileDropHandlers ?? {})}
1888 className={cn(1906 className={cn(
1889 // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.1907 "list-none [content-visibility:auto]",
1890 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",1908 compactRows
1909 ? "[contain-intrinsic-size:auto_36px]"
1910 : "py-0.5 [contain-intrinsic-size:auto_78px]",
1891 sortable?.isDragging && "relative z-20",1911 sortable?.isDragging && "relative z-20",
1892 )}1912 )}
1893 >1913 >
...@@ -1902,7 +1922,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1902,7 +1922,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1902 ref={rowRef}1922 ref={rowRef}
1903 role="button"1923 role="button"
1904 tabIndex={0}1924 tabIndex={0}
1905 data-testid="sidebar-row-card"1925 data-testid={compactRows ? "sidebar-row-compact" : "sidebar-row-card"}
1906 aria-busy={isRegeneratingTitle || undefined}1926 aria-busy={isRegeneratingTitle || undefined}
1907 className={rowSurfaceClassName}1927 className={rowSurfaceClassName}
1908 onClick={handleClick}1928 onClick={handleClick}
...@@ -1912,13 +1932,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1912,13 +1932,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1912 />1932 />
1913 }1933 }
1914 >1934 >
1915 <div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">1935 <div
1916 <div className="flex h-5 min-w-0 items-center gap-1.5">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">
1917 {draftIndicator}1944 {draftIndicator}
1918 {props.project ? (1945 {props.project ? (
1919 <ProjectFavicon project={props.project} className="size-4 shrink-0" />1946 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
1920 ) : null}1947 ) : null}
1921 {props.projectDisplayName ? (1948 {compactRows ? (
1949 title
1950 ) : props.projectDisplayName ? (
1922 <span1951 <span
1923 className={cn(1952 className={cn(
1924 "min-w-0 flex-1 truncate text-secondary-label text-xs",1953 "min-w-0 flex-1 truncate text-secondary-label text-xs",
...@@ -1931,6 +1960,50 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1931,6 +1960,50 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1931 <span className="flex-1" />1960 <span className="flex-1" />
1932 )}1961 )}
1933 {pinIndicator}1962 {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}
1934 {/* The visible state owns this slot's width: status at rest,2007 {/* The visible state owns this slot's width: status at rest,
1935 actions on hover/keyboard focus or while the popover is open. Keeping2008 actions on hover/keyboard focus or while the popover is open. Keeping
1936 the hidden state out of flow lets the project label reclaim2009 the hidden state out of flow lets the project label reclaim
...@@ -1938,20 +2011,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1938,20 +2011,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1938 {sortable?.isDragging ? (2011 {sortable?.isDragging ? (
1939 dragDestination2012 dragDestination
1940 ) : (2013 ) : (
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 >
1942 {/* Read-only status labels yield to the hover actions. Woke is2020 {/* Read-only status labels yield to the hover actions. Woke is
1943 itself an action, so it stays pointer-enabled and visible2021 itself an action, so it stays pointer-enabled and visible
1944 while the other controls appear beside it. */}2022 while the other controls appear beside it. */}
1945 <span2023 <span
1946 className={cn(2024 className={cn(
1947 isWokeStatus2025 isWokeStatus && !compactRows
1948 ? "pointer-events-auto"2026 ? "pointer-events-auto"
1949 : "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",2027 : "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",
1950 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",2028 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1951 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",2029 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1952 )}2030 )}
1953 >2031 >
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 ? (
1955 isWokeStatus ? (2041 isWokeStatus ? (
1956 <Tooltip>2042 <Tooltip>
1957 <TooltipTrigger2043 <TooltipTrigger
...@@ -2057,7 +2143,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2057,7 +2143,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2057 }2143 }
2058 >2144 >
2059 <CheckIcon className="size-3.5" />2145 <CheckIcon className="size-3.5" />
2060 Settle2146 {compactRows ? null : "Settle"}
2061 </TooltipTrigger>2147 </TooltipTrigger>
2062 <TooltipPopup>Settle thread</TooltipPopup>2148 <TooltipPopup>Settle thread</TooltipPopup>
2063 </Tooltip>2149 </Tooltip>
...@@ -2067,68 +2153,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -2067,68 +2153,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
2067 </span>2153 </span>
2068 )}2154 )}
2069 </div>2155 </div>
2070 <div className="mt-1 flex min-w-0">2156 {isRegeneratingTitle ? (
2071 {title}2157 <span role="status" className="sr-only">
2072 {isRegeneratingTitle ? (2158 Regenerating title
2073 <span role="status" className="sr-only">2159 </span>
2074 Regenerating title2160 ) : null}
2075 </span>2161 {compactRows ? null : (
2076 ) : null}2162 <>
2077 </div>2163 <div className="mt-1 flex min-w-0">{title}</div>
2078 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">2164 <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 while2165 {/* Always the branch. The plan step used to take this slot while
2080 working, but it truncated to a half-sentence and dropped the2166 working, but it truncated to a half-sentence and dropped the
2081 branch, so the row lost its most stable identifier. */}2167 branch, so the row lost its most stable identifier. */}
2082 {thread.branch ? (2168 {thread.branch ? (
2083 <>2169 <>
2084 <ThreadWorktreeIndicator thread={thread} />2170 <ThreadWorktreeIndicator thread={thread} />
2085 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">2171 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
2086 {thread.branch}2172 {thread.branch}
2087 </span>2173 </span>
2088 </>2174 </>
2089 ) : (2175 ) : (
2090 <span className="flex-1" />2176 <span className="flex-1" />
2091 )}2177 )}
2092 {terminalStatusIcon}2178 {terminalStatusIcon}
2093 {prBadge}2179 {prBadge}
2094 {diff ? (2180 {diff ? (
2095 <span className="shrink-0 font-mono">2181 <span className="shrink-0 font-mono">
2096 <span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}2182 <span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
2097 <span className="text-diff-deletion-foreground">−{diff.deletions}</span>2183 <span className="text-diff-deletion-foreground">−{diff.deletions}</span>
2098 </span>2184 </span>
2099 ) : null}2185 ) : null}
2100 <span2186 <span
2101 aria-hidden2187 aria-hidden
2102 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"2188 className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
2103 >2189 >
2104 {isRemote ? (2190 {isRemote ? (
2105 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">2191 <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
2106 <EnvironmentMachineIcon2192 <EnvironmentMachineIcon
2107 aria-hidden2193 aria-hidden
2108 kind={props.environmentMachine}2194 kind={props.environmentMachine}
2109 className="size-3.5"2195 className="size-3.5"
2110 />2196 />
2111 </span>2197 </span>
2112 ) : null}2198 ) : null}
2113 {driverKind ? (2199 {driverKind ? (
2114 <span className="inline-flex shrink-0 items-center">2200 <span className="inline-flex shrink-0 items-center">
2115 <ProviderInstanceIcon2201 <ProviderInstanceIcon
2116 driverKind={driverKind}2202 driverKind={driverKind}
2117 displayName={2203 displayName={
2118 providerEntry?.displayName ??2204 providerEntry?.displayName ??
2119 thread.session?.providerName ??2205 thread.session?.providerName ??
2120 modelInstanceId2206 modelInstanceId
2121 }2207 }
2122 accentColor={providerEntry?.accentColor}2208 accentColor={providerEntry?.accentColor}
2123 showBadge={showInstanceBadge}2209 showBadge={showInstanceBadge}
2124 // Glyph dims, badge stays saturated; offset matches the composer trigger.2210 // Glyph dims, badge stays saturated; offset matches the composer trigger.
2125 iconClassName="size-3.5 opacity-60"2211 iconClassName="size-3.5 opacity-60"
2126 badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"2212 badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"
2127 />2213 />
2214 </span>
2215 ) : null}
2128 </span>2216 </span>
2129 ) : null}2217 </div>
2130 </span>2218 </>
2131 </div>2219 )}
2132 </div>2220 </div>
2133 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}2221 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
2134 </TooltipTrigger>2222 </TooltipTrigger>
...@@ -2303,6 +2391,7 @@ export default function Sidebar() {...@@ -2303,6 +2391,7 @@ export default function Sidebar() {
2303 const compact = compactEnabled && sidebarState === "collapsed" && !isMobile;2391 const compact = compactEnabled && sidebarState === "collapsed" && !isMobile;
2304 const [snoozedFooter, setSnoozedFooter] = useState<HTMLUListElement | null>(null);2392 const [snoozedFooter, setSnoozedFooter] = useState<HTMLUListElement | null>(null);
2305 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2393 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2394 const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows);
2306 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);2395 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
2307 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);2396 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
2308 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);2397 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
...@@ -4829,10 +4918,9 @@ export default function Sidebar() {...@@ -4829,10 +4918,9 @@ export default function Sidebar() {
4829 const threadKey = scopedThreadKey(4918 const threadKey = scopedThreadKey(
4830 scopeThreadRef(thread.environmentId, thread.id),4919 scopeThreadRef(thread.environmentId, thread.id),
4831 );4920 );
4832 // Settled and snoozed are the ONLY things that collapse a4921 // Settled and snoozed always use slim rows. Active and
4833 // row: every other thread is a full card. Density comes4922 // pinned threads use cards unless the user has explicitly
4834 // from users (or the auto rules) actually parking work,4923 // enabled the compact thread-list preference.
4835 // not from the sidebar second-guessing what still matters.
4836 const isCard = section === "active" || section === "pinned";4924 const isCard = section === "active" || section === "pinned";
4837 const rowVariant = isCard ? "card" : "slim";4925 const rowVariant = isCard ? "card" : "slim";
4838 return (4926 return (
...@@ -4842,6 +4930,7 @@ export default function Sidebar() {...@@ -4842,6 +4930,7 @@ export default function Sidebar() {
4842 key={`${threadKey}:${rowVariant}`}4930 key={`${threadKey}:${rowVariant}`}
4843 thread={thread}4931 thread={thread}
4844 variant={rowVariant}4932 variant={rowVariant}
4933 compact={compactThreadRows}
4845 // Snoozed rows wake, settled rows un-settle, and cards settle.4934 // Snoozed rows wake, settled rows un-settle, and cards settle.
4846 variantAction={4935 variantAction={
4847 section === "snoozed"4936 section === "snoozed"
apps/web/src/components/settings/CompactSidebarPreview.tsx+38-9
...@@ -1,9 +1,30 @@...@@ -1,9 +1,30 @@
1import { useState } from "react";1import { useEffect, useRef, useState } from "react";
22
3import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
44
5export function CompactSidebarPreview() {5export function CompactSidebarPreview({
6 railEnabled,
7 compactRows,
8}: {
9 railEnabled: boolean;
10 compactRows: boolean;
11}) {
6 const [collapsed, setCollapsed] = useState(false);12 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
8 return (29 return (
9 <button30 <button
...@@ -11,24 +32,31 @@ export function CompactSidebarPreview() {...@@ -11,24 +32,31 @@ export function CompactSidebarPreview() {
11 aria-label="Toggle compact sidebar preview"32 aria-label="Toggle compact sidebar preview"
12 aria-pressed={collapsed}33 aria-pressed={collapsed}
13 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"34 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 }}
15 >39 >
16 <span40 <span
41 ref={sidebarRef}
17 aria-hidden42 aria-hidden
18 className={cn(43 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",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",
20 collapsed ? "w-3" : "w-9",45 collapsed ? (railEnabled ? "w-3" : "w-0") : "w-9",
21 )}46 )}
22 >47 >
23 {[0, 1, 2].map((row) => (48 {(compactRows ? [0, 1, 2, 3] : [0, 1, 2]).map((row) => (
24 <span key={row} className="flex h-1 shrink-0 items-center gap-1">49 <span key={row} className="flex shrink-0 items-start gap-1 px-1">
25 <span className="size-1 shrink-0 rounded-[1px] bg-muted-foreground/50" />50 <span className="size-1 shrink-0 rounded-[1px] bg-muted-foreground/50" />
26 <span51 <span
27 className={cn(52 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",
29 collapsed && "opacity-0",54 collapsed && "opacity-0",
30 )}55 )}
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>
32 </span>60 </span>
33 ))}61 ))}
34 </span>62 </span>
...@@ -42,6 +70,7 @@ export function CompactSidebarPreview() {...@@ -42,6 +70,7 @@ export function CompactSidebarPreview() {
42 className={cn(70 className={cn(
43 "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",71 "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",
44 collapsed &&72 collapsed &&
73 railEnabled &&
45 "group-hover/compact-preview:opacity-100 group-focus-visible/compact-preview:opacity-100",74 "group-hover/compact-preview:opacity-100 group-focus-visible/compact-preview:opacity-100",
46 )}75 )}
47 >76 >
apps/web/src/components/settings/SettingsPanels.tsx+66-13
...@@ -502,7 +502,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -502,7 +502,8 @@ export function useSettingsRestore(onRestored?: () => void) {
502 ...(theme !== "system" ? ["Theme"] : []),502 ...(theme !== "system" ? ["Theme"] : []),
503 ...(!followSystem ? ["Follow system"] : []),503 ...(!followSystem ? ["Follow system"] : []),
504 ...(themeHalves !== null ? ["Theme mix"] : []),504 ...(themeHalves !== null ? ["Theme mix"] : []),
505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled505 ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled ||
506 settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows
506 ? ["Compact sidebar"]507 ? ["Compact sidebar"]
507 : []),508 : []),
508 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast509 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
...@@ -644,6 +645,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -644,6 +645,7 @@ export function useSettingsRestore(onRestored?: () => void) {
644 settings.continueThreadsAfterServerUpdate,645 settings.continueThreadsAfterServerUpdate,
645 settings.sidebarAutoSettleAfterDays,646 settings.sidebarAutoSettleAfterDays,
646 settings.sidebarAutoSettleOnMerge,647 settings.sidebarAutoSettleOnMerge,
648 settings.sidebarCompactThreadRows,
647 settings.sidebarProjectGroupingMode,649 settings.sidebarProjectGroupingMode,
648 settings.sidebarThreadPreviewCount,650 settings.sidebarThreadPreviewCount,
649 settings.showSkillsInSlashMenu,651 settings.showSkillsInSlashMenu,
...@@ -739,6 +741,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -739,6 +741,7 @@ export function useSettingsRestore(onRestored?: () => void) {
739 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,741 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
740 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,742 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
741 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,743 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
744 sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
742 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,745 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
743 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,746 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
744 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,747 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
...@@ -1081,6 +1084,19 @@ export function AppearanceSettingsPanel() {...@@ -1081,6 +1084,19 @@ export function AppearanceSettingsPanel() {
1081 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);1084 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
1082 const settings = useScopedSettings();1085 const settings = useScopedSettings();
1083 const updateSettings = useUpdateScopedSettings();1086 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 };
1084 const environmentStageLabel = useEnvironmentStageLabel();1100 const environmentStageLabel = useEnvironmentStageLabel();
1085 const showEnvironmentIdentification =1101 const showEnvironmentIdentification =
1086 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;1102 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
...@@ -1370,19 +1386,56 @@ export function AppearanceSettingsPanel() {...@@ -1370,19 +1386,56 @@ export function AppearanceSettingsPanel() {
1370 <SettingsSection id="appearance-sidebar" title="Sidebar">1386 <SettingsSection id="appearance-sidebar" title="Sidebar">
1371 <SettingsRow1387 <SettingsRow
1372 {...searchableSetting("compact-sidebar")}1388 {...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 }
1374 control={1405 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">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">
1376 <CompactSidebarPreview />1407 <CompactSidebarPreview
1377 <div className="flex justify-end">1408 key={compactSidebarMode}
1378 <Switch1409 railEnabled={settings.compactSidebarEnabled}
1379 checked={settings.compactSidebarEnabled}1410 compactRows={settings.sidebarCompactThreadRows}
1380 onCheckedChange={(checked) =>1411 />
1381 updateSettings({ compactSidebarEnabled: Boolean(checked) })1412 <Select
1382 }1413 value={compactSidebarMode}
1383 aria-label="Compact sidebar"1414 onValueChange={(value) => {
1384 />1415 if (
1385 </div>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>
1386 </div>1439 </div>
1387 }1440 }
1388 />1441 />
apps/web/src/components/settings/settingsSearch.ts+3-1
...@@ -163,7 +163,9 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -163,7 +163,9 @@ export const SETTINGS_SEARCH_ITEMS = [
163 id: "compact-sidebar",163 id: "compact-sidebar",
164 title: "Compact sidebar",164 title: "Compact sidebar",
165 to: "/settings/appearance",165 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 ],
167 },169 },
168 {170 {
169 id: "environment-identification",171 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", () => {...@@ -228,3 +228,15 @@ describe("formatElapsedDurationLabel", () => {
228 expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d");228 expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d");
229 });229 });
230});230});
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 =...@@ -196,10 +196,10 @@ export type RelativeTimeState =
196 | { status: "invalid" }196 | { status: "invalid" }
197 | { status: "relative"; value: string; suffix: string | null };197 | { 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 {
200 const date = parseTimestampDate(isoDate);200 const date = parseTimestampDate(isoDate);
201 if (!date) return null;201 if (!date) return null;
202 const diffMs = Date.now() - date.getTime();202 const diffMs = nowMs - date.getTime();
203 if (diffMs < 0) return { value: "just now", suffix: null };203 if (diffMs < 0) return { value: "just now", suffix: null };
204 const seconds = Math.floor(diffMs / 1000);204 const seconds = Math.floor(diffMs / 1000);
205 if (seconds < 60) return { value: "just now", suffix: null };205 if (seconds < 60) return { value: "just now", suffix: null };
...@@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {...@@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
211 return { value: `${days}d`, suffix: "ago" };211 return { value: `${days}d`, suffix: "ago" };
212}212}
213213
214export function formatRelativeTimeLabel(isoDate: string) {214export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) {
215 const relative = formatRelativeTime(isoDate);215 const relative = formatRelativeTime(isoDate, nowMs);
216 if (!relative) return "";216 if (!relative) return "";
217 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;217 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
218}218}
packages/contracts/src/settings.test.ts+12-1
...@@ -482,7 +482,18 @@ describe("ClientSettings environment identification", () => {...@@ -482,7 +482,18 @@ describe("ClientSettings environment identification", () => {
482482
483describe("ClientSettings sidebar", () => {483describe("ClientSettings sidebar", () => {
484 it("defaults to the current sidebar", () => {484 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);
486 });497 });
487498
488 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {499 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({...@@ -451,6 +451,7 @@ export const ClientSettingsSchema = Schema.Struct({
451 sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(451 sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
452 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),452 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),
453 ),453 ),
454 sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
454 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(455 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
455 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),456 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
456 ),457 ),
...@@ -1500,6 +1501,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1500,6 +1501,7 @@ export const ClientSettingsPatch = Schema.Struct({
1500 ),1501 ),
1501 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),1502 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
1502 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),1503 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
1504 sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean),
1503 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),1505 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
1504 timestampFormat: Schema.optionalKey(TimestampFormat),1506 timestampFormat: Schema.optionalKey(TimestampFormat),
1505 snapShotEnabled: Schema.optionalKey(Schema.Boolean),1507 snapShotEnabled: Schema.optionalKey(Schema.Boolean),