diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea2a80010124..f868ccd6b847 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, + sidebarCompactThreadRows: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c269b73ee761..9e4e023e6e55 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,6 +118,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { @@ -945,6 +946,7 @@ const dropVerbBadge: Record = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + compact: boolean; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1712,6 +1714,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); } + const compact = props.compact; + const compactCompletedAt = + compact && status === "ready" && !isWokeStatus + ? (thread.latestTurn?.completedAt ?? null) + : null; const diff = latestTurnDiff(thread); return ( @@ -1720,8 +1727,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {...sortableRootProps} {...(fileDropHandlers ?? {})} className={cn( - // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. - "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", + "list-none [content-visibility:auto]", + compact + ? "[contain-intrinsic-size:auto_36px]" + : "py-0.5 [contain-intrinsic-size:auto_78px]", sortable?.isDragging && "relative z-20", )} > @@ -1732,7 +1741,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ref={rowRef} role="button" tabIndex={0} - data-testid="sidebar-row-card" + data-testid={compact ? "sidebar-row-compact" : "sidebar-row-card"} aria-busy={isRegeneratingTitle || undefined} className={rowSurfaceClassName} onClick={handleClick} @@ -1742,13 +1751,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > -
-
+
+
{draftIndicator} {props.project ? ( ) : null} - {props.projectDisplayName ? ( + {compact ? ( + title + ) : props.projectDisplayName ? ( - {topStatus ? ( + {compactCompletedAt ? ( + + ) : topStatus ? ( isWokeStatus ? ( {topStatus.label} + + {topStatus.label} + {status === "working" ? ( @@ -1829,7 +1854,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { threadTimeLabel(thread) )} - {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + {props.settlementSupported || + showSnoozeButton || + hasUnsentDraft || + (compact && prBadge) ? ( + {compact ? prBadge : null} {hasUnsentDraft ? ( - Settle + {compact ? null : "Settle"} Settle thread @@ -1889,68 +1918,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
-
- {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
-
- {/* Always the branch. The plan step used to take this slot while + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {compact ? null : ( + <> +
{title}
+
+ {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - {thread.branch} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - + {thread.branch ? ( + <> + + + {thread.branch} + + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} - ) : null} - -
+
+ + )}
{props.jumpLabel ? : null} @@ -2112,6 +2143,7 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -4645,10 +4677,9 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. + // Settled and snoozed always use slim rows. Active and + // pinned threads use cards unless the user has explicitly + // enabled the compact thread-list preference. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( @@ -4658,6 +4689,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} + compact={compactThreadRows} // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 143680509544..799d8e6b7817 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows + ? ["Compact thread list"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -630,6 +633,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, + settings.sidebarCompactThreadRows, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -719,6 +723,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, @@ -2241,6 +2246,33 @@ export function GeneralSettingsPanel() { + + updateSettings({ + sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarCompactThreadRows: Boolean(checked) }) + } + aria-label="Compact thread list" + /> + } + /> + { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-07T01:01:00Z")); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it("advances visible and accessible completion times without rerendering its memoized row", async () => { + const rowRender = vi.fn(); + const Row = memo(function Row() { + rowRender(); + return ; + }); + await act(() => { + renderer = create(); + }); + expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z"); + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect( + renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]), + ).toHaveLength(0); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "1m", + ]); + + await act(() => vi.advanceTimersByTime(60_000)); + + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "2m", + ]); + expect(rowRender).toHaveBeenCalledTimes(1); + await act(() => renderer!.unmount()); + renderer = undefined; + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx new file mode 100644 index 000000000000..6776c69081d7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx @@ -0,0 +1,22 @@ +import { CircleCheckIcon } from "lucide-react"; + +import { useNowMinute } from "../../hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; + +export function SidebarCompletedTime({ completedAt }: { completedAt: string }) { + // Subscribe inside the label so time advances even when the row is memoized. + const nowMinute = useNowMinute(); + const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`)); + const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, ""); + + return ( + + ); +} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 8c6287010d8f..5169e438bb2d 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -228,3 +228,15 @@ describe("formatElapsedDurationLabel", () => { expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); }); }); + +describe("explicit relative-time clock", () => { + it("uses the supplied minute instead of the wall clock", () => { + const completedAt = "2026-09-07T01:00:00Z"; + expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:01:00Z"))).toBe("1m ago"); + expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toBe("2m ago"); + expect(formatRelativeTime(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toEqual({ + value: "2m", + suffix: "ago", + }); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..983a9bb8b232 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -196,10 +196,10 @@ export type RelativeTimeState = | { status: "invalid" } | { status: "relative"; value: string; suffix: string | null }; -export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { +export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); if (!date) return null; - const diffMs = Date.now() - date.getTime(); + const diffMs = nowMs - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { return { value: `${days}d`, suffix: "ago" }; } -export function formatRelativeTimeLabel(isoDate: string) { - const relative = formatRelativeTime(isoDate); +export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) { + const relative = formatRelativeTime(isoDate, nowMs); if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7322307c1f89..94d52d667e6e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -390,7 +390,18 @@ describe("ClientSettings environment identification", () => { describe("ClientSettings sidebar", () => { it("defaults to the current sidebar", () => { - expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); + const settings = decodeClientSettings({}); + expect(settings.legacySidebarEnabled).toBe(false); + expect(settings.sidebarCompactThreadRows).toBe(false); + }); + + it("preserves an explicit compact thread row preference", () => { + expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe( + true, + ); + expect( + decodeClientSettingsPatch({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows, + ).toBe(true); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6136461fc1..8e949e1848d1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -432,6 +432,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), + sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), @@ -1389,6 +1390,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), + sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean),