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
5 changes: 2 additions & 3 deletions .storybook/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Settings } from '@/shared/settings'
import { DEFAULT_SETTINGS, type Settings } from '@/shared/settings'
import {
repositoryId,
semanticVersion,
Expand Down Expand Up @@ -330,8 +330,7 @@ export const storySyncResult: SyncExecuteResult = {
}

export const storySettings: Settings = {
defaultSkillTab: 'files',
preferredTerminal: 'terminal',
...DEFAULT_SETTINGS,
hiddenAgentIds: ['cursor'],
}

Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"ts-pattern": "^5.9.0",
"zod": "^4.4.3"
},
Expand All @@ -72,8 +72,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/browser-playwright": "^4.1.6",
"@vitest/coverage-v8": "^4.1.6",
"code-inspector-plugin": "^1.5.1",
"culori": "^4.0.2",
"electron": "^42.0.1",
Expand All @@ -84,16 +84,16 @@
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"fallow": "2.69.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.3",
"lint-staged": "^17.0.4",
"npm-run-all2": "^8.0.4",
"playwright": "^1.59.1",
"prettier": "^3.8.3",
"sharp": "^0.34.5",
"storybook": "10.3.6",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.11",
"vitest": "^4.1.5",
"vite": "^8.0.12",
"vitest": "^4.1.6",
"vitest-browser-react": "^2.2.0"
},
"lint-staged": {
Expand Down
406 changes: 203 additions & 203 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { attachExternalLinkHandler } from './utils/attachExternalLinkHandler'
import { clampSizeToWorkArea } from './utils/clampSizeToWorkArea'
import { isE2EBackgroundLaunch } from './utils/e2eEnv'
import { getSecureWebPreferences } from './utils/secureWebPreferences'
import {
applyWindowBackgroundBlur,
MAIN_WINDOW_OPAQUE_BACKGROUND,
} from './utils/windowBackgroundBlur'

process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
Expand Down Expand Up @@ -53,13 +57,14 @@ const DEFAULT_LAUNCH_WIDTH = 1200
const DEFAULT_LAUNCH_HEIGHT = 800

function createWindow(): void {
const settings = getSettings()
// Resolve the launch size from settings. `undefined` means the user has
// not chosen one — fall back to the default size + `maximize()` on
// ready-to-show. When set, we clamp to the current display's work area
// so a size saved on a wider monitor doesn't open off-screen on a smaller
// one (clamping happens *before* the BrowserWindow is constructed because
// Electron applies `width`/`height` literally — there's no built-in clamp).
const persistedWindowSize = getSettings().windowSize
const persistedWindowSize = settings.windowSize
const primaryWorkArea = screen.getPrimaryDisplay().workAreaSize
const hasCustomSize = persistedWindowSize !== undefined
const launchSize = hasCustomSize
Expand All @@ -80,7 +85,10 @@ function createWindow(): void {
minWidth: 800,
minHeight: 600,
show: false,
backgroundColor: '#0A0F1C',
backgroundColor: MAIN_WINDOW_OPAQUE_BACKGROUND,
// Required for the renderer root and Electron contentView alpha channel
// to reveal the native background blur when the Appearance slider is on.
transparent: true,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
webPreferences: {
Expand All @@ -90,6 +98,7 @@ function createWindow(): void {
webviewTag: true,
},
})
applyWindowBackgroundBlur(window, settings.windowBackgroundBlurRadius)
setMainWindow(window)

window.on('closed', () => {
Expand Down
36 changes: 36 additions & 0 deletions src/main/ipc/ipc-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'

import {
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
} from '@/shared/settings'

import { IPC_ARG_SCHEMAS } from './ipc-schemas'

/**
Expand Down Expand Up @@ -107,6 +112,12 @@ describe('settings:set lockstep with SettingsSchema', () => {
)
})

it('accepts the bounded windowBackgroundBlurRadius field', () => {
expect(schema.safeParse([{ windowBackgroundBlurRadius: 24 }]).success).toBe(
true,
)
})

it('rejects an unknown enum value for preferredTerminal', () => {
expect(
schema.safeParse([{ preferredTerminal: 'fish-shell' }]).success,
Expand All @@ -119,6 +130,26 @@ describe('settings:set lockstep with SettingsSchema', () => {
).toBe(false)
})

it('rejects an invalid windowBackgroundBlurRadius value', () => {
expect(
schema.safeParse([
{
windowBackgroundBlurRadius: WINDOW_BACKGROUND_BLUR_MIN_RADIUS - 1,
},
]).success,
).toBe(false)
expect(
schema.safeParse([{ windowBackgroundBlurRadius: 24.5 }]).success,
).toBe(false)
expect(
schema.safeParse([
{
windowBackgroundBlurRadius: WINDOW_BACKGROUND_BLUR_MAX_RADIUS + 1,
},
]).success,
).toBe(false)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('rejects unknown extra keys (.strict())', () => {
expect(
schema.safeParse([{ defaultSkillTab: 'files', somethingElse: 'x' }])
Expand All @@ -138,6 +169,11 @@ describe('settings:set lockstep with SettingsSchema', () => {
expect('hiddenAgentIds' in parsed[0]).toBe(false)
})

it('parsing a partial without windowBackgroundBlurRadius does NOT inject a default', () => {
const parsed = schema.parse([{ defaultSkillTab: 'info' }]) as [object]
expect('windowBackgroundBlurRadius' in parsed[0]).toBe(false)
})

it('accepts an explicit hiddenAgentIds array on settings:set', () => {
expect(
schema.safeParse([{ hiddenAgentIds: ['claude-code'] }]).success,
Expand Down
9 changes: 8 additions & 1 deletion src/main/ipc/ipc-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { z } from 'zod'

import { AGENT_IDS, TERMINAL_APP_IDS } from '@/shared/constants'
import type { IpcInvokeChannel } from '@/shared/ipc-contract'
import { SettingsSchema } from '@/shared/settings'
import {
SettingsSchema,
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA,
} from '@/shared/settings'

/**
* Zod schemas for runtime validation of IPC invoke arguments.
Expand Down Expand Up @@ -281,6 +284,10 @@ export const IPC_ARG_SCHEMAS: Partial<Record<IpcInvokeChannel, z.ZodTuple>> = {
// the {min,int} constraints can never drift. `undefined` is how
// the Settings UI clears the persisted size back to "use default".
windowSize: SettingsSchema.shape.windowSize,
// Electron 42 blur radius. Use the shared non-defaulting schema so
// unrelated partial settings writes do not reset blur to zero.
windowBackgroundBlurRadius:
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA.optional(),
// Strict z.enum here — renderers should only ever emit valid ids.
// Intentionally NOT chained off `SettingsSchema.shape.hiddenAgentIds`:
// that field carries a `.default([])` for forgiving disk reads, and
Expand Down
12 changes: 12 additions & 0 deletions src/main/ipc/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getMainWindow } from '@/main/services/mainWindowState'
import { getSettings, saveSettings } from '@/main/services/settings'
import { createOrFocusSettingsWindow } from '@/main/services/settingsWindow'
import { applyWindowBackgroundBlur } from '@/main/utils/windowBackgroundBlur'
import { IPC_CHANNELS } from '@/shared/ipc-channels'

import { typedHandle } from './typedHandle'
Expand Down Expand Up @@ -35,6 +37,16 @@ export function registerSettingsHandlers(): void {
// broadcast in that case so we don't fan out a no-op `settings:changed`
// and trigger a redundant Redux replace in every open window.
if (next !== before) {
if (
next.windowBackgroundBlurRadius !== before.windowBackgroundBlurRadius
) {
const mainWindow = getMainWindow()
// Settings can outlive the main window on macOS; a closed main window
// simply receives the persisted blur the next time it is created.
if (mainWindow !== null) {
applyWindowBackgroundBlur(mainWindow, next.windowBackgroundBlurRadius)
}
}
broadcastTypedEvent(IPC_CHANNELS.SETTINGS_CHANGED, next)
}
return next
Expand Down
1 change: 1 addition & 0 deletions src/main/services/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('areSettingsEqual', () => {
const baseSettings: Settings = {
defaultSkillTab: 'files',
preferredTerminal: 'terminal',
windowBackgroundBlurRadius: 0,
hiddenAgentIds: [],
}

Expand Down
115 changes: 115 additions & 0 deletions src/main/utils/windowBackgroundBlur.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { BrowserWindow } from 'electron'
import { describe, expect, it, vi } from 'vitest'

import { WINDOW_BACKGROUND_BLUR_MAX_RADIUS } from '@/shared/settings'

import {
applyWindowBackgroundBlur,
MAIN_WINDOW_BLURRED_BACKGROUND,
getMainWindowBackgroundColor,
MAIN_WINDOW_OPAQUE_BACKGROUND,
normalizeWindowBackgroundBlurRadius,
} from './windowBackgroundBlur'

/**
* Build the minimal BrowserWindow/contentView surface the blur mutator uses.
* @param supportsBlur - Whether the mocked contentView exposes Electron 42 blur.
* @param supportsViewBackgroundColor - Whether contentView background updates exist.
* @returns Mock window plus spies for assertions.
* @example
* const { window } = makeWindowMock(true)
* applyWindowBackgroundBlur(window, 12)
*/
function makeWindowMock(
supportsBlur: boolean,
supportsViewBackgroundColor = true,
) {
const contentView = {
...(supportsViewBackgroundColor ? { setBackgroundColor: vi.fn() } : {}),
...(supportsBlur ? { setBackgroundBlur: vi.fn() } : {}),
}
const window = {
setBackgroundColor: vi.fn(),
contentView,
} as unknown as BrowserWindow
return { window, contentView }
}

/**
* Pure helpers around Electron 42 background blur. The BrowserWindow mutator
* itself is covered by integration/e2e; these tests pin the clamping and alpha
* color contract before values reach Electron.
*/
describe('windowBackgroundBlur helpers', () => {
it('clamps persisted blur radius values to the supported range', () => {
expect(normalizeWindowBackgroundBlurRadius(-12)).toBe(0)
expect(normalizeWindowBackgroundBlurRadius(12.9)).toBe(12)
expect(normalizeWindowBackgroundBlurRadius(99)).toBe(
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
)
})

it('uses an opaque background when blur is disabled', () => {
expect(getMainWindowBackgroundColor(0)).toBe(MAIN_WINDOW_OPAQUE_BACKGROUND)
})

it('uses an alpha background when blur is enabled', () => {
expect(getMainWindowBackgroundColor(12)).toBe(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
})

it('applies opaque color and zero blur when the radius is disabled', () => {
const { window, contentView } = makeWindowMock(true)

applyWindowBackgroundBlur(window, 0)

expect(window.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_OPAQUE_BACKGROUND,
)
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_OPAQUE_BACKGROUND,
)
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(0)
})

it('applies alpha color and clamped blur when Electron 42 blur is available', () => {
const { window, contentView } = makeWindowMock(true)

applyWindowBackgroundBlur(window, WINDOW_BACKGROUND_BLUR_MAX_RADIUS + 1)

expect(window.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
)
})

it('skips blur safely when Electron exposes no setBackgroundBlur method', () => {
const { window, contentView } = makeWindowMock(false)

expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
expect(window.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
expect('setBackgroundBlur' in contentView).toBe(false)
})

it('skips contentView background safely when the method is absent', () => {
const { window, contentView } = makeWindowMock(true, false)

expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
expect(window.setBackgroundColor).toHaveBeenCalledWith(
MAIN_WINDOW_BLURRED_BACKGROUND,
)
expect('setBackgroundColor' in contentView).toBe(false)
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(12)
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading