Merge branch 'fix/macos-sidebar-titlebar' into fix/linux-electron-setup-sidebar

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-17 01:21:38 +05:30
12 changed files with 533 additions and 115 deletions
+1
View File
@@ -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)
+19
View File
@@ -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.
@@ -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 (
<div className="app-surface relative flex h-full flex-col bg-background text-foreground">
<div
className={cn(
'app-surface relative flex h-full flex-col bg-background text-foreground',
macWorkspace && 'macos-notification-safe-area',
)}
>
<div className="flex min-h-0 flex-1">
{settings ? (
<>
@@ -41,6 +52,16 @@ export function AppShell() {
</BackendGate>
)}
</div>
{/* Native drag-region hit testing follows document order. Keep this
no-drag control after every workspace titlebar, outside BackendGate. */}
{macWorkspace && (
<div
data-slot="macos-system-notifications"
className="app-no-drag fixed top-3.5 right-3.5 z-50"
>
<SystemNotifications enabled={backend.stage === 'ready'} titlebar />
</div>
)}
</div>
);
}
@@ -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 = (
<Popover open={deviceOpen} onOpenChange={setDeviceOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label={`${deviceLabel}: ${deviceStageText}`}
className={cn(engineLinkClass, 'h-7 w-full', !compact && 'justify-start gap-2 px-2')}
/>
}
>
<CpuIcon className={engineIconClass} aria-hidden="true" />
{!compact && <span className="min-w-0 truncate">{deviceLabel}</span>}
<span
className={cn(
compact
? 'absolute inset-x-2 bottom-0.5 h-0.5 rounded-full'
: 'ml-auto size-1.5 shrink-0 rounded-full',
deviceDot,
)}
aria-hidden="true"
/>
</PopoverTrigger>
{deviceContent}
</Popover>
);
if (compact) {
return (
<footer className="shrink-0 border-t border-border/50 px-1.5 py-2 text-muted-foreground">
<Popover open={deviceOpen} onOpenChange={setDeviceOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label={`${deviceLabel}: ${deviceStageText}`}
className={cn(engineLinkClass, 'w-full')}
/>
}
>
<CpuIcon className={engineIconClass} aria-hidden="true" />
<span
className={cn('absolute inset-x-2 bottom-0.5 h-0.5 rounded-full', deviceDot)}
aria-hidden="true"
/>
</PopoverTrigger>
{deviceContent}
</Popover>
<footer
className={cn(
'shrink-0 text-muted-foreground',
inline ? 'contents' : 'border-t border-border/50 px-1.5 py-2',
)}
>
{iconDevicePopover}
</footer>
);
}
return (
<footer className="@container/engines w-full min-w-0 max-w-full border-t border-border/50 px-3 py-1.5 text-[length:var(--text-caption)] text-muted-foreground">
<div>
<div className="flex items-center gap-0.5">
<Popover open={deviceOpen} onOpenChange={setDeviceOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label={`${deviceLabel}: ${deviceStageText}`}
className="group/status flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1 text-left outline-none transition-[background-color,box-shadow,backdrop-filter] duration-150 hover:bg-sidebar-accent/65 hover:backdrop-blur-xl hover:shadow-[inset_0_1px_0_rgb(255_255_255/8%),0_5px_14px_rgb(0_0_0/10%)] hover:ring-1 hover:ring-inset hover:ring-sidebar-border/60 focus-visible:ring-2 focus-visible:ring-ring"
{!footerLeading && (
<div className="flex items-center gap-0.5">
<Popover open={deviceOpen} onOpenChange={setDeviceOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label={`${deviceLabel}: ${deviceStageText}`}
className="group/status flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1 text-left outline-none transition-[background-color,box-shadow,backdrop-filter] duration-150 hover:bg-sidebar-accent/65 hover:backdrop-blur-xl hover:shadow-[inset_0_1px_0_rgb(255_255_255/8%),0_5px_14px_rgb(0_0_0/10%)] hover:ring-1 hover:ring-inset hover:ring-sidebar-border/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', deviceDot)}
aria-hidden="true"
/>
}
<CpuIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" role="status">
{deviceLabel}
</span>
</PopoverTrigger>
{deviceContent}
</Popover>
<button
type="button"
aria-label={expanded ? t('paneActions.collapse') : t('modelSettings.models')}
aria-expanded={expanded}
aria-controls="sidebar-engine-details"
onClick={() => setExpanded((value) => !value)}
className="flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-sidebar-accent/65 focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn('size-1.5 shrink-0 rounded-full', deviceDot)}
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-150 motion-reduce:transition-none',
expanded && 'rotate-180',
)}
aria-hidden="true"
/>
<CpuIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" role="status">
{deviceLabel}
</span>
</PopoverTrigger>
{deviceContent}
</Popover>
<button
type="button"
aria-label={expanded ? t('paneActions.collapse') : t('modelSettings.models')}
aria-expanded={expanded}
aria-controls="sidebar-engine-details"
onClick={() => setExpanded((value) => !value)}
className="flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-sidebar-accent/65 focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-150 motion-reduce:transition-none',
expanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
</div>
</button>
</div>
)}
<div
ref={tipAnchor}
className="mt-0.5 grid w-full min-w-0 grid-cols-6 gap-1 rounded-lg border border-border/50 bg-sidebar-accent/25 p-1"
@@ -809,6 +833,28 @@ export function StatusBar({ compact = false }: { compact?: boolean }) {
<PerformanceProfile tooltipAnchor={tipAnchor} />
</div>
)}
{footerLeading && (
<div className="mt-1.5 flex items-center gap-1 border-t border-border/50 pt-1.5">
{footerLeading}
<div className="min-w-0 flex-1">{iconDevicePopover}</div>
<button
type="button"
aria-label={expanded ? t('paneActions.collapse') : t('modelSettings.models')}
aria-expanded={expanded}
aria-controls="sidebar-engine-details"
onClick={() => setExpanded((value) => !value)}
className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-md outline-none transition-colors hover:bg-sidebar-accent/65 focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-150 motion-reduce:transition-none',
expanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
</div>
)}
</div>
</footer>
);
@@ -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(
<QueryClientProvider client={new QueryClient()}>
<SystemNotifications enabled={false} titlebar />
</QueryClientProvider>,
);
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();
});
});
@@ -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 (
<Popover>
<PopoverTrigger
@@ -186,9 +184,11 @@ export function SystemNotifications({
<Button
variant="ghost"
size="icon-xs"
className="relative shrink-0 text-muted-foreground hover:text-foreground"
className={cn(
'relative shrink-0 text-muted-foreground hover:text-foreground',
titlebar && 'app-no-drag',
)}
aria-label={triggerLabel}
disabled={!enabled && !updateNotification}
/>
}
>
@@ -206,11 +206,19 @@ export function SystemNotifications({
)}
</PopoverTrigger>
<PopoverContent
side={compact ? 'right' : 'top'}
align="start"
side={titlebar ? 'bottom' : compact ? 'right' : 'top'}
align={titlebar ? 'end' : 'start'}
className="max-h-[min(28rem,calc(100vh-2rem))] w-[min(22rem,calc(100vw-2rem))] space-y-1 overflow-y-auto p-1.5"
>
{!query.isPending && !query.isError && visible.length === 0 && (
{!enabled && visible.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{t('modelSettings.unavailable')}</p>
)}
{enabled && query.isPending && visible.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
{t('preferences.loading')}
</p>
)}
{enabled && !query.isPending && !query.isError && visible.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
{t('logs.all_clear')}
</p>
@@ -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<string | null>(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() {
<aside
aria-label={t('clone.saved_profiles')}
data-slot="compact-main-sidebar"
className="brand-sidebar relative isolate grid h-dvh min-h-0 w-12 shrink-0 grid-rows-[auto_minmax(0,1fr)_auto_auto] overflow-hidden border-r border-border/50 bg-sidebar"
className={cn(
'brand-sidebar relative isolate grid h-dvh min-h-0 shrink-0 grid-rows-[auto_minmax(0,1fr)_auto_auto] overflow-hidden bg-sidebar',
mac ? 'w-16' : 'w-12 border-r border-border/50',
)}
>
{showCompactBrand ? (
<div className="workspace-titlebar flex w-full shrink-0 items-center justify-center">
<img src={brandIcon} alt={t('app.name')} className="size-6 shrink-0" />
</div>
) : (
{mac && (
<span
aria-hidden="true"
data-slot="compact-sidebar-divider"
className="pointer-events-none absolute top-[72px] right-0 bottom-0 w-px bg-border/50"
/>
)}
<div
className={cn(
'workspace-titlebar flex shrink-0 justify-center',
mac ? 'min-h-[72px] items-end pb-1' : 'h-12 items-center',
)}
>
{!mac ? <img src={brandIcon} alt={t('app.name')} className="size-6 shrink-0" /> : (
<Button
variant="ghost"
size="icon-sm"
aria-label={t('clone.toggle_sidebar')}
aria-expanded={false}
onClick={() => {
setOpen(true);
setExpandedContext(compactContext);
setLibraryOpen(true);
}}
className={cn(
'workspace-titlebar h-auto w-full shrink-0 rounded-none outline-none focus-visible:ring-2 focus-visible:ring-ring',
isMac() && 'pt-5',
)}
className="shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<PanelLeftOpenIcon className="size-5" aria-hidden="true" />
</Button>
)}
<WorkspaceNavigation compact />
<div className="flex min-w-0 flex-col items-center">
<StatusBar compact />
<SystemNotifications enabled={backend.stage === 'ready'} compact />
)}
</div>
<div className="flex h-[var(--workspace-footer-height)] shrink-0 items-center justify-center border-t border-border/50">
<WorkspaceNavigation compact />
{!mac && <StatusBar compact />}
<div
className={cn(
'shrink-0 border-t border-border/50 py-2',
mac ? 'grid grid-cols-2 items-center gap-1 px-1' : 'flex flex-col items-center gap-1',
)}
>
<Link
to="/settings"
aria-label={t('nav.settings')}
title={t('nav.settings')}
className={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
className={buttonVariants({
variant: 'ghost',
size: mac ? 'icon-xs' : 'icon-sm',
})}
>
<SettingsIcon />
</Link>
{mac && <StatusBar compact inline />}
{!mac && <SystemNotifications enabled={backend.stage === 'ready'} compact />}
</div>
</aside>
)}
@@ -92,7 +163,7 @@ export function WorkspaceSidebar() {
<header
className={cn(
'workspace-titlebar flex shrink-0 items-center gap-2 px-4',
isMac() && 'pl-20',
mac && 'pl-24',
)}
>
<Link
@@ -108,7 +179,8 @@ export function WorkspaceSidebar() {
size="icon-sm"
aria-label={t('common.close')}
onClick={() => {
setOpen(false);
setExpandedContext(null);
setLibraryOpen(false);
}}
>
<PanelLeftIcon />
@@ -122,14 +194,29 @@ export function WorkspaceSidebar() {
<VoicesSidebar key={libraryTab} initialTab={libraryTab} />
<div className="flex min-w-0 shrink-0 flex-col border-t border-border/50">
<WorkspaceNavigation />
<StatusBar />
<div className="flex h-[var(--workspace-footer-height)] items-center justify-between gap-2 border-t border-border/50 px-3">
<Link to="/settings" className={buttonVariants({ variant: 'ghost', size: 'sm' })}>
<SettingsIcon />
{t('nav.settings')}
</Link>
<SystemNotifications enabled={backend.stage === 'ready'} />
</div>
<StatusBar
footerLeading={
mac ? (
<Link
to="/settings"
aria-label={t('nav.settings')}
title={t('nav.settings')}
className={buttonVariants({ variant: 'ghost', size: 'icon-xs' })}
>
<SettingsIcon />
</Link>
) : undefined
}
/>
{!mac && (
<div className="flex items-center justify-between gap-2 border-t border-border/50 px-3 py-2">
<Link to="/settings" className={buttonVariants({ variant: 'ghost', size: 'sm' })}>
<SettingsIcon />
{t('nav.settings')}
</Link>
<SystemNotifications enabled={backend.stage === 'ready'} />
</div>
)}
</div>
</aside>
)}
@@ -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 {
+11
View File
@@ -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');
});
+7
View File
@@ -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)
+67
View File
@@ -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 });
}
+123 -2
View File
@@ -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 {