Skip to content
Merged
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 docs/architecture/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ boundaries. Individual panels and hooks are catalogued only enough to locate the
- `src/renderer/index.tsx` — process entry: imports `monaco-setup`, mounts `<App/>` in `React.StrictMode` via `createRoot`, and pulls in dockview + theme CSS (`index.tsx:14`).
- `src/renderer/App.tsx` — the single stateful container. Composes ~30 hooks into one `DockAppState` object and renders `<AppShell/>` + `<QuickOpen/>` (`App.tsx:42`, `:256`).
- `src/renderer/AppShell.tsx` — presentational shell: title bar, the activity-bar icon rail + `DockviewReact` host (side by side in `.layout-workbench`), status bar, and all modals/overlays/toasts (`AppShell.tsx:80`).
- `src/renderer/components/TitleBar.tsx` — the window title bar. Carries the theme controls at its trailing edge: a family `<select>` and a light/dark toggle, rendered on every shell branch including the pre-setup and no-project screens (`AppShell.tsx:123`). The family list is derived from the theme registry via `getThemeFamilies()` rather than hardcoded, so it cannot outlive the themes it names — an earlier hardcoded copy kept offering Royal after that family was retired. Switching family preserves the current variant; the toggle flips the variant within the family (`App.tsx:186`).
- `src/renderer/DockTab.tsx` — `DockTab` (the per-panel tab header) and `EmptyWatermark` (the empty-group drop hint).
- `src/renderer/monaco-setup.ts` — wires `MonacoEnvironment.getWorker` to the per-language Vite `?worker` bundles and calls `loader.config({ monaco })`.
- `src/renderer/components/` — all UI by surface: `editor/`, `terminal/`, `sidebar/`, `git/`, `search/`, `modals/`, `memory/`, `new-task/`, `plugin-ui/`.
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useSidebarHandleCycle } from './hooks/dock-layout/useSidebarHandleCycle
import { useAgentSiblingDockTabs } from './hooks/agent-session/useAgentSiblingDockTabs'
import { useAppEffects } from './hooks/app/useAppEffects'
import { useCommands } from './hooks/app/useCommands'
import { themeFamilyOf } from '../shared/themes/registry'
import { cycleAgent } from './commands/agent-cycle'
import type { CommandContext } from './commands/command-handlers'
import type { DockPanelId } from './hooks/dock-layout/useDockLayout'
Expand Down Expand Up @@ -189,6 +190,13 @@ export function App(): React.JSX.Element {
: themeId.replace(/-dark$/, '-light')
void updateSettings({ theme: nextId })
}, [themeId, updateSettings])
// Switching family keeps the current light/dark variant, so the title bar's two
// controls stay independent of each other.
const themeFamily = themeFamilyOf(themeId)
const selectThemeFamily = useCallback((family: string) => {
const suffix = themeId.endsWith('-light') ? '-light' : '-dark'
void updateSettings({ theme: `${family}${suffix}` })
}, [themeId, updateSettings])
const updateNotification = useUpdateNotification()
const updateLog = useUpdateLog()
// Embedded agents are themed at launch, so a light↔dark switch only applies
Expand Down Expand Up @@ -515,6 +523,9 @@ export function App(): React.JSX.Element {
sidebarView={sidebarView}
onSelectSidebarView={setSidebarView}
onRenameActiveProject={(name) => { if (activeProjectId) void updateProject(activeProjectId, { name }) }}
onToggleTheme={toggleTheme}
themeFamily={themeFamily}
onSelectThemeFamily={selectThemeFamily}
/>
<QuickOpen
visible={quickOpenVisible && effectiveSessionId !== null}
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const project = {
function makeProps(overrides: Partial<AppShellProps> = {}): AppShellProps {
return {
themeClass: 'theme-dark',
onToggleTheme: vi.fn(),
themeFamily: 'manifold',
onSelectThemeFamily: vi.fn(),
settings: { setupCompleted: true } as AppShellProps['settings'],
projects: [project],
projectError: null,
Expand Down
16 changes: 13 additions & 3 deletions src/renderer/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,25 @@ export interface AppShellProps {
sidebarView: SidebarViewId
onSelectSidebarView: (id: SidebarViewId) => void
onRenameActiveProject: (name: string) => void
onToggleTheme: () => void
/** A theme id without its variant suffix, e.g. `jade`. */
themeFamily: string
onSelectThemeFamily: (family: string) => void
runCommand: (id: string) => void
}

export function AppShell(p: AppShellProps): React.JSX.Element {
useLoadPluginContributions()
const themeControls = {
themeType: (p.themeClass === 'theme-light' ? 'light' : 'dark') as 'dark' | 'light',
onToggleTheme: p.onToggleTheme,
themeFamily: p.themeFamily,
onSelectThemeFamily: p.onSelectThemeFamily,
}
if (!p.settings.setupCompleted) {
return (
<div className={`layout-root ${p.themeClass}`}>
<TitleBar />
<TitleBar {...themeControls} />
<WelcomeDialog onAddProject={() => void p.addProject()} onCloneProject={p.cloneProject} onComplete={p.overlays.handleSetupComplete} />
</div>
)
Expand All @@ -128,7 +138,7 @@ export function AppShell(p: AppShellProps): React.JSX.Element {
if (p.projects.length === 0) {
return (
<div className={`layout-root ${p.themeClass}`}>
<TitleBar />
<TitleBar {...themeControls} />
<OnboardingView variant="no-project" onAddProject={() => void p.handleAddProjectFromOnboarding()} onCloneProject={p.handleCloneFromOnboarding}
onCreateNewProject={p.handleCreateNewProject} creatingProject={p.appEffects.creatingProject}
cloningProject={p.appEffects.cloningProject} createError={p.projectError} />
Expand All @@ -141,7 +151,7 @@ export function AppShell(p: AppShellProps): React.JSX.Element {

return (
<div className={`layout-root ${p.themeClass}`}>
<TitleBar projectName={activeProjectName} />
<TitleBar projectName={activeProjectName} {...themeControls} />
<div className="layout-main">
<div className="layout-workbench">
<ActivityBar
Expand Down
64 changes: 64 additions & 0 deletions src/renderer/components/TitleBar.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,68 @@ export const titleBarStyles: Record<string, React.CSSProperties> = {
fontWeight: 500,
color: 'var(--text-muted)',
},
/** Theme controls sit at the window's trailing edge, past the title. */
rightGroup: {
marginLeft: 'auto',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 2,
},
themesGroup: {
// @ts-expect-error -- Electron-specific CSS property; opt out of window drag
WebkitAppRegion: 'no-drag',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
},
themesSelect: {
height: 26,
padding: '0 26px 0 10px',
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
fontSize: 'var(--type-ui-caption)',
fontWeight: 600,
letterSpacing: 'var(--tracking-normal)',
color: 'var(--text-muted)',
cursor: 'pointer',
whiteSpace: 'nowrap',
appearance: 'none',
WebkitAppearance: 'none',
// Inline chevron: a native select arrow ignores the theme tokens, so the
// caret is drawn with currentColor and inherits the hover promotion below.
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>\")",
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 9px center',
outline: 'none',
transition: 'background-color 150ms ease, color 150ms ease, border-color 150ms ease',
},
themesSelectHover: {
backgroundColor: 'var(--list-hover-bg)',
color: 'var(--text-primary)',
},
themeToggle: {
// @ts-expect-error -- Electron-specific CSS property; opt out of window drag
WebkitAppRegion: 'no-drag',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 26,
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
fontSize: 13,
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'background 150ms ease, color 150ms ease',
},
themeToggleHover: {
background: 'var(--list-hover-bg)',
color: 'var(--text-primary)',
},
}
59 changes: 55 additions & 4 deletions src/renderer/components/TitleBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
import { TitleBar } from './TitleBar'

const wired = {
themeType: 'dark' as const,
onToggleTheme: vi.fn(),
themeFamily: 'manifold',
onSelectThemeFamily: vi.fn(),
}

describe('TitleBar', () => {
it('shows "Manifold" when no project is active', () => {
render(<TitleBar />)
Expand All @@ -15,10 +22,54 @@ describe('TitleBar', () => {
expect(screen.queryByText('Manifold')).not.toBeInTheDocument()
})

it('carries no controls — search lives in the activity rail, themes in Settings and the command palette', () => {
it('omits the theme controls when no handlers are wired', () => {
render(<TitleBar projectName="Alpha" />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByLabelText('Theme')).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})

it('keeps search out of the title bar — it lives in the activity rail', () => {
render(<TitleBar projectName="Alpha" {...wired} />)
expect(screen.queryByLabelText('Search files, code and memory')).toBeNull()
})

describe('theme controls', () => {
it('selects a theme family', () => {
const onSelectThemeFamily = vi.fn()
render(<TitleBar projectName="Alpha" {...wired} onSelectThemeFamily={onSelectThemeFamily} />)

const select = screen.getByLabelText('Theme') as HTMLSelectElement
expect(select.value).toBe('manifold')

fireEvent.change(select, { target: { value: 'jade' } })
expect(onSelectThemeFamily).toHaveBeenCalledWith('jade')
})

// The family list is derived from the theme registry rather than hardcoded. The
// previous hardcoded list is exactly what went stale — it kept offering Royal
// after that family was retired.
it('lists the shipped families and nothing retired', () => {
render(<TitleBar projectName="Alpha" {...wired} />)
const options = [...screen.getByLabelText('Theme').querySelectorAll('option')].map((o) => o.textContent)

expect(options).toContain('Manifold')
expect(options).toContain('Jade')
expect(options).not.toContain('Royal')
// One entry per family, not one per dark/light theme.
expect(new Set(options).size).toBe(options.length)
})

it('offers the opposite variant and toggles to it', () => {
const onToggleTheme = vi.fn()
const { rerender } = render(
<TitleBar projectName="Alpha" {...wired} onToggleTheme={onToggleTheme} />,
)

fireEvent.click(screen.getByRole('button', { name: 'Switch to Light theme' }))
expect(onToggleTheme).toHaveBeenCalled()

rerender(<TitleBar projectName="Alpha" {...wired} themeType="light" onToggleTheme={onToggleTheme} />)
expect(screen.getByRole('button', { name: 'Switch to Dark theme' })).toBeInTheDocument()
})
})
})
52 changes: 50 additions & 2 deletions src/renderer/components/TitleBar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,65 @@
import React from 'react'
import React, { useState } from 'react'
import { titleBarStyles as styles } from './TitleBar.styles'
import { getThemeFamilies } from '../../shared/themes/registry'

interface TitleBarProps {
projectName?: string
themeType?: 'dark' | 'light'
onToggleTheme?: () => void
/** A theme id without its variant suffix, e.g. `jade`. */
themeFamily?: string
onSelectThemeFamily?: (family: string) => void
}

export function TitleBar({ projectName }: TitleBarProps): React.JSX.Element {
export function TitleBar({
projectName,
themeType,
onToggleTheme,
themeFamily,
onSelectThemeFamily,
}: TitleBarProps): React.JSX.Element {
const [themeHovered, setThemeHovered] = useState(false)
const [themesHovered, setThemesHovered] = useState(false)

return (
<div style={styles.container}>
<div style={styles.trafficLightSpacer} />
<div style={styles.titleArea}>
{!projectName && <span style={styles.title}>Manifold</span>}
</div>
<div style={styles.rightGroup}>
{themeFamily && onSelectThemeFamily && (
<label style={styles.themesGroup}>
<select
value={themeFamily}
onChange={(e) => onSelectThemeFamily(e.target.value)}
onMouseEnter={() => setThemesHovered(true)}
onMouseLeave={() => setThemesHovered(false)}
style={{ ...styles.themesSelect, ...(themesHovered ? styles.themesSelectHover : undefined) }}
aria-label="Theme"
>
{getThemeFamilies().map((family) => (
<option key={family.id} value={family.id}>
{family.label}
</option>
))}
</select>
</label>
)}
{onToggleTheme && (
<button
type="button"
onClick={onToggleTheme}
onMouseEnter={() => setThemeHovered(true)}
onMouseLeave={() => setThemeHovered(false)}
style={{ ...styles.themeToggle, ...(themeHovered ? styles.themeToggleHover : undefined) }}
title={themeType === 'dark' ? 'Switch to Light theme' : 'Switch to Dark theme'}
aria-label={themeType === 'dark' ? 'Switch to Light theme' : 'Switch to Dark theme'}
>
{themeType === 'dark' ? '☀' : '☾'}
</button>
)}
</div>
</div>
)
}
35 changes: 34 additions & 1 deletion src/shared/themes/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getThemeList, loadTheme, migrateLegacyTheme } from './registry'
import { getThemeList, loadTheme, migrateLegacyTheme, getThemeFamilies, themeFamilyOf } from './registry'
import { DEFAULT_SETTINGS } from '../defaults'

describe('first-run default theme', () => {
Expand Down Expand Up @@ -38,6 +38,39 @@ describe('retired Royal ids', () => {
})
})

describe('theme families', () => {
it('strips the variant suffix', () => {
expect(themeFamilyOf('jade-light')).toBe('jade')
expect(themeFamilyOf('manifold-dark')).toBe('manifold')
// Family ids are already suffix-free and must survive a round trip.
expect(themeFamilyOf('jade')).toBe('jade')
})

it('collapses each dark/light pair into one entry', () => {
const families = getThemeFamilies()
const ids = families.map((f) => f.id)

expect(ids).toEqual(['manifold', 'garfield', 'neon', 'jade', 'platinum'])
expect(families.map((f) => f.label)).toEqual(['Manifold', 'Garfield', 'Neon', 'Jade', 'Platinum'])
expect(ids).not.toContain('royal')
})

// The list is derived so it cannot outlive the themes it names — the previous
// hardcoded copy kept offering Royal after that family was retired.
it('stays in step with the shipped theme list', () => {
const fromThemes = new Set(getThemeList().map((t) => themeFamilyOf(t.id)))
expect(new Set(getThemeFamilies().map((f) => f.id))).toEqual(fromThemes)
})

it('names a real theme when a family is combined with either variant', () => {
const ids = getThemeList().map((t) => t.id)
for (const family of getThemeFamilies()) {
expect(ids).toContain(`${family.id}-dark`)
expect(ids).toContain(`${family.id}-light`)
}
})
})

describe('custom themes', () => {
it.each([
'manifold-dark',
Expand Down
24 changes: 24 additions & 0 deletions src/shared/themes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ export function getThemeList(): ThemeMeta[] {
return cachedList
}

/** A theme id with its variant suffix removed: `jade-light` → `jade`. */
export function themeFamilyOf(themeId: string): string {
return themeId.replace(/-(dark|light)$/, '')
}

/**
* The shipped theme families, in registration order. Derived from the theme list
* rather than hardcoded: the title bar's previous hardcoded list kept offering
* Royal after that family was retired, which is exactly the drift this avoids.
*/
export function getThemeFamilies(): ThemeMeta[] {
const families: ThemeMeta[] = []
const seen = new Set<string>()

for (const { id, label, type } of getThemeList()) {
const familyId = themeFamilyOf(id)
if (seen.has(familyId)) continue
seen.add(familyId)
families.push({ id: familyId, label: label.replace(/ (Dark|Light)$/, ''), type })
}

return families
}

export function loadTheme(id: string): ConvertedTheme {
const cached = themeCache.get(id)
if (cached) return cached
Expand Down