Merge branch 'fix/macos-sidebar-titlebar' into fix/linux-electron-setup-sidebar
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user