| 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,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"; |
| 124 | import { useClientSettings } from "../hooks/useSettings"; | 124 | import { useClientSettings } from "../hooks/useSettings"; |
| 125 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 125 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 126 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 126 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 127 | import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime"; | ||
| 127 | import { useNowMinute } from "../hooks/useNowMinute"; | 128 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 128 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 129 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 129 | import { | 130 | import { |
| ... | @@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = { | ... | @@ -1031,6 +1032,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = { |
| 1031 | const SidebarThreadRow = memo(function SidebarThreadRow(props: { | 1032 | const 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 quiet | 1036 | // 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 | compact | 1297 | 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 | } |
| 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 | const diff = latestTurnDiff(thread); | 1899 | const diff = latestTurnDiff(thread); |
| 1882 | 1900 | ||
| 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 | <span | 1951 | <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. Keeping | 2008 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1936 | the hidden state out of flow lets the project label reclaim | 2009 | 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 | dragDestination | 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 | {/* Read-only status labels yield to the hover actions. Woke is | 2020 | {/* Read-only status labels yield to the hover actions. Woke is |
| 1943 | itself an action, so it stays pointer-enabled and visible | 2021 | 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 | <span | 2023 | <span |
| 1946 | className={cn( | 2024 | className={cn( |
| 1947 | isWokeStatus | 2025 | 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 | <TooltipTrigger | 2043 | <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 | Settle | 2146 | {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 title | 2160 | ) : 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 while | 2165 | {/* Always the branch. The plan step used to take this slot while |
| 2080 | working, but it truncated to a half-sentence and dropped the | 2166 | 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 | <span | 2186 | <span |
| 2101 | aria-hidden | 2187 | 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 | <EnvironmentMachineIcon | 2192 | <EnvironmentMachineIcon |
| 2107 | aria-hidden | 2193 | 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 | <ProviderInstanceIcon | 2201 | <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 | modelInstanceId | 2206 | 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 a | 4921 | // Settled and snoozed always use slim rows. Active and |
| 4833 | // row: every other thread is a full card. Density comes | 4922 | // 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 @@ |
| 1 | import { useState } from "react"; | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | 2 | ||
| 3 | import { cn } from "~/lib/utils"; | 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 | 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]); | ||
| 7 | 28 | ||
| 8 | return ( | 29 | return ( |
| 9 | <button | 30 | <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 | <span | 40 | <span |
| 41 | ref={sidebarRef} | ||
| 17 | aria-hidden | 42 | 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 | <span | 51 | <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.compactSidebarEnabled | 505 | ...(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.appearanceContrast | 509 | ...(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 | <SettingsRow | 1387 | <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 | <Switch | 1409 | 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 @@ | ||
| 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,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 | |||
| 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,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 }; |
| 198 | 198 | ||
| 199 | export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { | 199 | export 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 | } |
| 213 | 213 | ||
| 214 | export function formatRelativeTimeLabel(isoDate: string) { | 214 | export 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", () => { |
| 482 | 482 | ||
| 483 | describe("ClientSettings sidebar", () => { | 483 | describe("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 | }); |
| 487 | 498 | ||
| 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), |