From a0e334763921586d44e9bb23cf56fe187e11ad94 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:13:12 +0530 Subject: [PATCH 1/3] fix(electron): refine macOS sidebar and titlebar controls --- docs/electron-macos-shell.md | 13 ++ .../src/components/app-shell/app-shell.tsx | 10 +- .../src/components/app-shell/status-bar.tsx | 158 +++++++++++------- .../app-shell/system-notifications.test.tsx | 27 +++ .../app-shell/system-notifications.tsx | 39 +++-- .../app-shell/workspace-sidebar.tsx | 105 ++++++++---- electron/src/renderer/src/styles/globals.css | 3 + electron/tests/sidebar-layout-smoke.mjs | 125 +++++++++++++- 8 files changed, 375 insertions(+), 105 deletions(-) create mode 100644 docs/electron-macos-shell.md diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md new file mode 100644 index 00000000..656f3e31 --- /dev/null +++ b/docs/electron-macos-shell.md @@ -0,0 +1,13 @@ +# macOS desktop shell + +The expanded sidebar reserves space for the native traffic lights and app name. +The collapsed sidebar is 64 px wide, with its toggle below the traffic lights +and its right divider beginning below the 72 px header region. + +Notifications appear at the top right, with space reserved before the bell. +The notification menu opens downward and remains available while notification +data loads. Settings uses an icon in the macOS sidebar footer; Local device +sits beside it and opens the device and compute-target menu. The expanded +sidebar retains the Local device label. + +Windows and Linux retain their existing notification and device placement. diff --git a/electron/src/renderer/src/components/app-shell/app-shell.tsx b/electron/src/renderer/src/components/app-shell/app-shell.tsx index 8af65cac..a93d0934 100644 --- a/electron/src/renderer/src/components/app-shell/app-shell.tsx +++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx @@ -3,15 +3,23 @@ import { CommandPalette } from '@/components/command-palette'; import { Outlet, useRouterState } from '@tanstack/react-router'; import { BackendGate } from '../backend-gate'; import { RepairAgentDock } from './repair-agent-dock'; +import { isMac } from '../bridge'; +import { cn } from '@/lib/utils'; export function AppShell() { const pathname = useRouterState({ select: (state) => state.location.pathname, }); const settings = pathname.startsWith('/settings'); + const macWorkspace = isMac() && !settings; const SettingsWorkspace = pathname === '/settings/openapi' ? 'div' : 'main'; return ( -
+
{settings ? ( <> diff --git a/electron/src/renderer/src/components/app-shell/status-bar.tsx b/electron/src/renderer/src/components/app-shell/status-bar.tsx index ffee9b54..14db5513 100644 --- a/electron/src/renderer/src/components/app-shell/status-bar.tsx +++ b/electron/src/renderer/src/components/app-shell/status-bar.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, type ReactNode } from 'react'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useTranslationEngines } from '@/features/settings/translation-settings'; import { Link } from '@tanstack/react-router'; @@ -222,7 +222,15 @@ function EngineTip({ ); } -export function StatusBar({ compact = false }: { compact?: boolean }) { +export function StatusBar({ + compact = false, + inline = false, + footerLeading, +}: { + compact?: boolean; + inline?: boolean; + footerLeading?: ReactNode; +}) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const [deviceOpen, setDeviceOpen] = useState(false); @@ -661,72 +669,88 @@ export function StatusBar({ compact = false }: { compact?: boolean }) { : 'modelSettings.unavailable', }, ]; + const iconDevicePopover = ( + + + } + > + + {deviceContent} + + ); if (compact) { return ( -
- - - } - > - - {deviceContent} - +
+ {iconDevicePopover}
); } return (
-
- - + + + } + > + + {deviceContent} + + -
+ +
+ )}
)} + {footerLeading && ( +
+ {footerLeading} +
{iconDevicePopover}
+ +
+ )}
); diff --git a/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx b/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx index 33ba714b..378e7e8e 100644 --- a/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx +++ b/electron/src/renderer/src/components/app-shell/system-notifications.test.tsx @@ -46,6 +46,13 @@ describe('SystemNotifications desktop updates', () => { beforeEach(() => { mocks.navigate.mockReset(); mocks.listener = undefined; + mocks.state = { + status: 'available', + currentVersion: '0.5.2', + availableVersion: '0.5.3', + channel: 'stable', + progress: 0, + } as UpdateState; }); afterEach(cleanup); @@ -62,4 +69,24 @@ describe('SystemNotifications desktop updates', () => { fireEvent.click(await screen.findByText('common.open')); await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ to: '/settings/updates' })); }); + + test('opens while notifications are still loading', async () => { + mocks.state = { + status: 'idle', + currentVersion: '0.5.2', + channel: 'stable', + progress: 0, + } as UpdateState; + render( + + + , + ); + + const trigger = await screen.findByRole('button', { name: 'preferences.loading' }); + expect(trigger).toBeEnabled(); + expect(trigger).toHaveClass('app-no-drag'); + fireEvent.click(trigger); + expect(await screen.findByText('preferences.loading')).toBeVisible(); + }); }); diff --git a/electron/src/renderer/src/components/app-shell/system-notifications.tsx b/electron/src/renderer/src/components/app-shell/system-notifications.tsx index d186452b..72de9958 100644 --- a/electron/src/renderer/src/components/app-shell/system-notifications.tsx +++ b/electron/src/renderer/src/components/app-shell/system-notifications.tsx @@ -83,9 +83,11 @@ function LevelIcon({ level }: { level: SystemNotification['level'] }) { export function SystemNotifications({ enabled, compact = false, + titlebar = false, }: { enabled: boolean; compact?: boolean; + titlebar?: boolean; }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -120,24 +122,20 @@ export function SystemNotifications({ return { id: `desktop-update-${update.availableVersion}`, level: 'info', - title: t( - update.status === 'downloaded' ? 'update.ready' : 'update.available', - { version: update.availableVersion }, - ), + title: t(update.status === 'downloaded' ? 'update.ready' : 'update.available', { + version: update.availableVersion, + }), message: t('update.safety'), action: { type: 'navigate', target: '/settings/updates', label: t('common.open') }, persistent: true, }; }, [t, update]); - const visible = useMemo( - () => { - const backend = (query.data?.notifications ?? []).filter( - (note) => note.level === 'error' || !dismissed.includes(note.id), - ); - return updateNotification ? [updateNotification, ...backend] : backend; - }, - [dismissed, query.data?.notifications, updateNotification], - ); + const visible = useMemo(() => { + const backend = (query.data?.notifications ?? []).filter( + (note) => note.level === 'error' || !dismissed.includes(note.id), + ); + return updateNotification ? [updateNotification, ...backend] : backend; + }, [dismissed, query.data?.notifications, updateNotification]); const dismiss = (id: string) => { const next = [...dismissed.filter((item) => item !== id), id].slice(-50); @@ -186,9 +184,11 @@ export function SystemNotifications({ + +
- -
+ {!mac && } +
- + {mac && } + {!mac && }
)} @@ -137,7 +169,7 @@ export function WorkspaceSidebar() {
- -
- - - {t('nav.settings')} - - -
+ + + + ) : undefined + } + /> + {!mac && ( +
+ + + {t('nav.settings')} + + +
+ )}
)} diff --git a/electron/src/renderer/src/styles/globals.css b/electron/src/renderer/src/styles/globals.css index 708f7178..f1028ef5 100644 --- a/electron/src/renderer/src/styles/globals.css +++ b/electron/src/renderer/src/styles/globals.css @@ -175,6 +175,9 @@ .native-controls-right { padding-right: 148px; } +.macos-notification-safe-area main .workspace-titlebar { + padding-right: 3.5rem; +} /* Keep the Lucide family visually consistent across controls and pane headings. */ @layer base { diff --git a/electron/tests/sidebar-layout-smoke.mjs b/electron/tests/sidebar-layout-smoke.mjs index 558207fc..6d6868ad 100644 --- a/electron/tests/sidebar-layout-smoke.mjs +++ b/electron/tests/sidebar-layout-smoke.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -const browser = await chromium.launch({ channel: 'msedge', headless: true }); +const browser = await chromium.launch({ + ...(process.env.PLAYWRIGHT_BUNDLED === '1' ? {} : { channel: 'msedge' }), + headless: true, +}); const page = await browser.newPage(); const out = mkdtempSync(join(tmpdir(), 'voicestudio-sidebar-')); const ui = process.env.VOICESTUDIO_UI_URL || 'http://localhost:3912'; @@ -79,8 +82,126 @@ try { await compactMain.waitFor(); } } + const macPage = await browser.newPage(); + try { + await macPage.addInitScript(() => { + localStorage.setItem('voicestudio.setup.complete.v1', '1'); + Object.defineProperty(window, 'voicestudio', { + value: { + app: { + version: 'test', + platform: 'darwin', + isDev: true, + onNavigate: () => () => {}, + onPersistenceFlush: () => () => {}, + }, + repair: { + list: async () => [], + getState: async () => ({ + status: 'idle', + output: '', + workspaceAvailable: false, + }), + onEvent: () => () => {}, + }, + }, + }); + }); + await macPage.goto(ui + '/#/clone'); + const macSidebar = macPage.locator('aside').first(); + const macNotifications = macPage.locator('[data-slot=macos-system-notifications]'); + const macNotificationBounds = await macNotifications.boundingBox(); + assert.ok( + macNotificationBounds && + macNotificationBounds.y < 20 && + macNotificationBounds.x + macNotificationBounds.width >= + (await macPage.evaluate(() => window.innerWidth)) - 20, + 'macOS notifications must sit in the top-right titlebar corner', + ); + const titlebarActions = await macPage.locator('main .workspace-titlebar button').all(); + const titlebarActionBounds = ( + await Promise.all(titlebarActions.map((action) => action.boundingBox())) + ).filter(Boolean); + assert.ok( + macNotificationBounds && + titlebarActionBounds.every( + (bounds) => bounds.x + bounds.width <= macNotificationBounds.x - 12, + ), + 'macOS titlebar actions must leave space before notifications', + ); + await macNotifications.getByRole('button').first().click(); + await macPage.locator('[data-slot=popover-content][data-open]').waitFor({ state: 'visible' }); + await macPage.keyboard.press('Escape'); + const brandLink = macSidebar.getByRole('link', { name: 'VoiceStudio', exact: true }); + const brandBounds = await brandLink.boundingBox(); + assert.ok(brandBounds && brandBounds.x >= 96, 'macOS brand must clear the traffic lights'); + assert.ok( + await brandLink + .locator('span') + .evaluate((element) => element.scrollWidth <= element.clientWidth), + 'macOS titlebar must show the complete VoiceStudio wordmark', + ); + const expandedSettings = macSidebar.getByRole('link', { name: 'Settings', exact: true }); + const expandedDevice = macSidebar.getByRole('button', { name: /Local device/ }); + const expandedSettingsBounds = await expandedSettings.boundingBox(); + const expandedDeviceBounds = await expandedDevice.boundingBox(); + assert.equal((await expandedSettings.innerText()).trim(), ''); + assert.ok( + expandedSettingsBounds && + expandedDeviceBounds && + expandedDeviceBounds.x > expandedSettingsBounds.x, + 'expanded macOS Local device must sit right of icon-only Settings', + ); + await expandedDevice.click(); + await macPage.locator('[data-slot=popover-content][data-open]').waitFor({ state: 'visible' }); + await macPage.keyboard.press('Escape'); + await macSidebar.getByRole('button', { name: 'Close', exact: true }).click(); + const compactMacSidebar = macPage.locator('[data-slot=compact-main-sidebar]'); + await compactMacSidebar.waitFor(); + assert.equal(Math.round((await compactMacSidebar.boundingBox()).width), 64); + const compactDividerBounds = await compactMacSidebar + .locator('[data-slot=compact-sidebar-divider]') + .boundingBox(); + assert.ok( + compactDividerBounds && compactDividerBounds.y >= 72, + 'macOS compact-sidebar divider must begin below the titlebar', + ); + const compactToggleBounds = await compactMacSidebar + .getByRole('button', { name: 'Toggle Sidebar', exact: true }) + .boundingBox(); + assert.ok( + compactToggleBounds && compactToggleBounds.y >= 32, + 'macOS compact-sidebar toggle must sit below the traffic lights', + ); + const compactSettingsBounds = await compactMacSidebar + .getByRole('link', { name: 'Settings', exact: true }) + .boundingBox(); + const compactDeviceBounds = await compactMacSidebar + .getByRole('button', { name: /Local device/ }) + .boundingBox(); + assert.ok( + compactSettingsBounds && + compactDeviceBounds && + compactDeviceBounds.x > compactSettingsBounds.x && + Math.abs( + compactDeviceBounds.y + + compactDeviceBounds.height / 2 - + (compactSettingsBounds.y + compactSettingsBounds.height / 2), + ) <= 1, + `macOS Local device must sit to the right of Settings: ${JSON.stringify({ compactSettingsBounds, compactDeviceBounds })}`, + ); + const workspaceTitleBounds = await macPage + .getByRole('heading', { name: 'Voice cloning' }) + .boundingBox(); + assert.ok( + workspaceTitleBounds && workspaceTitleBounds.x >= 88, + 'macOS workspace title must clear the traffic lights', + ); + } finally { + await macPage.close(); + } console.log( - 'Sidebar compact/expanded, 9 destinations, 6 engine links, visible models, non-duplicated Profiles, settings visibility and navigation passed. ' + + 'Sidebar compact/expanded, macOS titlebar clearance, 9 destinations, 6 engine links, visible models, non-duplicated Profiles, settings visibility and navigation passed. ' + out, ); } finally { From 4c3766c0c979e68faf7abede5cd4b1119955b48d Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:13:39 +0530 Subject: [PATCH 2/3] docs: note macOS shell improvements in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..9a67a62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- macOS desktop sidebar clears the traffic lights, uses a narrower collapsed rail, and places notifications and device controls with more space (#2126) - Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032) - A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034) - An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026) From 332d09e1a04983e2e8dee386a39af7b2572fe8cd Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:25:03 +0530 Subject: [PATCH 3/3] fix(electron): register notification hit region after titlebar drag regions --- docs/electron-macos-shell.md | 6 ++ .../src/components/app-shell/app-shell.tsx | 13 ++++ .../app-shell/workspace-sidebar.tsx | 8 --- electron/tests/fixtures/native-bell-host.cjs | 11 +++ electron/tests/fixtures/native-click.swift | 7 ++ electron/tests/native-bell-repro.mjs | 67 +++++++++++++++++++ 6 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 electron/tests/fixtures/native-bell-host.cjs create mode 100644 electron/tests/fixtures/native-click.swift create mode 100644 electron/tests/native-bell-repro.mjs diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md index 656f3e31..2b754a3a 100644 --- a/docs/electron-macos-shell.md +++ b/docs/electron-macos-shell.md @@ -11,3 +11,9 @@ sits beside it and opens the device and compute-target menu. The expanded sidebar retains the Local device label. Windows and Linux retain their existing notification and device placement. + +The notification control follows workspace headers in document order so their +native drag regions cannot consume its mouse clicks. On macOS, run +`node tests/native-bell-repro.mjs` from `electron/` against the dev renderer +to verify a real system mouse click (requires Swift and Accessibility access). +Browser automation alone bypasses native titlebar hit testing. diff --git a/electron/src/renderer/src/components/app-shell/app-shell.tsx b/electron/src/renderer/src/components/app-shell/app-shell.tsx index a93d0934..1d1b676a 100644 --- a/electron/src/renderer/src/components/app-shell/app-shell.tsx +++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx @@ -5,8 +5,11 @@ import { BackendGate } from '../backend-gate'; import { RepairAgentDock } from './repair-agent-dock'; import { isMac } from '../bridge'; import { cn } from '@/lib/utils'; +import { useBackendStatus } from '@/hooks/use-backend-status'; +import { SystemNotifications } from './system-notifications'; export function AppShell() { + const backend = useBackendStatus(); const pathname = useRouterState({ select: (state) => state.location.pathname, }); @@ -47,6 +50,16 @@ export function AppShell() { )}
+ {/* Native drag-region hit testing follows document order. Keep this + no-drag control after every workspace titlebar, outside BackendGate. */} + {macWorkspace && ( +
+ +
+ )}
); } diff --git a/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx b/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx index d2c33637..63f12bf4 100644 --- a/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx +++ b/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx @@ -85,14 +85,6 @@ export function WorkspaceSidebar() { }); return ( <> - {mac && ( -
- -
- )} {compact && (