Skip to content

Commit 9779ca5

Browse files
committed
Add sidebar density settings
- Add settings for sidebar row height, font size, and spacing - Apply the new density controls to project and thread rows - Update tests and settings copy for the new sidebar options
1 parent e0a7269 commit 9779ca5

4 files changed

Lines changed: 326 additions & 25 deletions

File tree

apps/web/src/appSettings.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import * as Schema from "effect/Schema";
22
import { describe, expect, it } from "vitest";
33

4-
import { AppSettingsSchema, DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE } from "./appSettings";
4+
import {
5+
AppSettingsSchema,
6+
DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE,
7+
DEFAULT_SIDEBAR_FONT_SIZE,
8+
DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT,
9+
DEFAULT_SIDEBAR_SPACING,
10+
DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT,
11+
} from "./appSettings";
512

613
describe("AppSettingsSchema", () => {
714
it("defaults codeViewerAutosave to false", () => {
@@ -17,6 +24,15 @@ describe("AppSettingsSchema", () => {
1724
expect(settings.includeDiagnosticsTipsInCopy).toBe(false);
1825
});
1926

27+
it("defaults sidebar appearance controls", () => {
28+
const settings = Schema.decodeUnknownSync(AppSettingsSchema)({});
29+
30+
expect(settings.sidebarProjectRowHeight).toBe(DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT);
31+
expect(settings.sidebarThreadRowHeight).toBe(DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT);
32+
expect(settings.sidebarFontSize).toBe(DEFAULT_SIDEBAR_FONT_SIZE);
33+
expect(settings.sidebarSpacing).toBe(DEFAULT_SIDEBAR_SPACING);
34+
});
35+
2036
it("preserves an explicit codeViewerAutosave setting", () => {
2137
const settings = Schema.decodeUnknownSync(AppSettingsSchema)({
2238
codeViewerAutosave: true,

apps/web/src/appSettings.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ const MAX_CUSTOM_MODEL_COUNT = 32;
2020
export const MAX_CUSTOM_MODEL_LENGTH = 256;
2121
const BACKGROUND_IMAGE_KEY = "okcode:background-image";
2222
const BACKGROUND_OPACITY_KEY = "okcode:background-opacity";
23+
export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 24;
24+
export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 44;
25+
export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 28;
26+
export const SIDEBAR_THREAD_ROW_HEIGHT_MIN = 24;
27+
export const SIDEBAR_THREAD_ROW_HEIGHT_MAX = 44;
28+
export const DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT = 28;
29+
export const SIDEBAR_FONT_SIZE_MIN = 10;
30+
export const SIDEBAR_FONT_SIZE_MAX = 16;
31+
export const DEFAULT_SIDEBAR_FONT_SIZE = 12;
32+
export const SIDEBAR_SPACING_MIN = 4;
33+
export const SIDEBAR_SPACING_MAX = 12;
34+
export const DEFAULT_SIDEBAR_SPACING = 8;
2335

2436
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2537
export type TimestampFormat = typeof TimestampFormat.Type;
@@ -92,6 +104,12 @@ export const AppSettingsSchema = Schema.Struct({
92104
),
93105
timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
94106
sidebarOpacity: Schema.Number.pipe(withDefaults(() => 1)),
107+
sidebarProjectRowHeight: Schema.Number.pipe(
108+
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT),
109+
),
110+
sidebarThreadRowHeight: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT)),
111+
sidebarFontSize: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_FONT_SIZE)),
112+
sidebarSpacing: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_SPACING)),
95113
sidebarHideFiles: Schema.Boolean.pipe(withDefaults(() => false)),
96114
sidebarAccentProjectNames: Schema.Boolean.pipe(withDefaults(() => true)),
97115
sidebarAccentColorOverride: Schema.optional(Schema.String.check(Schema.isMaxLength(64))),
@@ -185,12 +203,36 @@ function clampBackgroundOpacity(value: number): number {
185203
return Math.max(0.05, Math.min(1, value));
186204
}
187205

206+
function clampSidebarProjectRowHeight(value: number): number {
207+
return Math.round(
208+
Math.max(SIDEBAR_PROJECT_ROW_HEIGHT_MIN, Math.min(SIDEBAR_PROJECT_ROW_HEIGHT_MAX, value)),
209+
);
210+
}
211+
212+
function clampSidebarThreadRowHeight(value: number): number {
213+
return Math.round(
214+
Math.max(SIDEBAR_THREAD_ROW_HEIGHT_MIN, Math.min(SIDEBAR_THREAD_ROW_HEIGHT_MAX, value)),
215+
);
216+
}
217+
218+
function clampSidebarFontSize(value: number): number {
219+
return Math.round(Math.max(SIDEBAR_FONT_SIZE_MIN, Math.min(SIDEBAR_FONT_SIZE_MAX, value)));
220+
}
221+
222+
function clampSidebarSpacing(value: number): number {
223+
return Math.round(Math.max(SIDEBAR_SPACING_MIN, Math.min(SIDEBAR_SPACING_MAX, value)));
224+
}
225+
188226
function normalizeAppSettings(settings: AppSettings): AppSettings {
189227
return {
190228
...settings,
191229
backgroundImageUrl: settings.backgroundImageUrl.trim(),
192230
backgroundImageOpacity: clampBackgroundOpacity(settings.backgroundImageOpacity),
193231
sidebarOpacity: clampOpacity(settings.sidebarOpacity),
232+
sidebarProjectRowHeight: clampSidebarProjectRowHeight(settings.sidebarProjectRowHeight),
233+
sidebarThreadRowHeight: clampSidebarThreadRowHeight(settings.sidebarThreadRowHeight),
234+
sidebarFontSize: clampSidebarFontSize(settings.sidebarFontSize),
235+
sidebarSpacing: clampSidebarSpacing(settings.sidebarSpacing),
194236
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
195237
customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
196238
customOpenClawModels: normalizeCustomModelSlugs(settings.customOpenClawModels, "openclaw"),

apps/web/src/components/Sidebar.tsx

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,16 @@ import {
4848
XIcon,
4949
XCircleIcon,
5050
} from "lucide-react";
51-
import { type MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
51+
import {
52+
type CSSProperties,
53+
type MouseEvent,
54+
memo,
55+
useCallback,
56+
useEffect,
57+
useMemo,
58+
useRef,
59+
useState,
60+
} from "react";
5261
import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
5362
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
5463
import { useClientMode } from "~/hooks/useClientMode";
@@ -139,6 +148,48 @@ const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {
139148
};
140149
const EMPTY_THREADS: readonly Thread[] = [];
141150
const EMPTY_THREAD_IDS: readonly ThreadIdType[] = [];
151+
152+
type SidebarDensityStyle = CSSProperties & {
153+
"--ok-sidebar-project-row-height": string;
154+
"--ok-sidebar-thread-row-height": string;
155+
"--ok-sidebar-font-size": string;
156+
"--ok-sidebar-spacing": string;
157+
};
158+
159+
const SIDEBAR_PROJECT_HEADER_STYLE: CSSProperties = {
160+
gap: "calc(var(--ok-sidebar-spacing) * 0.5)",
161+
};
162+
163+
const SIDEBAR_PROJECT_ROW_STYLE: CSSProperties = {
164+
minHeight: "var(--ok-sidebar-project-row-height)",
165+
paddingInline: "var(--ok-sidebar-spacing)",
166+
paddingBlock: "calc(var(--ok-sidebar-spacing) * 0.75)",
167+
fontSize: "var(--ok-sidebar-font-size)",
168+
};
169+
170+
const SIDEBAR_PROJECT_TITLE_STYLE: CSSProperties = {
171+
fontSize: "var(--ok-sidebar-font-size)",
172+
};
173+
174+
const SIDEBAR_THREAD_LIST_STYLE: CSSProperties = {
175+
gap: "calc(var(--ok-sidebar-spacing) * 0.25)",
176+
paddingInline: "calc(var(--ok-sidebar-spacing) * 0.5)",
177+
};
178+
179+
const SIDEBAR_THREAD_ROW_STYLE: CSSProperties = {
180+
minHeight: "var(--ok-sidebar-thread-row-height)",
181+
paddingInline: "var(--ok-sidebar-spacing)",
182+
paddingBlock: "calc(var(--ok-sidebar-spacing) * 0.5)",
183+
gap: "calc(var(--ok-sidebar-spacing) * 0.5)",
184+
fontSize: "var(--ok-sidebar-font-size)",
185+
};
186+
187+
const SIDEBAR_COLLAPSE_TOGGLE_STYLE: CSSProperties = {
188+
minHeight: "calc(var(--ok-sidebar-thread-row-height) - 4px)",
189+
paddingInline: "var(--ok-sidebar-spacing)",
190+
fontSize: "calc(var(--ok-sidebar-font-size) - 2px)",
191+
};
192+
142193
interface PrStatusIndicator {
143194
label: "PR open" | "PR closed" | "PR merged";
144195
colorClass: string;
@@ -381,13 +432,14 @@ const MemoizedThreadRow = memo(
381432
size="sm"
382433
isActive={isActive}
383434
className={cn(
384-
"h-auto min-h-7 translate-x-0 items-center gap-2 rounded-md px-2 py-1 text-left",
435+
"h-auto translate-x-0 items-center rounded-md text-left",
385436
isActive
386437
? "bg-accent/60 text-foreground"
387438
: isSelected
388439
? "bg-accent/40 text-foreground"
389440
: "text-muted-foreground hover:bg-accent/40 hover:text-foreground",
390441
)}
442+
style={SIDEBAR_THREAD_ROW_STYLE}
391443
onClick={(event) => {
392444
handleThreadClick(event, thread.id, orderedProjectThreadIds);
393445
}}
@@ -422,15 +474,15 @@ const MemoizedThreadRow = memo(
422474
}}
423475
>
424476
<ThreadIcon className={cn("size-3.5 shrink-0", threadIconColor)} />
425-
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
477+
<div className="flex min-w-0 flex-1 items-center text-left">
426478
<EditableThreadTitle
427479
title={thread.title}
428480
isEditing={editingThreadId === thread.id}
429481
draftTitle={editingThreadTitle}
430482
inputRef={bindInputRef}
431483
containerClassName="min-w-0 flex-1"
432-
titleClassName="min-w-0 flex-1 truncate text-xs"
433-
inputClassName="h-6 px-1 text-xs"
484+
titleClassName="min-w-0 flex-1 truncate leading-tight"
485+
inputClassName="h-auto min-h-0 px-1 text-[length:var(--ok-sidebar-font-size)] leading-tight"
434486
onStartEditing={() => {
435487
startEditing({
436488
threadId: thread.id,
@@ -610,6 +662,21 @@ export default function Sidebar() {
610662
() => sortThreadsByProjectIdForSidebar(sidebarThreads, appSettings.sidebarThreadSortOrder),
611663
[appSettings.sidebarThreadSortOrder, sidebarThreads],
612664
);
665+
const sidebarDensityStyle = useMemo(
666+
() =>
667+
({
668+
"--ok-sidebar-project-row-height": `${appSettings.sidebarProjectRowHeight}px`,
669+
"--ok-sidebar-thread-row-height": `${appSettings.sidebarThreadRowHeight}px`,
670+
"--ok-sidebar-font-size": `${appSettings.sidebarFontSize}px`,
671+
"--ok-sidebar-spacing": `${appSettings.sidebarSpacing}px`,
672+
}) as SidebarDensityStyle,
673+
[
674+
appSettings.sidebarFontSize,
675+
appSettings.sidebarProjectRowHeight,
676+
appSettings.sidebarSpacing,
677+
appSettings.sidebarThreadRowHeight,
678+
],
679+
);
613680
const orderedThreadIdsByProjectId = useMemo(() => {
614681
const orderedThreadIds = new Map<ProjectId, ThreadIdType[]>();
615682
for (const [projectId, projectThreads] of sortedThreadsByProjectId) {
@@ -1366,16 +1433,20 @@ export default function Sidebar() {
13661433
return (
13671434
<Collapsible className="group/collapsible" open={shouldShowThreadPanel}>
13681435
<div
1369-
className="group/project-header relative flex items-center gap-1 rounded-md"
1370-
style={{ backgroundColor: isDark ? pColor.bgDark : pColor.bg }}
1436+
className="group/project-header relative flex items-center rounded-md"
1437+
style={{
1438+
...SIDEBAR_PROJECT_HEADER_STYLE,
1439+
backgroundColor: isDark ? pColor.bgDark : pColor.bg,
1440+
}}
13711441
>
13721442
<SidebarMenuButton
13731443
ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
13741444
size="sm"
13751445
className={cn(
1376-
"min-w-0 flex-1 gap-0 rounded-md px-2 py-1.5 text-left hover:bg-transparent",
1446+
"h-auto min-w-0 flex-1 gap-0 rounded-md text-left hover:bg-transparent",
13771447
isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer",
13781448
)}
1449+
style={SIDEBAR_PROJECT_ROW_STYLE}
13791450
{...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
13801451
{...(isManualProjectSorting && dragHandleProps ? dragHandleProps.listeners : {})}
13811452
onPointerDownCapture={handleProjectTitlePointerDownCapture}
@@ -1394,7 +1465,8 @@ export default function Sidebar() {
13941465
ref={bindProjectInputRef}
13951466
type="text"
13961467
value={draftProjectTitle}
1397-
className="min-w-0 flex-1 rounded border border-primary/40 bg-background px-1 text-xs font-medium outline-none focus:border-primary"
1468+
className="min-w-0 flex-1 rounded border border-primary/40 bg-background px-1 font-medium outline-none focus:border-primary"
1469+
style={SIDEBAR_PROJECT_TITLE_STYLE}
13981470
onChange={(e) => setDraftProjectTitle(e.target.value)}
13991471
onKeyDown={(e) => {
14001472
if (e.key === "Enter") {
@@ -1412,15 +1484,16 @@ export default function Sidebar() {
14121484
<span className="min-w-0 flex-1">
14131485
<span
14141486
className={cn(
1415-
"block truncate text-xs font-semibold",
1487+
"block truncate font-semibold leading-tight",
14161488
projectNameTone === "mutedStrong" && "text-muted-foreground/72",
14171489
projectNameTone === "mutedSoft" && "text-muted-foreground/48",
14181490
)}
1419-
style={
1420-
projectNameTone === "project"
1491+
style={{
1492+
...SIDEBAR_PROJECT_TITLE_STYLE,
1493+
...(projectNameTone === "project"
14211494
? { color: isDark ? pColor.textDark : pColor.text }
1422-
: undefined
1423-
}
1495+
: {}),
1496+
}}
14241497
>
14251498
{project.name}
14261499
</span>
@@ -1452,7 +1525,10 @@ export default function Sidebar() {
14521525
</div>
14531526

14541527
<CollapsibleContent>
1455-
<SidebarMenuSub className="relative mx-0 my-0 w-auto translate-x-0 gap-0 border-none bg-transparent px-1 py-0">
1528+
<SidebarMenuSub
1529+
className="relative mx-0 my-0 w-auto translate-x-0 border-none bg-transparent py-0"
1530+
style={SIDEBAR_THREAD_LIST_STYLE}
1531+
>
14561532
{renderedThreads.map((thread) => (
14571533
<MemoizedThreadRow
14581534
key={thread.id}
@@ -1486,7 +1562,8 @@ export default function Sidebar() {
14861562
render={<button type="button" />}
14871563
data-thread-selection-safe
14881564
size="sm"
1489-
className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1565+
className="h-auto w-full translate-x-0 justify-start text-left text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1566+
style={SIDEBAR_COLLAPSE_TOGGLE_STYLE}
14901567
onClick={() => {
14911568
expandThreadListForProject(project.id);
14921569
}}
@@ -1501,7 +1578,8 @@ export default function Sidebar() {
15011578
render={<button type="button" />}
15021579
data-thread-selection-safe
15031580
size="sm"
1504-
className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1581+
className="h-auto w-full translate-x-0 justify-start text-left text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1582+
style={SIDEBAR_COLLAPSE_TOGGLE_STYLE}
15051583
onClick={() => {
15061584
collapseThreadListForProject(project.id);
15071585
}}
@@ -1972,7 +2050,7 @@ export default function Sidebar() {
19722050
</SidebarMenu>
19732051
</SidebarGroup>
19742052
) : null}
1975-
<SidebarGroup className="px-2 py-2">
2053+
<SidebarGroup className="px-2 py-2" style={sidebarDensityStyle}>
19762054
<div className="mb-1 flex items-center justify-between px-2">
19772055
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
19782056
Projects
@@ -2159,7 +2237,7 @@ export default function Sidebar() {
21592237
onDragEnd={handleProjectDragEnd}
21602238
onDragCancel={handleProjectDragCancel}
21612239
>
2162-
<SidebarMenu>
2240+
<SidebarMenu style={{ gap: "calc(var(--ok-sidebar-spacing) * 0.25)" }}>
21632241
<SortableContext
21642242
items={sortedProjects.map((project) => project.id)}
21652243
strategy={verticalListSortingStrategy}
@@ -2173,7 +2251,7 @@ export default function Sidebar() {
21732251
</SidebarMenu>
21742252
</DndContext>
21752253
) : (
2176-
<SidebarMenu className="gap-0.5">
2254+
<SidebarMenu style={{ gap: "calc(var(--ok-sidebar-spacing) * 0.25)" }}>
21772255
{sortedProjects.map((project, index) => (
21782256
<SidebarMenuItem key={project.id} className="rounded-md">
21792257
{renderProjectItem(project, null, index)}

0 commit comments

Comments
 (0)