diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 7c9a48737..c4bbb5edd 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,7 +1,14 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; -import { AppSettingsSchema, DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE } from "./appSettings"; +import { + AppSettingsSchema, + DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, + DEFAULT_SIDEBAR_FONT_SIZE, + DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, + DEFAULT_SIDEBAR_SPACING, + DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, +} from "./appSettings"; describe("AppSettingsSchema", () => { it("defaults codeViewerAutosave to false", () => { @@ -17,6 +24,15 @@ describe("AppSettingsSchema", () => { expect(settings.includeDiagnosticsTipsInCopy).toBe(false); }); + it("defaults sidebar appearance controls", () => { + const settings = Schema.decodeUnknownSync(AppSettingsSchema)({}); + + expect(settings.sidebarProjectRowHeight).toBe(DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT); + expect(settings.sidebarThreadRowHeight).toBe(DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT); + expect(settings.sidebarFontSize).toBe(DEFAULT_SIDEBAR_FONT_SIZE); + expect(settings.sidebarSpacing).toBe(DEFAULT_SIDEBAR_SPACING); + }); + it("preserves an explicit codeViewerAutosave setting", () => { const settings = Schema.decodeUnknownSync(AppSettingsSchema)({ codeViewerAutosave: true, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index f7d05f974..d72a06963 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -20,6 +20,18 @@ const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; const BACKGROUND_IMAGE_KEY = "okcode:background-image"; const BACKGROUND_OPACITY_KEY = "okcode:background-opacity"; +export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 24; +export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 44; +export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 28; +export const SIDEBAR_THREAD_ROW_HEIGHT_MIN = 24; +export const SIDEBAR_THREAD_ROW_HEIGHT_MAX = 44; +export const DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT = 28; +export const SIDEBAR_FONT_SIZE_MIN = 10; +export const SIDEBAR_FONT_SIZE_MAX = 16; +export const DEFAULT_SIDEBAR_FONT_SIZE = 12; +export const SIDEBAR_SPACING_MIN = 4; +export const SIDEBAR_SPACING_MAX = 12; +export const DEFAULT_SIDEBAR_SPACING = 8; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; @@ -92,6 +104,12 @@ export const AppSettingsSchema = Schema.Struct({ ), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), sidebarOpacity: Schema.Number.pipe(withDefaults(() => 1)), + sidebarProjectRowHeight: Schema.Number.pipe( + withDefaults(() => DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT), + ), + sidebarThreadRowHeight: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT)), + sidebarFontSize: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_FONT_SIZE)), + sidebarSpacing: Schema.Number.pipe(withDefaults(() => DEFAULT_SIDEBAR_SPACING)), sidebarHideFiles: Schema.Boolean.pipe(withDefaults(() => false)), sidebarAccentProjectNames: Schema.Boolean.pipe(withDefaults(() => true)), sidebarAccentColorOverride: Schema.optional(Schema.String.check(Schema.isMaxLength(64))), @@ -185,12 +203,36 @@ function clampBackgroundOpacity(value: number): number { return Math.max(0.05, Math.min(1, value)); } +function clampSidebarProjectRowHeight(value: number): number { + return Math.round( + Math.max(SIDEBAR_PROJECT_ROW_HEIGHT_MIN, Math.min(SIDEBAR_PROJECT_ROW_HEIGHT_MAX, value)), + ); +} + +function clampSidebarThreadRowHeight(value: number): number { + return Math.round( + Math.max(SIDEBAR_THREAD_ROW_HEIGHT_MIN, Math.min(SIDEBAR_THREAD_ROW_HEIGHT_MAX, value)), + ); +} + +function clampSidebarFontSize(value: number): number { + return Math.round(Math.max(SIDEBAR_FONT_SIZE_MIN, Math.min(SIDEBAR_FONT_SIZE_MAX, value))); +} + +function clampSidebarSpacing(value: number): number { + return Math.round(Math.max(SIDEBAR_SPACING_MIN, Math.min(SIDEBAR_SPACING_MAX, value))); +} + function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, backgroundImageUrl: settings.backgroundImageUrl.trim(), backgroundImageOpacity: clampBackgroundOpacity(settings.backgroundImageOpacity), sidebarOpacity: clampOpacity(settings.sidebarOpacity), + sidebarProjectRowHeight: clampSidebarProjectRowHeight(settings.sidebarProjectRowHeight), + sidebarThreadRowHeight: clampSidebarThreadRowHeight(settings.sidebarThreadRowHeight), + sidebarFontSize: clampSidebarFontSize(settings.sidebarFontSize), + sidebarSpacing: clampSidebarSpacing(settings.sidebarSpacing), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), customOpenClawModels: normalizeCustomModelSlugs(settings.customOpenClawModels, "openclaw"), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7db4e0db8..70c56e84b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -48,7 +48,16 @@ import { XIcon, XCircleIcon, } from "lucide-react"; -import { type MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type CSSProperties, + type MouseEvent, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; import { useClientMode } from "~/hooks/useClientMode"; @@ -139,6 +148,48 @@ const SIDEBAR_THREAD_SORT_LABELS: Record = { }; const EMPTY_THREADS: readonly Thread[] = []; const EMPTY_THREAD_IDS: readonly ThreadIdType[] = []; + +type SidebarDensityStyle = CSSProperties & { + "--ok-sidebar-project-row-height": string; + "--ok-sidebar-thread-row-height": string; + "--ok-sidebar-font-size": string; + "--ok-sidebar-spacing": string; +}; + +const SIDEBAR_PROJECT_HEADER_STYLE: CSSProperties = { + gap: "calc(var(--ok-sidebar-spacing) * 0.5)", +}; + +const SIDEBAR_PROJECT_ROW_STYLE: CSSProperties = { + minHeight: "var(--ok-sidebar-project-row-height)", + paddingInline: "var(--ok-sidebar-spacing)", + paddingBlock: "calc(var(--ok-sidebar-spacing) * 0.75)", + fontSize: "var(--ok-sidebar-font-size)", +}; + +const SIDEBAR_PROJECT_TITLE_STYLE: CSSProperties = { + fontSize: "var(--ok-sidebar-font-size)", +}; + +const SIDEBAR_THREAD_LIST_STYLE: CSSProperties = { + gap: "calc(var(--ok-sidebar-spacing) * 0.25)", + paddingInline: "calc(var(--ok-sidebar-spacing) * 0.5)", +}; + +const SIDEBAR_THREAD_ROW_STYLE: CSSProperties = { + minHeight: "var(--ok-sidebar-thread-row-height)", + paddingInline: "var(--ok-sidebar-spacing)", + paddingBlock: "calc(var(--ok-sidebar-spacing) * 0.5)", + gap: "calc(var(--ok-sidebar-spacing) * 0.5)", + fontSize: "var(--ok-sidebar-font-size)", +}; + +const SIDEBAR_COLLAPSE_TOGGLE_STYLE: CSSProperties = { + minHeight: "calc(var(--ok-sidebar-thread-row-height) - 4px)", + paddingInline: "var(--ok-sidebar-spacing)", + fontSize: "calc(var(--ok-sidebar-font-size) - 2px)", +}; + interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; @@ -381,13 +432,14 @@ const MemoizedThreadRow = memo( size="sm" isActive={isActive} className={cn( - "h-auto min-h-7 translate-x-0 items-center gap-2 rounded-md px-2 py-1 text-left", + "h-auto translate-x-0 items-center rounded-md text-left", isActive ? "bg-accent/60 text-foreground" : isSelected ? "bg-accent/40 text-foreground" : "text-muted-foreground hover:bg-accent/40 hover:text-foreground", )} + style={SIDEBAR_THREAD_ROW_STYLE} onClick={(event) => { handleThreadClick(event, thread.id, orderedProjectThreadIds); }} @@ -422,15 +474,15 @@ const MemoizedThreadRow = memo( }} > -
+
{ startEditing({ threadId: thread.id, @@ -610,6 +662,21 @@ export default function Sidebar() { () => sortThreadsByProjectIdForSidebar(sidebarThreads, appSettings.sidebarThreadSortOrder), [appSettings.sidebarThreadSortOrder, sidebarThreads], ); + const sidebarDensityStyle = useMemo( + () => + ({ + "--ok-sidebar-project-row-height": `${appSettings.sidebarProjectRowHeight}px`, + "--ok-sidebar-thread-row-height": `${appSettings.sidebarThreadRowHeight}px`, + "--ok-sidebar-font-size": `${appSettings.sidebarFontSize}px`, + "--ok-sidebar-spacing": `${appSettings.sidebarSpacing}px`, + }) as SidebarDensityStyle, + [ + appSettings.sidebarFontSize, + appSettings.sidebarProjectRowHeight, + appSettings.sidebarSpacing, + appSettings.sidebarThreadRowHeight, + ], + ); const orderedThreadIdsByProjectId = useMemo(() => { const orderedThreadIds = new Map(); for (const [projectId, projectThreads] of sortedThreadsByProjectId) { @@ -1366,16 +1433,20 @@ export default function Sidebar() { return (
setDraftProjectTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { @@ -1412,15 +1484,16 @@ export default function Sidebar() { {project.name} @@ -1452,7 +1525,10 @@ export default function Sidebar() {
- + {renderedThreads.map((thread) => ( } data-thread-selection-safe size="sm" - 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" + className="h-auto w-full translate-x-0 justify-start text-left text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + style={SIDEBAR_COLLAPSE_TOGGLE_STYLE} onClick={() => { expandThreadListForProject(project.id); }} @@ -1501,7 +1578,8 @@ export default function Sidebar() { render={