diff --git a/CHANGELOG.md b/CHANGELOG.md index 822bbe15..4ae28dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ the frozen-backend fallback mirror it for their toolchains. - Tauri and Electron now share native dictation, watch-folder, and Wayland shortcut contracts; focused paste stays ordered and first-run uv stays pinned at 0.12.13 (#2122) - Dubbing demos synchronize playheads without simultaneous playback and let you open a sample in the editor (#2131) +- macOS desktop sidebar clears the traffic lights, uses a narrower collapsed rail, and places notifications and device controls with more space (#2126) - Dubbing timelines keep short segments proportional, support zoom, and remove timestamp-confirmed duplicate ASR context (#2129) - Dubbing translation shares the agent footer with live logs, validated output, cancellation and contextual retries (#2129) diff --git a/docs/electron-macos-shell.md b/docs/electron-macos-shell.md new file mode 100644 index 00000000..2b754a3a --- /dev/null +++ b/docs/electron-macos-shell.md @@ -0,0 +1,19 @@ +# 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. + +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 e5f5106c..e607d7b4 100644 --- a/electron/src/renderer/src/components/app-shell/app-shell.tsx +++ b/electron/src/renderer/src/components/app-shell/app-shell.tsx @@ -4,15 +4,26 @@ 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'; +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, }); const settings = pathname.startsWith('/settings'); + const macWorkspace = isMac() && !settings; const SettingsWorkspace = pathname === '/settings/openapi' ? 'div' : 'main'; return ( -
+
{settings ? ( <> @@ -41,6 +52,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/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 ( -
); 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..fb96640a 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); @@ -178,7 +176,7 @@ export function SystemNotifications({ .map((note) => note.title || note.message) .filter(Boolean) .join('. ') || - t(query.isError ? 'common.error' : query.isPending ? 'preferences.loading' : 'logs.all_clear'); + t(!enabled ? 'modelSettings.unavailable' : query.isError ? 'common.error' : query.isPending ? 'preferences.loading' : 'logs.all_clear'); return ( } > @@ -206,11 +206,19 @@ export function SystemNotifications({ )} - {!query.isPending && !query.isError && visible.length === 0 && ( + {!enabled && visible.length === 0 && ( +

{t('modelSettings.unavailable')}

+ )} + {enabled && query.isPending && visible.length === 0 && ( +

+ {t('preferences.loading')} +

+ )} + {enabled && !query.isPending && !query.isError && visible.length === 0 && (

{t('logs.all_clear')}

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 1625fe54..6fdc705f 100644 --- a/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx +++ b/electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx @@ -1,31 +1,84 @@ -import { Link } from '@tanstack/react-router'; +import { Link, useRouterState } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { PanelLeftIcon, PanelLeftOpenIcon, SettingsIcon } from 'lucide-react'; import { brandIcon, brandArtwork } from '@/lib/brand'; -import { getBridge, isMac } from '@/components/bridge'; +import { isMac } from '@/components/bridge'; import { cn } from '@/lib/utils'; import { Button, buttonVariants } from '@/components/ui/button'; import { usePaneResize } from '@/hooks/use-pane-resize'; -import { useWorkspace } from '@/lib/store/workspace'; +import { setWorkspace, useWorkspace } from '@/lib/store/workspace'; import { VoicesSidebar } from '@/features/clone/voices-sidebar'; import { WorkspaceNavigation } from './workspace-menu'; import { StatusBar } from './status-bar'; import { SystemNotifications } from './system-notifications'; import { useBackendStatus } from '@/hooks/use-backend-status'; -import { useWorkspaceSidebarState } from './use-workspace-sidebar'; +import { useState, useSyncExternalStore } from 'react'; + +const SECONDARY_ROUTES = new Set([ + '/stories', + '/audiobook', + '/tools', + '/batch', + '/gallery', + '/personas', + '/projects', + '/dub', + '/design', + '/transcriptions', +]); +// A local-controls pane needs enough room for the actual workspace. At the +// default desktop window, preserve navigation as a rail and restore the full +// voice library automatically once both it and a local-controls pane leave a +// useful editing canvas. Browser zoom and Windows display scaling are included +// in the CSS viewport width, so this threshold also covers high-DPI layouts. +const COMPACT_QUERY = '(max-width: 1680px)'; + +function routeHasSecondarySidebar(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/'; + return [...SECONDARY_ROUTES].some( + (route) => normalized === route || normalized.startsWith(`${route}/`), + ); +} + +function routeOwnsVoiceLibrary(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/'; + return normalized === '/personas' || normalized.startsWith('/personas/'); +} + +function useCompactViewport(): boolean { + return useSyncExternalStore( + (notify) => { + const query = window.matchMedia(COMPACT_QUERY); + query.addEventListener('change', notify); + return () => query.removeEventListener('change', notify); + }, + () => window.matchMedia(COMPACT_QUERY).matches, + () => false, + ); +} export function WorkspaceSidebar() { const backend = useBackendStatus(); - const showCompactBrand = ['win32', 'linux'].includes(getBridge()?.app.platform ?? ''); const { t } = useTranslation(); + const mac = isMac(); const { libraryOpen, libraryTab } = useWorkspace(); - const { compact, compactViewport, forceExpanded, secondaryWorkspace, setOpen } = - useWorkspaceSidebarState(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const compactViewport = useCompactViewport(); + const compactContext = `${pathname}:${compactViewport}`; + const [expandedContext, setExpandedContext] = useState(null); + const forceExpanded = expandedContext === compactContext; + const ownsVoiceLibrary = routeOwnsVoiceLibrary(pathname); + const compact = + !libraryOpen || + ((ownsVoiceLibrary || (compactViewport && routeHasSecondarySidebar(pathname))) && + !forceExpanded); + const secondaryWorkspace = routeHasSecondarySidebar(pathname); + const setLibraryOpen = (libraryOpen: boolean) => setWorkspace({ libraryOpen }); const sidebarResize = usePaneResize({ storageKey: 'voicestudio.library-width', side: 'left', - minimum: 220, - initial: 256, + minimum: mac ? 288 : 220, + initial: mac ? 288 : 256, maximum: 360, reserve: compactViewport && secondaryWorkspace && forceExpanded ? 520 : 640, enabled: libraryOpen, @@ -36,43 +89,61 @@ export function WorkspaceSidebar() { )} @@ -92,7 +163,7 @@ export function WorkspaceSidebar() {
{ - setOpen(false); + setExpandedContext(null); + setLibraryOpen(false); }} > @@ -122,14 +194,29 @@ 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 46db0545..888589c0 100644 --- a/electron/src/renderer/src/styles/globals.css +++ b/electron/src/renderer/src/styles/globals.css @@ -176,6 +176,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/fixtures/native-bell-host.cjs b/electron/tests/fixtures/native-bell-host.cjs new file mode 100644 index 00000000..fb40379c --- /dev/null +++ b/electron/tests/fixtures/native-bell-host.cjs @@ -0,0 +1,11 @@ +const { app, BrowserWindow } = require('electron'); +app.whenReady().then(() => { + const window = new BrowserWindow({ + width: 1100, + height: 760, + x: 60, + y: 60, + titleBarStyle: 'hidden', + }); + window.loadURL('about:blank'); +}); diff --git a/electron/tests/fixtures/native-click.swift b/electron/tests/fixtures/native-click.swift new file mode 100644 index 00000000..702ad505 --- /dev/null +++ b/electron/tests/fixtures/native-click.swift @@ -0,0 +1,7 @@ +import CoreGraphics +import Foundation + +let point = CGPoint(x: Double(CommandLine.arguments[1])!, y: Double(CommandLine.arguments[2])!) +CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap) +usleep(80000) +CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap) diff --git a/electron/tests/native-bell-repro.mjs b/electron/tests/native-bell-repro.mjs new file mode 100644 index 00000000..86f18b40 --- /dev/null +++ b/electron/tests/native-bell-repro.mjs @@ -0,0 +1,67 @@ +import { _electron as electron } from 'playwright'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +if (process.platform !== 'darwin') + throw new Error('Requires macOS and Accessibility permission for native mouse events.'); +const scratch = mkdtempSync(join(tmpdir(), 'vs-native-bell-')); +const click = join(scratch, 'native-click'); +execFileSync('swiftc', [ + fileURLToPath(new URL('./fixtures/native-click.swift', import.meta.url)), + '-o', + click, +]); +const app = await electron.launch({ + args: [fileURLToPath(new URL('./fixtures/native-bell-host.cjs', import.meta.url))], +}); +try { + const page = await app.firstWindow(); + await page.addInitScript(() => { + localStorage.setItem('voicestudio.setup.complete.v1', '1'); + window.voicestudio = { + app: { + platform: 'darwin', + version: 'test', + onNavigate: () => () => {}, + onPersistenceFlush: () => () => {}, + }, + repair: { + list: async () => [], + getState: async () => ({ status: 'idle', output: '', workspaceAvailable: false }), + onEvent: () => () => {}, + }, + }; + }); + await page.goto((process.env.VOICESTUDIO_UI_URL || 'http://localhost:3902') + '/#/clone'); + const bell = page.locator('[data-slot=macos-system-notifications] button'); + await bell.waitFor(); + const rect = await bell.boundingBox(); + const bounds = await app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0]; + w.show(); + w.focus(); + return w.getContentBounds(); + }); + await page.waitForTimeout(500); + execFileSync(click, [ + String(bounds.x + rect.x + rect.width / 2), + String(bounds.y + rect.y + rect.height / 2), + ]); + await page.waitForTimeout(500); + const nativeOpened = await page.locator('[data-slot=popover-content][data-open]').isVisible(); + console.log(JSON.stringify({ nativeOpened })); + if (!nativeOpened) { + await bell.click(); + console.log( + JSON.stringify({ + automatedOpened: await page.locator('[data-slot=popover-content][data-open]').isVisible(), + }), + ); + process.exitCode = 1; + } +} finally { + await app.close(); + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/electron/tests/sidebar-layout-smoke.mjs b/electron/tests/sidebar-layout-smoke.mjs index 558207fc..0186f512 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.length > 0 && 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 {