| author | |
| committer | |
| log | 77bca8b2d76a1f42552e5eee7d277fcb1160347a |
| tree | 0c9832093890167104122f8b74b87b0bb2b7e43f |
| parent | 6e5e986f15b3cbec3588646ddf216f00b4301785 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 57 | proactivePanelsEnabled: true, |
| 58 | 58 | showSkillsInSlashMenu: false, |
| 59 | 59 | providerModelPreferences: {}, |
| 60 | sidebarCompactThreadRows: false, | |
| 60 | 61 | sidebarProjectGroupingMode: "repository_path", |
| 61 | 62 | sidebarProjectGroupingOverrides: { |
| 62 | 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 | 124 | import { useClientSettings } from "../hooks/useSettings"; |
| 125 | 125 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 126 | 126 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 127 | import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime"; | |
| 127 | 128 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 128 | 129 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 129 | 130 | import { |
| ... | ... | @@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = { |
| 1031 | 1032 | const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1032 | 1033 | thread: SidebarThreadSummary; |
| 1033 | 1034 | variant: "card" | "slim"; |
| 1035 | compact: boolean; | |
| 1034 | 1036 | // Slim rows are either settled (action: un-settle) or merely quiet |
| 1035 | 1037 | // (seen Ready threads — action: settle). |
| 1036 | 1038 | variantAction: "settle" | "unsettle" | "unsnooze"; |
| ... | ... | @@ -1292,7 +1294,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1292 | 1294 | terminalStatus={terminalStatus} |
| 1293 | 1295 | terminalProcessCount={terminalProcessCount} |
| 1294 | 1296 | compactStatus={ |
| 1295 | compact | |
| 1297 | compact || (props.compact && variant === "card") | |
| 1296 | 1298 | ? (topStatus?.label ?? |
| 1297 | 1299 | (variantAction === "unsnooze" |
| 1298 | 1300 | ? "Snoozed" |
| ... | ... | @@ -1878,6 +1880,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1878 | 1880 | ); |
| 1879 | 1881 | } |
| 1880 | 1882 | |
| 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 | 1899 | const diff = latestTurnDiff(thread); |
| 1882 | 1900 | |
| 1883 | 1901 | return ( |
| ... | ... | @@ -1886,8 +1904,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1886 | 1904 | {...sortableRootProps} |
| 1887 | 1905 | {...(fileDropHandlers ?? {})} |
| 1888 | 1906 | 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]", | |
| 1891 | 1911 | sortable?.isDragging && "relative z-20", |
| 1892 | 1912 | )} |
| 1893 | 1913 | > |
| ... | ... | @@ -1902,7 +1922,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1902 | 1922 | ref={rowRef} |
| 1903 | 1923 | role="button" |
| 1904 | 1924 | tabIndex={0} |
| 1905 | data-testid="sidebar-row-card" | |
| 1925 | data-testid={compactRows ? "sidebar-row-compact" : "sidebar-row-card"} | |
| 1906 | 1926 | aria-busy={isRegeneratingTitle || undefined} |
| 1907 | 1927 | className={rowSurfaceClassName} |
| 1908 | 1928 | onClick={handleClick} |
| ... | ... | @@ -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)]"> | |
| 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"> | |
| 1917 | 1944 | {draftIndicator} |
| 1918 | 1945 | {props.project ? ( |
| 1919 | 1946 | <ProjectFavicon project={props.project} className="size-4 shrink-0" /> |
| 1920 | 1947 | ) : null} |
| 1921 | {props.projectDisplayName ? ( | |
| 1948 | {compactRows ? ( | |
| 1949 | title | |
| 1950 | ) : props.projectDisplayName ? ( | |
| 1922 | 1951 | <span |
| 1923 | 1952 | className={cn( |
| 1924 | 1953 | "min-w-0 flex-1 truncate text-secondary-label text-xs", |
| ... | ... | @@ -1931,6 +1960,50 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1931 | 1960 | <span className="flex-1" /> |
| 1932 | 1961 | )} |
| 1933 | 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 | 2007 | {/* The visible state owns this slot's width: status at rest, |
| 1935 | 2008 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1936 | 2009 | the hidden state out of flow lets the project label reclaim |
| ... | ... | @@ -1938,20 +2011,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1938 | 2011 | {sortable?.isDragging ? ( |
| 1939 | 2012 | 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 | 2020 | {/* Read-only status labels yield to the hover actions. Woke is |
| 1943 | 2021 | itself an action, so it stays pointer-enabled and visible |
| 1944 | 2022 | while the other controls appear beside it. */} |
| 1945 | 2023 | <span |
| 1946 | 2024 | className={cn( |
| 1947 | isWokeStatus | |
| 2025 | isWokeStatus && !compactRows | |
| 1948 | 2026 | ? "pointer-events-auto" |
| 1949 | 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 | 2028 | "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", |
| 1951 | 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 | 2041 | isWokeStatus ? ( |
| 1956 | 2042 | <Tooltip> |
| 1957 | 2043 | <TooltipTrigger |
| ... | ... | @@ -2057,7 +2143,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 2057 | 2143 | } |
| 2058 | 2144 | > |
| 2059 | 2145 | <CheckIcon className="size-3.5" /> |
| 2060 | Settle | |
| 2146 | {compactRows ? null : "Settle"} | |
| 2061 | 2147 | </TooltipTrigger> |
| 2062 | 2148 | <TooltipPopup>Settle thread</TooltipPopup> |
| 2063 | 2149 | </Tooltip> |
| ... | ... | @@ -2067,68 +2153,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 2067 | 2153 | </span> |
| 2068 | 2154 | )} |
| 2069 | 2155 | </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 | |
| 2080 | 2166 | working, but it truncated to a half-sentence and dropped the |
| 2081 | 2167 | 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} | |
| 2128 | 2216 | </span> |
| 2129 | ) : null} | |
| 2130 | </span> | |
| 2131 | </div> | |
| 2217 | </div> | |
| 2218 | </> | |
| 2219 | )} | |
| 2132 | 2220 | </div> |
| 2133 | 2221 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} |
| 2134 | 2222 | </TooltipTrigger> |
| ... | ... | @@ -2303,6 +2391,7 @@ export default function Sidebar() { |
| 2303 | 2391 | const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; |
| 2304 | 2392 | const [snoozedFooter, setSnoozedFooter] = useState<HTMLUListElement | null>(null); |
| 2305 | 2393 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2394 | const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); | |
| 2306 | 2395 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 2307 | 2396 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 2308 | 2397 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| ... | ... | @@ -4829,10 +4918,9 @@ export default function Sidebar() { |
| 4829 | 4918 | const threadKey = scopedThreadKey( |
| 4830 | 4919 | scopeThreadRef(thread.environmentId, thread.id), |
| 4831 | 4920 | ); |
| 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. | |
| 4836 | 4924 | const isCard = section === "active" || section === "pinned"; |
| 4837 | 4925 | const rowVariant = isCard ? "card" : "slim"; |
| 4838 | 4926 | return ( |
| ... | ... | @@ -4842,6 +4930,7 @@ export default function Sidebar() { |
| 4842 | 4930 | key={`${threadKey}:${rowVariant}`} |
| 4843 | 4931 | thread={thread} |
| 4844 | 4932 | variant={rowVariant} |
| 4933 | compact={compactThreadRows} | |
| 4845 | 4934 | // Snoozed rows wake, settled rows un-settle, and cards settle. |
| 4846 | 4935 | variantAction={ |
| 4847 | 4936 | section === "snoozed" |
apps/web/src/components/settings/CompactSidebarPreview.tsx+38-9| ... | ... | @@ -1,9 +1,30 @@ |
| 1 | import { useState } from "react"; | |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import { cn } from "~/lib/utils"; |
| 4 | 4 | |
| 5 | export function CompactSidebarPreview() { | |
| 5 | export function CompactSidebarPreview({ | |
| 6 | railEnabled, | |
| 7 | compactRows, | |
| 8 | }: { | |
| 9 | railEnabled: boolean; | |
| 10 | compactRows: boolean; | |
| 11 | }) { | |
| 6 | 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]); | |
| 7 | 28 | |
| 8 | 29 | return ( |
| 9 | 30 | <button |
| ... | ... | @@ -11,24 +32,31 @@ export function CompactSidebarPreview() { |
| 11 | 32 | aria-label="Toggle compact sidebar preview" |
| 12 | 33 | aria-pressed={collapsed} |
| 13 | 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 | 40 | <span |
| 41 | ref={sidebarRef} | |
| 17 | 42 | aria-hidden |
| 18 | 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", | |
| 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", | |
| 21 | 46 | )} |
| 22 | 47 | > |
| 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"> | |
| 25 | 50 | <span className="size-1 shrink-0 rounded-[1px] bg-muted-foreground/50" /> |
| 26 | 51 | <span |
| 27 | 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 | 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 | 60 | </span> |
| 33 | 61 | ))} |
| 34 | 62 | </span> |
| ... | ... | @@ -42,6 +70,7 @@ export function CompactSidebarPreview() { |
| 42 | 70 | className={cn( |
| 43 | 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 | 72 | collapsed && |
| 73 | railEnabled && | |
| 45 | 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 | 502 | ...(theme !== "system" ? ["Theme"] : []), |
| 503 | 503 | ...(!followSystem ? ["Follow system"] : []), |
| 504 | 504 | ...(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 | |
| 506 | 507 | ? ["Compact sidebar"] |
| 507 | 508 | : []), |
| 508 | 509 | ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast |
| ... | ... | @@ -644,6 +645,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 644 | 645 | settings.continueThreadsAfterServerUpdate, |
| 645 | 646 | settings.sidebarAutoSettleAfterDays, |
| 646 | 647 | settings.sidebarAutoSettleOnMerge, |
| 648 | settings.sidebarCompactThreadRows, | |
| 647 | 649 | settings.sidebarProjectGroupingMode, |
| 648 | 650 | settings.sidebarThreadPreviewCount, |
| 649 | 651 | settings.showSkillsInSlashMenu, |
| ... | ... | @@ -739,6 +741,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 739 | 741 | panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, |
| 740 | 742 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 741 | 743 | sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, |
| 744 | sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, | |
| 742 | 745 | sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, |
| 743 | 746 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, |
| 744 | 747 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, |
| ... | ... | @@ -1081,6 +1084,19 @@ export function AppearanceSettingsPanel() { |
| 1081 | 1084 | const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); |
| 1082 | 1085 | const settings = useScopedSettings(); |
| 1083 | 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 | 1100 | const environmentStageLabel = useEnvironmentStageLabel(); |
| 1085 | 1101 | const showEnvironmentIdentification = |
| 1086 | 1102 | resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; |
| ... | ... | @@ -1370,19 +1386,56 @@ export function AppearanceSettingsPanel() { |
| 1370 | 1386 | <SettingsSection id="appearance-sidebar" title="Sidebar"> |
| 1371 | 1387 | <SettingsRow |
| 1372 | 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 | 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"> | |
| 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> | |
| 1386 | 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 | 163 | id: "compact-sidebar", |
| 164 | 164 | title: "Compact sidebar", |
| 165 | 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 | 171 | id: "environment-identification", |
apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import { act, memo } from "react"; | |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 3 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { SidebarCompletedTime } from "./SidebarCompletedTime"; | |
| 6 | ||
| 7 | let renderer: ReactTestRenderer | undefined; | |
| 8 | ||
| 9 | beforeEach(() => { | |
| 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 | ||
| 21 | afterEach(async () => { | |
| 22 | await act(() => renderer?.unmount()); | |
| 23 | renderer = undefined; | |
| 24 | vi.unstubAllGlobals(); | |
| 25 | vi.useRealTimers(); | |
| 26 | }); | |
| 27 | ||
| 28 | it("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 @@ |
| 1 | import { useNowMinute } from "../../hooks/useNowMinute"; | |
| 2 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | |
| 3 | ||
| 4 | export 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 | 228 | expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); |
| 229 | 229 | }); |
| 230 | 230 | }); |
| 231 | ||
| 232 | describe("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 | 196 | | { status: "invalid" } |
| 197 | 197 | | { status: "relative"; value: string; suffix: string | null }; |
| 198 | 198 | |
| 199 | export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { | |
| 199 | export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null { | |
| 200 | 200 | const date = parseTimestampDate(isoDate); |
| 201 | 201 | if (!date) return null; |
| 202 | const diffMs = Date.now() - date.getTime(); | |
| 202 | const diffMs = nowMs - date.getTime(); | |
| 203 | 203 | if (diffMs < 0) return { value: "just now", suffix: null }; |
| 204 | 204 | const seconds = Math.floor(diffMs / 1000); |
| 205 | 205 | if (seconds < 60) return { value: "just now", suffix: null }; |
| ... | ... | @@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { |
| 211 | 211 | return { value: `${days}d`, suffix: "ago" }; |
| 212 | 212 | } |
| 213 | 213 | |
| 214 | export function formatRelativeTimeLabel(isoDate: string) { | |
| 215 | const relative = formatRelativeTime(isoDate); | |
| 214 | export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) { | |
| 215 | const relative = formatRelativeTime(isoDate, nowMs); | |
| 216 | 216 | if (!relative) return ""; |
| 217 | 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 | 482 | |
| 483 | 483 | describe("ClientSettings sidebar", () => { |
| 484 | 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 | }); |
| 487 | 498 | |
| 488 | 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 | 451 | sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( |
| 452 | 452 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), |
| 453 | 453 | ), |
| 454 | sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 454 | 455 | sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( |
| 455 | 456 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), |
| 456 | 457 | ), |
| ... | ... | @@ -1500,6 +1501,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1500 | 1501 | ), |
| 1501 | 1502 | sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), |
| 1502 | 1503 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), |
| 1504 | sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean), | |
| 1503 | 1505 | sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), |
| 1504 | 1506 | timestampFormat: Schema.optionalKey(TimestampFormat), |
| 1505 | 1507 | snapShotEnabled: Schema.optionalKey(Schema.Boolean), |