diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53044dcf7e5d..94a6d391ac47 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -57,6 +57,7 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, + compactSidebarThreadRows: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..56bc1711a2cb 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -62,7 +62,7 @@ function preview( scale = 1, ) { const strategy = createSidebarSortingStrategy(input); - const args = layout(input.items, active, over, scale); + const args = layout(input.items, active, over, scale, input.compactThreadRows ? 64 : 82); return new Map( input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]), ); @@ -640,6 +640,41 @@ describe("sidebar drag projection", () => { expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5); }); + it.each([1, 0.75])("reserves compact cards in empty sections at scale %s", (scale) => { + const items = [ + pinnedHeader, + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const input = { items, settledOrder: [], settledExpanded: true, compactThreadRows: true }; + const pinned = preview(input, "s", sidebarMarkerId("pinned-header"), scale); + expect(pinned.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(pinned.get(sidebarMarkerId("pinned-divider"))?.y).toBe(64 * scale + 1); + const active = preview(input, "s", sidebarMarkerId("active-placeholder"), scale); + expect(active.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); + expect(active.get(sidebarMarkerId("settled-header"))?.y).toBe(64 * scale); + }); + + it.each([1, 0.75])("shrinks a compact card when moved to Settled at scale %s", (scale) => { + const items = [ + pinnedHeader, + divider, + thread("a", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: ["s", "a"], settledExpanded: true, compactThreadRows: true }, + "a", + "s", + scale, + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-28 * scale); + expect(result.get("s")?.y).toBe(-28 * scale); + }); + it("updates the projection when the target or measured geometry changes", () => { const strategy = createSidebarSortingStrategy({ items: pinned, diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index bfd112d18998..8813d463363d 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -102,6 +102,7 @@ export function createSidebarSortingStrategy(input: { routeThreadKey?: string | null; snoozedThreadCount?: number; cardHeight?: number; + compactThreadRows?: boolean; slimHeight?: number; /** Space each pinned boundary opens for its label while dragging. The * markers stay zero height at rest, so nothing is reserved until pickup. */ @@ -140,10 +141,13 @@ export function createSidebarSortingStrategy(input: { else slimHeight ??= rects[index]?.height; if (item.key !== active.key) groups[item.section].push(item); } - // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. + // Include the card's 0.25rem row padding in either density. + const baseCardHeight = input.compactThreadRows ? 64 : 82; const scale = - slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); - cardHeight ??= 82 * scale; + slimHeight !== undefined + ? slimHeight / 36 + : (headerScale ?? (cardHeight ?? baseCardHeight) / baseCardHeight); + cardHeight ??= baseCardHeight * scale; slimHeight ??= 36 * scale; const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; const group = groups[target.section]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cc3487b595ed..5e586639283e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -340,6 +340,8 @@ function SidebarThreadTooltip({ }) { const driverKind = providerEntry?.driverKind ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const isWorking = resolveSidebarThreadStatus(thread) === "working"; + const workingStartedAt = isWorking ? resolveWorkingStartedAt(thread) : null; return ( ) : null} + {workingStartedAt !== null ? ( +
+ +
+ Working for +
+
+ ) : null} {driverKind ? (
void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; + const compact = useClientSettings((settings) => settings.compactSidebarThreadRows); const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -756,7 +767,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onClick={handleActivate} onKeyDown={handleKeyDown} > -
+
{props.project ? ( @@ -952,6 +968,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"; @@ -1178,7 +1195,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; const showInstanceBadge = providerEntry !== null && shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); @@ -1718,8 +1734,8 @@ 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 py-0.5 [content-visibility:auto]", + props.compact ? "[contain-intrinsic-size:auto_60px]" : "[contain-intrinsic-size:auto_78px]", sortable?.isDragging && "relative z-20", )} > @@ -1740,7 +1756,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > -
+
{draftIndicator} {props.project ? ( @@ -1749,15 +1770,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.projectDisplayName ? ( {props.projectDisplayName} - ) : ( - - )} + ) : null} + {props.compact && thread.worktreePath !== null ? ( + + ) : null} + {pinIndicator} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping @@ -1794,7 +1817,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} > - {topStatus.label} + + {topStatus.label} + } /> @@ -1823,8 +1851,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( + + {topStatus.label} + + {!props.compact && status === "working" ? ( @@ -1895,40 +1925,44 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
-
+
{title} {isRegeneratingTitle ? ( Regenerating title ) : null} -
-
- {/* 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} + {!props.compact ? ( + thread.branch ? ( + <> + + + {thread.branch} + + + ) : ( + + ) + ) : null} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} {isRemote ? ( ) : null} - {driverKind ? ( - + {providerEntry?.driverKind ? ( + @@ -2118,6 +2152,7 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const compactThreadRows = useClientSettings((s) => s.compactSidebarThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -3403,6 +3438,7 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, + compactThreadRows, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3412,6 +3448,7 @@ export default function Sidebar() { }), [ draggedSettledOrder, + compactThreadRows, routeThreadKey, settledShelfExpanded, settledVisibleCount, @@ -4595,6 +4632,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 6baa561a91fe..d05fc09558b3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -530,6 +530,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme ? ["Diff colors"] : []), + ...(settings.compactSidebarThreadRows !== DEFAULT_UNIFIED_SETTINGS.compactSidebarThreadRows + ? ["Compact thread rows"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.compactSidebarThreadRows, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -752,6 +756,7 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, + compactSidebarThreadRows: DEFAULT_UNIFIED_SETTINGS.compactSidebarThreadRows, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, @@ -1318,6 +1323,19 @@ export function AppearanceSettingsPanel() { } /> ) : null} + + updateSettings({ compactSidebarThreadRows: Boolean(checked) }) + } + aria-label="Compact thread rows" + /> + } + /> { describe("ClientSettings sidebar", () => { it("defaults to the current sidebar", () => { - expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); + const settings = decodeClientSettings({}); + expect(settings.legacySidebarEnabled).toBe(false); + expect(settings.compactSidebarThreadRows).toBe(false); + }); + + it("preserves an explicit compact thread row preference", () => { + expect(decodeClientSettings({ compactSidebarThreadRows: true }).compactSidebarThreadRows).toBe( + true, + ); + expect( + decodeClientSettingsPatch({ compactSidebarThreadRows: true }).compactSidebarThreadRows, + ).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 7b3715d704be..080392864c29 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -450,6 +450,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), + compactSidebarThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), @@ -1508,6 +1509,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), + compactSidebarThreadRows: Schema.optionalKey(Schema.Boolean), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean),