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 = (
+
+
+ }
+ >
+
+ {!compact && {deviceLabel} }
+
+
+ {deviceContent}
+
+ );
if (compact) {
return (
-
-
-
- }
- >
-
-
-
- {deviceContent}
-
+
);
}
return (
-
-
-
+
+
+ }
+ >
+
- }
+
+
+ {deviceLabel}
+
+
+ {deviceContent}
+
+
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"
>
-
-
-
- {deviceLabel}
-
-
- {deviceContent}
-
- 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"
- >
-
-
-
+
+
+ )}
)}
+ {footerLeading && (
+
+ {footerLeading}
+
{iconDevicePopover}
+
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"
+ >
+
+
+
+ )}
);
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() {
- {showCompactBrand ? (
-
-
-
- ) : (
+ {mac && (
+
+ )}
+
+ {!mac ?
: (
{
- 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"
>
- )}
-
-
-
-
+ )}
-
+
+ {!mac &&
}
+
+ {mac && }
+ {!mac && }
)}
@@ -92,7 +163,7 @@ export function WorkspaceSidebar() {