Add left sidebar appearance setting

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-12 13:35:19 -07:00
co-authored by Orca
parent 0310b032bb
commit 5caa5a079c
24 changed files with 713 additions and 76 deletions
@@ -136,6 +136,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
+1
View File
@@ -140,6 +140,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
+29
View File
@@ -97,6 +97,11 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
import {
normalizeLeftSidebarAppearanceMode,
normalizeLeftSidebarTintColor,
normalizeLeftSidebarTintOpacity
} from '../shared/left-sidebar-appearance'
import {
compareFeatureInteractionUsageBuckets,
getFeatureInteractionCategory,
@@ -2308,6 +2313,15 @@ export class Store {
terminalCustomThemes: normalizeTerminalCustomThemes(
parsed.settings?.terminalCustomThemes
),
leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode(
parsed.settings?.leftSidebarAppearanceMode
),
leftSidebarTintColor: normalizeLeftSidebarTintColor(
parsed.settings?.leftSidebarTintColor
),
leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity(
parsed.settings?.leftSidebarTintOpacity
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
@@ -3635,6 +3649,21 @@ export class Store {
updates.terminalCustomThemes
)
}
if ('leftSidebarAppearanceMode' in updates) {
sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode(
updates.leftSidebarAppearanceMode
)
}
if ('leftSidebarTintColor' in updates) {
sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor(
updates.leftSidebarTintColor
)
}
if ('leftSidebarTintOpacity' in updates) {
sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity(
updates.leftSidebarTintOpacity
)
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
@@ -194,6 +194,27 @@ describe('AppearancePane', () => {
expect(updateSettings).toHaveBeenCalledWith({ uiLanguage: 'zh' })
})
it('updates the left sidebar appearance from sidebar settings', async () => {
mocks.state.settingsSearchQuery = 'left sidebar'
const updateSettings = vi.fn()
const settings = getDefaultSettings('/tmp')
const container = await renderAppearancePane(settings, updateSettings)
const matchTerminalButton = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
).find((button) => button.textContent === 'Match Terminal')
expect(matchTerminalButton).toBeDefined()
await act(async () => {
matchTerminalButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({
leftSidebarAppearanceMode: 'match-terminal'
})
})
it('restores the Automations sidebar button from the sidebar settings switch', async () => {
const updateSettings = vi.fn()
const settings = {
@@ -27,6 +27,7 @@ import {
getAppearancePaneSearchEntries,
getLanguageEntries,
getLayoutEntries,
getLeftSidebarAppearanceEntry,
getSidebarEntries,
getStatusBarEntries,
getStatusBarToggles,
@@ -48,6 +49,7 @@ import {
} from '@/i18n/supported-languages'
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
export { getAppearancePaneSearchEntries }
type AppearancePaneProps = {
@@ -104,6 +106,7 @@ export function AppearancePane({
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
showWarpImport: !isWebClientLocation()
})
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const visibleSections = [
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
@@ -412,6 +415,15 @@ export function AppearancePane({
/>
<div className="divide-y divide-border/40">
<SearchableSetting
title={leftSidebarAppearanceEntry.title}
description={leftSidebarAppearanceEntry.description}
keywords={leftSidebarAppearanceEntry.keywords}
className="space-y-2"
>
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
@@ -0,0 +1,115 @@
import type React from 'react'
import type { GlobalSettings, LeftSidebarAppearanceMode } from '../../../../shared/types'
import {
DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
DEFAULT_LEFT_SIDEBAR_TINT_OPACITY,
MAX_LEFT_SIDEBAR_TINT_OPACITY
} from '../../../../shared/left-sidebar-appearance'
import { translate } from '@/i18n/i18n'
import {
ColorField,
NumberField,
SettingsRow,
SettingsSegmentedControl
} from './SettingsFormControls'
type LeftSidebarAppearanceSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function LeftSidebarAppearanceSetting({
settings,
updateSettings
}: LeftSidebarAppearanceSettingProps): React.JSX.Element {
return (
<div className="space-y-2">
<SettingsRow
alignTop
label={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.title',
'Left Sidebar Appearance'
)}
description={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.rowDescription',
'Make the left sidebar match your terminal, stay default, or use a tint.'
)}
control={
<SettingsSegmentedControl<LeftSidebarAppearanceMode>
size="sm"
value={settings.leftSidebarAppearanceMode ?? 'default'}
onChange={(leftSidebarAppearanceMode) => updateSettings({ leftSidebarAppearanceMode })}
ariaLabel={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.title',
'Left Sidebar Appearance'
)}
options={[
{
value: 'default',
label: translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.default',
'Default'
)
},
{
value: 'match-terminal',
label: translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.matchTerminal',
'Match Terminal'
)
},
{
value: 'soft-contrast',
label: translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.softContrast',
'Soft Contrast'
)
},
{
value: 'tinted',
label: translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.tinted',
'Tinted'
)
}
]}
/>
}
/>
{(settings.leftSidebarAppearanceMode ?? 'default') === 'tinted' ? (
<div className="space-y-2">
<ColorField
label={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.tintColor',
'Sidebar Tint'
)}
description={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.tintColorDescription',
'The color mixed into the left sidebar surface.'
)}
value={settings.leftSidebarTintColor ?? DEFAULT_LEFT_SIDEBAR_TINT_COLOR}
fallback={DEFAULT_LEFT_SIDEBAR_TINT_COLOR}
onChange={(leftSidebarTintColor) => updateSettings({ leftSidebarTintColor })}
/>
<NumberField
label={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.tintOpacity',
'Tint Strength'
)}
description={translate(
'auto.components.settings.AppearancePane.leftSidebarAppearance.tintOpacityDescription',
'Controls how strongly the tint is mixed into the sidebar.'
)}
value={settings.leftSidebarTintOpacity ?? DEFAULT_LEFT_SIDEBAR_TINT_OPACITY}
defaultValue={DEFAULT_LEFT_SIDEBAR_TINT_OPACITY}
min={0}
max={MAX_LEFT_SIDEBAR_TINT_OPACITY}
step={0.01}
suffix={`0 to ${MAX_LEFT_SIDEBAR_TINT_OPACITY}`}
onChange={(leftSidebarTintOpacity) => updateSettings({ leftSidebarTintOpacity })}
/>
</div>
) : null}
</div>
)
}
@@ -947,6 +947,7 @@ function Settings(): React.JSX.Element {
>
<SettingsSidebar
activeSectionId={activeSectionId}
settings={settings}
generalGroups={generalNavGroups}
repoSections={repoNavSections}
hasRepos={repos.length > 0}
@@ -1,9 +1,11 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { Bot, Mic, Network } from 'lucide-react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { SettingsSidebar } from './SettingsSidebar'
import { TooltipProvider } from '../ui/tooltip'
import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress'
import type { GlobalSettings } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
useSettingsSetupGuideProgress: vi.fn()
@@ -29,11 +31,15 @@ function makeSetupGuideProgress(
}
}
function renderSidebar(activeSectionId = 'orchestration'): string {
function renderSidebar(
activeSectionId = 'orchestration',
settings: GlobalSettings = getDefaultSettings('/tmp')
): string {
return renderToStaticMarkup(
<TooltipProvider>
<SettingsSidebar
activeSectionId={activeSectionId}
settings={settings}
generalGroups={[
{
id: 'capabilities',
@@ -88,6 +94,20 @@ describe('SettingsSidebar', () => {
mocks.useSettingsSetupGuideProgress.mockReturnValue(makeSetupGuideProgress())
})
it('applies left sidebar appearance styles to the settings navigation', () => {
const markup = renderSidebar('orchestration', {
...getDefaultSettings('/tmp'),
leftSidebarAppearanceMode: 'match-terminal',
terminalColorOverrides: {
background: '#101820',
foreground: '#f0f4f8'
}
})
expect(markup).toContain('--worktree-sidebar:#101820')
expect(markup).toContain('--worktree-sidebar-foreground:#f0f4f8')
})
it('renders install state labels separately from static badges', () => {
const markup = renderSidebar()
@@ -1,8 +1,9 @@
import type { RefObject } from 'react'
import type { CSSProperties, RefObject } from 'react'
import { useMemo } from 'react'
import { ArrowLeft, Search, Server } from 'lucide-react'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types'
import type { GitHubRepositoryIdentity } from '../../../../shared/types'
import type { GitHubRepositoryIdentity, GlobalSettings } from '../../../../shared/types'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { cn } from '@/lib/utils'
import { RepoIconGlyph } from '../repo/repo-icon'
@@ -13,6 +14,8 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
import { useSettingsSetupGuideProgress } from './settings-setup-guide-progress'
import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress'
import { translate } from '@/i18n/i18n'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark'
type NavSection = {
id: string
@@ -37,6 +40,7 @@ type RepoNavSection = NavSection & {
type SettingsSidebarProps = {
activeSectionId: string
settings: GlobalSettings | null
generalGroups: NavGroup[]
repoSections: RepoNavSection[]
hasRepos: boolean
@@ -112,6 +116,7 @@ function SettingsSetupGuideNavRow({
export function SettingsSidebar({
activeSectionId,
settings,
generalGroups,
repoSections,
hasRepos,
@@ -122,6 +127,11 @@ export function SettingsSidebar({
onSelectSection
}: SettingsSidebarProps): React.JSX.Element {
const setupGuideProgress = useSettingsSetupGuideProgress(true)
const systemPrefersDark = useSystemPrefersDark()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
) as CSSProperties | undefined
const setupActive = activeSectionId === 'setup-guide'
// Why: "Hide from sidebar" only hides the top-left app sidebar prompt;
// Settings should remain a stable place to reopen the checklist.
@@ -159,7 +169,10 @@ export function SettingsSidebar({
)
return (
<aside className="flex w-[280px] shrink-0 flex-col border-r border-worktree-sidebar-border bg-worktree-sidebar">
<aside
className="flex w-[280px] shrink-0 flex-col border-r border-worktree-sidebar-border bg-worktree-sidebar"
style={leftSidebarStyle}
>
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<Button
variant="ghost"
@@ -1,5 +1,6 @@
import type { SettingsSearchEntry } from './settings-search'
import { getTerminalAppearanceSearchEntries } from './terminal-search'
import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
@@ -153,71 +154,7 @@ export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntr
}))
)
export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'),
description: translate(
'auto.components.settings.appearance.search.9a248333c7',
'Show the Tasks button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.0d5a74b606', 'tasks'),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show'),
...translateSearchKeyword('auto.components.settings.appearance.search.2ee4810f38', 'github'),
...translateSearchKeyword('auto.components.settings.appearance.search.6b846424cc', 'linear')
]
},
{
title: translate(
'auto.components.settings.appearance.search.caa27e1a8e',
'Show Automations Button'
),
description: translate(
'auto.components.settings.appearance.search.ae13a0d340',
'Show the Automations button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.appearance.search.b186f3cefb',
'automations'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.58f4e22fa2',
'automation'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.4c920ab2d1',
'schedule'
),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show')
]
},
{
title: translate(
'auto.components.settings.appearance.search.1de96ec8a6',
'Show Orca Mobile Button'
),
description: translate(
'auto.components.settings.appearance.search.682293cadf',
'Show the Orca Mobile button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.74618577c7', 'mobile'),
...translateSearchKeyword('auto.components.settings.appearance.search.5e5b8878bf', 'phone'),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show'),
...translateSearchKeyword('auto.components.settings.appearance.search.839fb1e3ed', 'toolbox')
]
}
])
export { getLeftSidebarAppearanceEntry, getSidebarEntries }
export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
@@ -0,0 +1,107 @@
import type { SettingsSearchEntry } from './settings-search'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export const getLeftSidebarAppearanceEntry = createLocalizedCatalog(
(): SettingsSearchEntry => ({
title: translate(
'auto.components.settings.appearance.search.leftSidebarAppearance.title',
'Left Sidebar Appearance'
),
description: translate(
'auto.components.settings.appearance.search.leftSidebarAppearance.description',
'Make the left sidebar match your terminal, stay default, or use a tint.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.leftSidebarAppearance.project',
'project'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.leftSidebarAppearance.terminal',
'terminal'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.leftSidebarAppearance.background',
'background'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.leftSidebarAppearance.tint',
'tint'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.leftSidebarAppearance.contrast',
'contrast'
)
]
})
)
export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'),
description: translate(
'auto.components.settings.appearance.search.9a248333c7',
'Show the Tasks button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.0d5a74b606', 'tasks'),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show'),
...translateSearchKeyword('auto.components.settings.appearance.search.2ee4810f38', 'github'),
...translateSearchKeyword('auto.components.settings.appearance.search.6b846424cc', 'linear')
]
},
{
title: translate(
'auto.components.settings.appearance.search.caa27e1a8e',
'Show Automations Button'
),
description: translate(
'auto.components.settings.appearance.search.ae13a0d340',
'Show the Automations button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.appearance.search.b186f3cefb',
'automations'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.58f4e22fa2',
'automation'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.4c920ab2d1',
'schedule'
),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show')
]
},
{
title: translate(
'auto.components.settings.appearance.search.1de96ec8a6',
'Show Orca Mobile Button'
),
description: translate(
'auto.components.settings.appearance.search.682293cadf',
'Show the Orca Mobile button at the top of the left sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.74618577c7', 'mobile'),
...translateSearchKeyword('auto.components.settings.appearance.search.5e5b8878bf', 'phone'),
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword('auto.components.settings.appearance.search.6cf5f54ce1', 'button'),
...translateSearchKeyword('auto.components.settings.appearance.search.648eeada79', 'hide'),
...translateSearchKeyword('auto.components.settings.appearance.search.ac79fe4a04', 'show'),
...translateSearchKeyword('auto.components.settings.appearance.search.839fb1e3ed', 'toolbox')
]
},
getLeftSidebarAppearanceEntry()
])
@@ -35,6 +35,7 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
type WorkspaceKanbanDrawerProps = {
leftSidebarStyle?: React.CSSProperties
open: boolean
preserveOpenForMenu: boolean
onOpenChange: (open: boolean) => void
@@ -42,6 +43,7 @@ type WorkspaceKanbanDrawerProps = {
}
export default function WorkspaceKanbanDrawer({
leftSidebarStyle,
open,
preserveOpenForMenu,
onOpenChange,
@@ -481,6 +483,7 @@ export default function WorkspaceKanbanDrawer({
overlayStyle={{ top: 36, left: drawerLeftCss, pointerEvents: 'none' }}
style={
{
...leftSidebarStyle,
// Why: the board is a companion to the workspace sidebar, so it
// expands from the sidebar edge instead of covering the sidebar.
left: drawerLeftCss,
+11 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'
import React, { useEffect, useMemo } from 'react'
import { useAppStore } from '@/store'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useSidebarResize } from '@/hooks/useSidebarResize'
@@ -14,6 +14,8 @@ import { cn } from '@/lib/utils'
import { FolderPlus, Loader2 } from 'lucide-react'
import { useSidebarProjectDrop } from './useSidebarProjectDrop'
import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel'
import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog'))
const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog'))
@@ -43,9 +45,15 @@ function Sidebar({
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
const repos = useAppStore((s) => s.repos)
const settings = useAppStore((s) => s.settings)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const activeModal = useAppStore((s) => s.activeModal)
const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop()
const systemPrefersDark = useSystemPrefersDark()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
) as React.CSSProperties | undefined
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false)
const unmountAddRepoDialogTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const {
@@ -116,6 +124,7 @@ function Sidebar({
ref={containerRef}
data-native-file-drop-target={sidebarOpen ? nativeDropTarget : undefined}
className="relative min-h-0 flex-shrink-0 bg-worktree-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent"
style={leftSidebarStyle}
{...dropHandlers}
>
{sidebarOpen && (
@@ -183,6 +192,7 @@ function Sidebar({
</React.Suspense>
{sidebarOpen ? (
<WorkspaceKanbanDrawer
leftSidebarStyle={leftSidebarStyle}
open={workspaceBoardOpen}
preserveOpenForMenu={workspaceBoardMenuOpen}
onOpenChange={handleWorkspaceBoardOpenChange}
+18 -1
View File
@@ -4177,7 +4177,20 @@
"7d26ccabe8": "Dark",
"fb0e0b4453": "System",
"932ff1fbff": "Theme",
"0f28e7b30c": "Choose how Orca looks in the app window."
"0f28e7b30c": "Choose how Orca looks in the app window.",
"leftSidebarAppearance": {
"title": "Left Sidebar Appearance",
"description": "Make the left sidebar match your terminal, stay default, or use a tint.",
"rowDescription": "Make the left sidebar match your terminal, stay default, or use a tint.",
"default": "Default",
"matchTerminal": "Match Terminal",
"softContrast": "Soft Contrast",
"tinted": "Tinted",
"tintColor": "Sidebar Tint",
"tintColorDescription": "The color mixed into the left sidebar surface.",
"tintOpacity": "Tint Strength",
"tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar."
}
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6215,6 +6228,10 @@
"locale": "locale",
"i18n": "i18n",
"translation": "translation"
},
"leftSidebarAppearance": {
"title": "Left Sidebar Appearance",
"description": "Make the left sidebar match your terminal, stay default, or use a tint."
}
}
},
+18 -1
View File
@@ -4177,7 +4177,20 @@
"7d26ccabe8": "Oscuro",
"fb0e0b4453": "Sistema",
"932ff1fbff": "Tema",
"0f28e7b30c": "Elige cómo se ve Orca en la ventana de la aplicación."
"0f28e7b30c": "Elige cómo se ve Orca en la ventana de la aplicación.",
"leftSidebarAppearance": {
"title": "Apariencia de la barra lateral izquierda",
"description": "Haz que la barra lateral izquierda coincida con tu terminal, conserve el valor predeterminado o use un tinte.",
"rowDescription": "Haz que la barra lateral izquierda coincida con tu terminal, conserve el valor predeterminado o use un tinte.",
"default": "Predeterminado",
"matchTerminal": "Coincidir con terminal",
"softContrast": "Contraste suave",
"tinted": "Con tinte",
"tintColor": "Tinte de la barra lateral",
"tintColorDescription": "El color que se mezcla en la superficie de la barra lateral izquierda.",
"tintOpacity": "Intensidad del tinte",
"tintOpacityDescription": "Controla con qué fuerza se mezcla el tinte en la barra lateral."
}
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6179,6 +6192,10 @@
"locale": "lugar",
"i18n": "i18n",
"translation": "traducción"
},
"leftSidebarAppearance": {
"title": "Apariencia de la barra lateral izquierda",
"description": "Haz que la barra lateral izquierda coincida con tu terminal, conserve el valor predeterminado o use un tinte."
}
}
},
+18 -1
View File
@@ -4162,7 +4162,20 @@
"7d26ccabe8": "ダーク",
"fb0e0b4453": "システム",
"932ff1fbff": "テーマ",
"0f28e7b30c": "アプリ ウィンドウで Orca がどのように見えるかを選択します。"
"0f28e7b30c": "アプリ ウィンドウで Orca がどのように見えるかを選択します。",
"leftSidebarAppearance": {
"title": "左サイドバーの外観",
"description": "左サイドバーをターミナルに合わせるか、既定のままにするか、色合いを加えます。",
"rowDescription": "左サイドバーをターミナルに合わせるか、既定のままにするか、色合いを加えます。",
"default": "デフォルト",
"matchTerminal": "ターミナルに合わせる",
"softContrast": "ソフトコントラスト",
"tinted": "色合い",
"tintColor": "サイドバーの色合い",
"tintColorDescription": "左サイドバーの表面に混ぜる色です。",
"tintOpacity": "色合いの強さ",
"tintOpacityDescription": "サイドバーに色合いをどの程度強く混ぜるかを調整します。"
}
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6200,6 +6213,10 @@
"locale": "ロケール",
"i18n": "i18n",
"translation": "翻訳"
},
"leftSidebarAppearance": {
"title": "左サイドバーの外観",
"description": "左サイドバーをターミナルに合わせるか、既定のままにするか、色合いを加えます。"
}
}
},
+18 -1
View File
@@ -4162,7 +4162,20 @@
"7d26ccabe8": "다크",
"fb0e0b4453": "체계",
"932ff1fbff": "주제",
"0f28e7b30c": "Orca가 앱 창에 표시되는 방식을 선택합니다."
"0f28e7b30c": "Orca가 앱 창에 표시되는 방식을 선택합니다.",
"leftSidebarAppearance": {
"title": "왼쪽 사이드바 모양",
"description": "왼쪽 사이드바를 터미널에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다.",
"rowDescription": "왼쪽 사이드바를 터미널에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다.",
"default": "기본값",
"matchTerminal": "터미널에 맞춤",
"softContrast": "부드러운 대비",
"tinted": "색조 적용",
"tintColor": "사이드바 색조",
"tintColorDescription": "왼쪽 사이드바 표면에 섞을 색상입니다.",
"tintOpacity": "색조 강도",
"tintOpacityDescription": "사이드바에 색조를 얼마나 강하게 섞을지 조절합니다."
}
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6164,6 +6177,10 @@
"locale": "로케일",
"i18n": "i18n",
"translation": "번역"
},
"leftSidebarAppearance": {
"title": "왼쪽 사이드바 모양",
"description": "왼쪽 사이드바를 터미널에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다."
}
}
},
+18 -1
View File
@@ -4162,7 +4162,20 @@
"7d26ccabe8": "深色",
"fb0e0b4453": "系统",
"932ff1fbff": "主题",
"0f28e7b30c": "选择 Orca 在应用程序窗口中的外观。"
"0f28e7b30c": "选择 Orca 在应用程序窗口中的外观。",
"leftSidebarAppearance": {
"title": "左侧边栏外观",
"description": "让左侧边栏匹配终端、保持默认,或使用色调。",
"rowDescription": "让左侧边栏匹配终端、保持默认,或使用色调。",
"default": "默认",
"matchTerminal": "匹配终端",
"softContrast": "柔和对比",
"tinted": "色调",
"tintColor": "边栏色调",
"tintColorDescription": "混入左侧边栏表面的颜色。",
"tintOpacity": "色调强度",
"tintOpacityDescription": "控制色调混入边栏的强度。"
}
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6164,6 +6177,10 @@
"locale": "区域设置",
"i18n": "国际化",
"translation": "翻译"
},
"leftSidebarAppearance": {
"title": "左侧边栏外观",
"description": "让左侧边栏匹配终端、保持默认,或使用色调。"
}
}
},
@@ -0,0 +1,97 @@
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import { resolveLeftSidebarStyleVariables } from './left-sidebar-appearance'
function settings(overrides = {}) {
return {
...getDefaultSettings(tmpdir()),
...overrides
}
}
describe('resolveLeftSidebarStyleVariables', () => {
it('leaves the default sidebar token surface untouched', () => {
expect(resolveLeftSidebarStyleVariables(settings(), true)).toBeUndefined()
})
it('matches terminal background, foreground, and scoped text tokens', () => {
const vars = resolveLeftSidebarStyleVariables(
settings({
leftSidebarAppearanceMode: 'match-terminal',
terminalColorOverrides: {
background: '#101820',
foreground: '#f0f4f8'
}
}),
true
)
expect(vars).toMatchObject({
'--worktree-sidebar': '#101820',
'--worktree-sidebar-foreground': '#f0f4f8',
'--sidebar': '#101820',
'--sidebar-foreground': '#f0f4f8',
'--background': '#101820',
'--foreground': '#f0f4f8'
})
expect(vars?.['--worktree-sidebar-accent']).toContain('#f0f4f8 9%')
expect(vars?.['--sidebar-accent']).toBe(vars?.['--worktree-sidebar-accent'])
})
it('honors terminal background opacity for matched terminal surfaces', () => {
const vars = resolveLeftSidebarStyleVariables(
settings({
leftSidebarAppearanceMode: 'match-terminal',
terminalColorOverrides: { background: '#123456' },
terminalBackgroundOpacity: 0.5
}),
true
)
expect(vars?.['--worktree-sidebar']).toBe('rgba(18, 52, 86, 0.5)')
})
it('builds a tinted surface from normalized tint settings', () => {
const vars = resolveLeftSidebarStyleVariables(
settings({
leftSidebarAppearanceMode: 'tinted',
leftSidebarTintColor: '336699',
leftSidebarTintOpacity: 0.125
}),
true
)
expect(vars?.['--worktree-sidebar']).toBe(
'color-mix(in srgb, #336699 12.5%, var(--background))'
)
expect(vars?.['--sidebar']).toBe(vars?.['--worktree-sidebar'])
expect(vars?.['--worktree-sidebar-foreground']).toBe('var(--foreground)')
})
it('caps tinted opacity so arbitrary tint colors stay subtle', () => {
const vars = resolveLeftSidebarStyleVariables(
settings({
leftSidebarAppearanceMode: 'tinted',
leftSidebarTintColor: '#000000',
leftSidebarTintOpacity: 1
}),
true
)
expect(vars?.['--worktree-sidebar']).toBe('color-mix(in srgb, #000000 35%, var(--background))')
})
it('uses the soft contrast token recipe without overriding app text tokens', () => {
const vars = resolveLeftSidebarStyleVariables(
settings({ leftSidebarAppearanceMode: 'soft-contrast' }),
true
)
expect(vars?.['--worktree-sidebar']).toBe(
'color-mix(in srgb, var(--background) 96%, var(--foreground) 4%)'
)
expect(vars?.['--sidebar']).toBe(vars?.['--worktree-sidebar'])
expect(vars?.['--foreground']).toBeUndefined()
})
})
@@ -0,0 +1,135 @@
import type { GlobalSettings } from '../../../shared/types'
import { HEX_COLOR_RE } from '../../../shared/color-validation'
import {
normalizeLeftSidebarTintColor,
normalizeLeftSidebarTintOpacity
} from '../../../shared/left-sidebar-appearance'
import { resolveEffectiveTerminalAppearance } from './terminal-theme'
type LeftSidebarAppearanceSettings = Pick<
GlobalSettings,
| 'leftSidebarAppearanceMode'
| 'leftSidebarTintColor'
| 'leftSidebarTintOpacity'
| 'theme'
| 'terminalThemeDark'
| 'terminalDividerColorDark'
| 'terminalUseSeparateLightTheme'
| 'terminalThemeLight'
| 'terminalCustomThemes'
| 'terminalDividerColorLight'
| 'terminalColorOverrides'
| 'terminalBackgroundOpacity'
>
export type LeftSidebarStyleVariables = Record<string, string>
function hexToRgba(hex: string, alpha: number): string {
const normalized = normalizeLeftSidebarTintColor(hex)
let clean = normalized.replace('#', '')
if (clean.length === 3) {
clean = clean
.split('')
.map((part) => part + part)
.join('')
}
const r = parseInt(clean.slice(0, 2), 16)
const g = parseInt(clean.slice(2, 4), 16)
const b = parseInt(clean.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function applyAlpha(color: string, alpha: number | undefined): string {
if (alpha === undefined || alpha >= 1 || !HEX_COLOR_RE.test(color.trim())) {
return color
}
return hexToRgba(color, Math.min(1, Math.max(0, alpha)))
}
function buildSurfaceVariables(args: {
background: string
foreground: string
overrideTextTokens?: boolean
}): LeftSidebarStyleVariables {
const { background, foreground, overrideTextTokens = false } = args
const accent = `color-mix(in srgb, ${foreground} 9%, ${background})`
const border = `color-mix(in srgb, ${foreground} 14%, ${background})`
const ring = `color-mix(in srgb, ${foreground} 44%, ${background})`
const vars: LeftSidebarStyleVariables = {
'--worktree-sidebar': background,
'--worktree-sidebar-foreground': foreground,
'--worktree-sidebar-accent': accent,
'--worktree-sidebar-accent-foreground': foreground,
'--worktree-sidebar-border': border,
'--worktree-sidebar-ring': ring,
// Why: older worktree-sidebar descendants still consume the shadcn sidebar
// token family; mirror it inside this scoped root so every left-sidebar
// surface follows the selected appearance.
'--sidebar': background,
'--sidebar-foreground': foreground,
'--sidebar-accent': accent,
'--sidebar-accent-foreground': foreground,
'--sidebar-border': border,
'--sidebar-ring': ring
}
if (overrideTextTokens) {
vars['--background'] = background
vars['--foreground'] = foreground
vars['--card'] = `color-mix(in srgb, ${foreground} 4%, ${background})`
vars['--card-foreground'] = foreground
vars['--accent'] = `color-mix(in srgb, ${foreground} 9%, ${background})`
vars['--accent-foreground'] = foreground
vars['--muted'] = `color-mix(in srgb, ${foreground} 7%, ${background})`
vars['--muted-foreground'] = `color-mix(in srgb, ${foreground} 62%, ${background})`
vars['--border'] = `color-mix(in srgb, ${foreground} 14%, ${background})`
}
return vars
}
function resolveTerminalSurfaceVariables(
settings: LeftSidebarAppearanceSettings,
systemPrefersDark: boolean
): LeftSidebarStyleVariables {
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const background = applyAlpha(
settings.terminalColorOverrides?.background ?? appearance.theme?.background ?? '#000000',
settings.terminalBackgroundOpacity
)
const foreground =
settings.terminalColorOverrides?.foreground ?? appearance.theme?.foreground ?? '#fafafa'
return buildSurfaceVariables({ background, foreground, overrideTextTokens: true })
}
function resolveTintedSurfaceVariables(
settings: LeftSidebarAppearanceSettings
): LeftSidebarStyleVariables {
const tintColor = normalizeLeftSidebarTintColor(settings.leftSidebarTintColor)
const tintOpacity = normalizeLeftSidebarTintOpacity(settings.leftSidebarTintOpacity)
const tintPercent = Number((tintOpacity * 100).toFixed(2))
const background = `color-mix(in srgb, ${tintColor} ${tintPercent}%, var(--background))`
return buildSurfaceVariables({ background, foreground: 'var(--foreground)' })
}
function resolveSoftContrastSurfaceVariables(): LeftSidebarStyleVariables {
const background = 'color-mix(in srgb, var(--background) 96%, var(--foreground) 4%)'
return buildSurfaceVariables({ background, foreground: 'var(--foreground)' })
}
export function resolveLeftSidebarStyleVariables(
settings: LeftSidebarAppearanceSettings | null | undefined,
systemPrefersDark: boolean
): LeftSidebarStyleVariables | undefined {
if (!settings) {
return undefined
}
switch (settings.leftSidebarAppearanceMode) {
case 'default':
return undefined
case 'match-terminal':
return resolveTerminalSurfaceVariables(settings, systemPrefersDark)
case 'tinted':
return resolveTintedSurfaceVariables(settings)
case 'soft-contrast':
return resolveSoftContrastSurfaceVariables()
}
}
+7
View File
@@ -24,6 +24,10 @@ import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom'
import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection'
import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults'
import { UI_LANGUAGE_SYSTEM } from './ui-language'
import {
DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
DEFAULT_LEFT_SIDEBAR_TINT_OPACITY
} from './left-sidebar-appearance'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@@ -181,6 +185,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
leftSidebarAppearanceMode: 'default',
leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY,
uiLanguage: UI_LANGUAGE_SYSTEM,
appIcon: DEFAULT_APP_ICON_ID,
appFontFamily: DEFAULT_APP_FONT_FAMILY,
+37
View File
@@ -0,0 +1,37 @@
import { HEX_COLOR_RE } from './color-validation'
import type { LeftSidebarAppearanceMode } from './types'
export const LEFT_SIDEBAR_APPEARANCE_MODES = [
'default',
'match-terminal',
'tinted',
'soft-contrast'
] as const
export const DEFAULT_LEFT_SIDEBAR_TINT_COLOR = '#18181b'
export const DEFAULT_LEFT_SIDEBAR_TINT_OPACITY = 0.08
export const MAX_LEFT_SIDEBAR_TINT_OPACITY = 0.35
export function normalizeLeftSidebarAppearanceMode(value: unknown): LeftSidebarAppearanceMode {
return LEFT_SIDEBAR_APPEARANCE_MODES.includes(value as LeftSidebarAppearanceMode)
? (value as LeftSidebarAppearanceMode)
: 'default'
}
export function normalizeLeftSidebarTintColor(value: unknown): string {
if (typeof value !== 'string') {
return DEFAULT_LEFT_SIDEBAR_TINT_COLOR
}
const trimmed = value.trim()
if (!trimmed || !HEX_COLOR_RE.test(trimmed)) {
return DEFAULT_LEFT_SIDEBAR_TINT_COLOR
}
return trimmed.startsWith('#') ? trimmed : `#${trimmed}`
}
export function normalizeLeftSidebarTintOpacity(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_LEFT_SIDEBAR_TINT_OPACITY
}
return Math.min(MAX_LEFT_SIDEBAR_TINT_OPACITY, Math.max(0, value))
}
+1 -1
View File
@@ -19,7 +19,7 @@ describe('hasNativeFileDragTypes', () => {
})
describe('resolveNativeFileDropPath', () => {
it('routes drops on the project sidebar to the add-project surface', () => {
it('routes drops on the left sidebar to the add-project surface', () => {
expect(
resolveNativeFileDropPath([{ nativeFileDropTarget: NATIVE_FILE_DROP_TARGET.projectSidebar }])
).toEqual({ target: NATIVE_FILE_DROP_TARGET.projectSidebar })
+6
View File
@@ -2042,6 +2042,8 @@ export type OpenInApplication = {
export type SourceControlViewMode = 'list' | 'tree'
export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' | 'soft-contrast'
export type FloatingTerminalCwdRequest = {
path?: string
requireTrusted?: boolean
@@ -2066,6 +2068,10 @@ export type GlobalSettings = {
branchPrefixCustom: string
enableGitHubAttribution: boolean
theme: 'system' | 'dark' | 'light'
/** Controls the left sidebar surface without changing terminal brightness. */
leftSidebarAppearanceMode: LeftSidebarAppearanceMode
leftSidebarTintColor?: string
leftSidebarTintOpacity?: number
uiLanguage: UiLanguage
appIcon: AppIconId
appFontFamily: string