Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = {
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
sidebarCompactThreadRows: false,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
178 changes: 105 additions & 73 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -945,6 +946,7 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
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";
Expand Down Expand Up @@ -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 (
Expand All @@ -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",
)}
>
Expand All @@ -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}
Expand All @@ -1742,13 +1751,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
}
>
<div className="relative z-10 h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]">
<div className="flex h-5 min-w-0 items-center gap-1.5">
<div
className={cn(
"relative z-10",
compact
? "flex h-9 items-center px-2.5"
: "h-[4.875rem] px-[var(--sidebar-row-content-inset)] py-[var(--sidebar-content-inset)]",
)}
>
<div className="flex h-5 w-full min-w-0 items-center gap-1.5">
{draftIndicator}
{props.project ? (
<ProjectFavicon project={props.project} className="size-4 shrink-0" />
) : null}
{props.projectDisplayName ? (
{compact ? (
title
) : props.projectDisplayName ? (
<span
className={cn(
"min-w-0 flex-1 truncate text-secondary-label text-xs",
Expand Down Expand Up @@ -1781,7 +1799,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
{compactCompletedAt ? (
<SidebarCompletedTime completedAt={compactCompletedAt} />
) : topStatus ? (
isWokeStatus ? (
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -1817,7 +1837,12 @@ 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. */}
<span role="status">{topStatus.label}</span>
<span
role="status"
className={compact && status === "working" ? "sr-only" : undefined}
>
{topStatus.label}
</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
Expand All @@ -1829,7 +1854,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
threadTimeLabel(thread)
)}
</span>
{props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
{props.settlementSupported ||
showSnoozeButton ||
hasUnsentDraft ||
(compact && prBadge) ? (
<span
className={cn(
// focus-visible, not focus-within: a mouse click leaves
Expand All @@ -1841,6 +1869,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{compact ? prBadge : null}
{hasUnsentDraft ? (
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -1879,7 +1908,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
}
>
<CheckIcon className="size-3.5" />
Settle
{compact ? null : "Settle"}
</TooltipTrigger>
<TooltipPopup>Settle thread</TooltipPopup>
</Tooltip>
Expand All @@ -1889,68 +1918,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
</span>
)}
</div>
<div className="mt-1 flex min-w-0">
{title}
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Regenerating title
</span>
) : null}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
{/* Always the branch. The plan step used to take this slot while
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Regenerating title
</span>
) : null}
{compact ? null : (
<>
<div className="mt-1 flex min-w-0">{title}</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-secondary-label text-xs">
{/* 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 ? (
<>
<ThreadWorktreeIndicator thread={thread} />
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
{thread.branch}
</span>
</>
) : (
<span className="flex-1" />
)}
{terminalStatusIcon}
{prBadge}
{diff ? (
<span className="shrink-0 font-mono">
<span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
<span className="text-diff-deletion-foreground">−{diff.deletions}</span>
</span>
) : null}
<span
aria-hidden
className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
>
{isRemote ? (
<span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
<EnvironmentMachineIcon
aria-hidden
kind={props.environmentMachine}
className="size-3.5"
/>
</span>
) : null}
{driverKind ? (
<span className="inline-flex shrink-0 items-center">
<ProviderInstanceIcon
driverKind={driverKind}
displayName={
providerEntry?.displayName ??
thread.session?.providerName ??
modelInstanceId
}
accentColor={providerEntry?.accentColor}
showBadge={showInstanceBadge}
// Glyph dims, badge stays saturated; offset matches the composer trigger.
iconClassName="size-3.5 opacity-60"
badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"
/>
{thread.branch ? (
<>
<ThreadWorktreeIndicator thread={thread} />
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
{thread.branch}
</span>
</>
) : (
<span className="flex-1" />
)}
{terminalStatusIcon}
{prBadge}
{diff ? (
<span className="shrink-0 font-mono">
<span className="text-diff-addition-foreground">+{diff.insertions}</span>{" "}
<span className="text-diff-deletion-foreground">−{diff.deletions}</span>
</span>
) : null}
<span
aria-hidden
className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1"
>
{isRemote ? (
<span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70">
<EnvironmentMachineIcon
aria-hidden
kind={props.environmentMachine}
className="size-3.5"
/>
</span>
) : null}
{driverKind ? (
<span className="inline-flex shrink-0 items-center">
<ProviderInstanceIcon
driverKind={driverKind}
displayName={
providerEntry?.displayName ??
thread.session?.providerName ??
modelInstanceId
}
accentColor={providerEntry?.accentColor}
showBadge={showInstanceBadge}
// Glyph dims, badge stays saturated; offset matches the composer trigger.
iconClassName="size-3.5 opacity-60"
badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-[7px]"
/>
</span>
) : null}
</span>
) : null}
</span>
</div>
</div>
</>
)}
</div>
{props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
</TooltipTrigger>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
Expand All @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
? ["Project Grouping"]
: []),
...(settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
? ["Compact thread list"]
: []),
...(settings.sidebarAutoSettleAfterDays !==
DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
? ["Auto-settle inactive threads"]
Expand Down Expand Up @@ -630,6 +633,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.continueThreadsAfterServerUpdate,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
settings.sidebarCompactThreadRows,
settings.sidebarProjectGroupingMode,
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2241,6 +2246,33 @@ export function GeneralSettingsPanel() {
</SettingsSection>

<SettingsSection id="behavior" title="Behavior">
<SettingsRow
{...searchableSetting("compact-thread-list")}
description="Show active and pinned threads on one line. Hover a thread to see its full details."
resetAction={
settings.sidebarCompactThreadRows !==
DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows ? (
<SettingResetButton
label="compact thread list"
onClick={() =>
updateSettings({
sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
})
}
/>
) : null
}
control={
<Switch
checked={settings.sidebarCompactThreadRows}
onCheckedChange={(checked) =>
updateSettings({ sidebarCompactThreadRows: Boolean(checked) })
}
aria-label="Compact thread list"
/>
}
/>

<SettingsRow
{...searchableSetting("time-format")}
description="System default follows your browser or OS clock preference."
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["combine matching repositories environments sidebar"],
},
{
id: "compact-thread-list",
title: "Compact thread list",
to: "/settings/general",
},
{
id: "auto-settle-inactive-threads",
title: "Auto-settle inactive threads",
Expand Down
Loading
Loading