Skip to content

Commit c7cda0f

Browse files
ralyodioclaude
andauthored
fix(desktop): open Settings as an overlay so it doesn't end the session (#26)
Settings was a sibling route under AppLayout, so navigating to it unmounted HomePage — which ran CapturePreview's unmount cleanup (stopHosting), dropping the host from the LiveKit room and ending the live session (and restarting egress, which traps YouTube on "Preparing"). Coming back landed on a fresh HomePage with no session. Settings now renders as a full-screen overlay (new useUIStore) over the still -mounted Home, so the session keeps running underneath. All entry points are covered: the main-process menu/tray IPC ('navigate' -> '/settings') is intercepted in main.tsx, the mac/win TitleBar button opens the overlay, and the Back button closes it. The unmounting '/settings' route is removed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e559c9e commit c7cda0f

8 files changed

Lines changed: 116 additions & 10 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { render, screen, act } from '@testing-library/react';
3+
import { AppLayout } from './AppLayout';
4+
import { useUIStore } from '@/stores/ui';
5+
6+
vi.mock('./TitleBar', () => ({ TitleBar: () => null }));
7+
vi.mock('@/routes/settings', () => ({
8+
SettingsPage: () => <div data-testid="settings-overlay" />,
9+
}));
10+
11+
describe('AppLayout', () => {
12+
beforeEach(() => {
13+
useUIStore.setState({ settingsOpen: false });
14+
});
15+
16+
it('keeps children mounted and only shows the settings overlay when open', () => {
17+
render(
18+
<AppLayout>
19+
<div data-testid="home">home</div>
20+
</AppLayout>
21+
);
22+
23+
expect(screen.getByTestId('home')).toBeInTheDocument();
24+
expect(screen.queryByTestId('settings-overlay')).toBeNull();
25+
26+
act(() => {
27+
useUIStore.getState().openSettings();
28+
});
29+
30+
// Home stays mounted underneath the overlay — the session is never torn down.
31+
expect(screen.getByTestId('home')).toBeInTheDocument();
32+
expect(screen.getByTestId('settings-overlay')).toBeInTheDocument();
33+
34+
act(() => {
35+
useUIStore.getState().closeSettings();
36+
});
37+
expect(screen.queryByTestId('settings-overlay')).toBeNull();
38+
expect(screen.getByTestId('home')).toBeInTheDocument();
39+
});
40+
});
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
import type { ReactNode } from 'react';
22
import { TitleBar } from './TitleBar';
3+
import { SettingsPage } from '@/routes/settings';
4+
import { useUIStore } from '@/stores/ui';
35

46
interface AppLayoutProps {
57
children: ReactNode;
68
}
79

810
export function AppLayout({ children }: AppLayoutProps) {
11+
const settingsOpen = useUIStore((s) => s.settingsOpen);
12+
913
return (
1014
<div className="flex min-h-screen flex-col bg-background text-foreground">
1115
<TitleBar />
12-
<main className="flex flex-1 flex-col">{children}</main>
16+
<main className="relative flex flex-1 flex-col">
17+
{children}
18+
19+
{/* Settings is an overlay rather than a route so opening it keeps Home
20+
(and the active session) mounted underneath. Scoped to the content
21+
area so the title bar / window controls stay usable. */}
22+
{settingsOpen && (
23+
<div className="absolute inset-0 z-50 flex flex-col overflow-auto bg-background">
24+
<SettingsPage />
25+
</div>
26+
)}
27+
</main>
1328
</div>
1429
);
1530
}

‎apps/desktop/src/renderer/components/layout/TitleBar.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { useNavigate } from 'react-router-dom';
33
import { LogOut, User, ChevronDown, Settings } from 'lucide-react';
44
import { isElectron, getElectronAPI } from '../../lib/ipc';
55
import { useAuthStore } from '@/stores/auth';
6+
import { useUIStore } from '@/stores/ui';
67

78
export function TitleBar() {
89
const platform = isElectron() ? getElectronAPI().platform : 'unknown';
910
const isMac = platform === 'darwin';
1011
const isLinux = platform === 'linux';
1112
const { user, logout } = useAuthStore();
13+
const openSettings = useUIStore((s) => s.openSettings);
1214
const navigate = useNavigate();
1315
const [showMenu, setShowMenu] = useState(false);
1416

@@ -70,7 +72,7 @@ export function TitleBar() {
7072
<button
7173
onClick={() => {
7274
setShowMenu(false);
73-
void navigate('/settings');
75+
openSettings();
7476
}}
7577
className="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-muted"
7678
>

‎apps/desktop/src/renderer/main.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import ReactDOM from 'react-dom/client';
33
import { RouterProvider } from 'react-router-dom';
44
import { router } from './routes';
55
import { getElectronAPI, isElectron } from './lib/ipc';
6+
import { useUIStore } from './stores/ui';
67
import './styles/globals.css';
78

89
// Listen for navigation events from main process (menu, tray, etc.)
910
if (isElectron()) {
1011
const api = getElectronAPI();
1112
api.on('navigate', (path) => {
13+
// Settings is an overlay, not a route — routing to it would unmount the
14+
// active session on Home and end it. Open the overlay instead.
15+
if (path === '/settings') {
16+
useUIStore.getState().openSettings();
17+
return;
18+
}
1219
void router.navigate(path);
1320
});
1421
}

‎apps/desktop/src/renderer/routes/index.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { LoginPage } from './login';
33
import { HomePage } from './home';
44
import { JoinPage } from './join';
55
import { ViewerPage } from './viewer';
6-
import { SettingsPage } from './settings';
76
import { AppLayout } from '@/components/layout/AppLayout';
87
import { ProtectedRoute } from '@/components/layout/ProtectedRoute';
98

@@ -36,10 +35,8 @@ export const router = createHashRouter([
3635
path: 'viewer/:sessionId',
3736
element: <ViewerPage />,
3837
},
39-
{
40-
path: 'settings',
41-
element: <SettingsPage />,
42-
},
38+
// Settings is rendered as an overlay (see AppLayout) instead of a sibling
39+
// route, so opening it does not unmount the active session on Home.
4340
],
4441
},
4542
{

‎apps/desktop/src/renderer/routes/settings.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useState, useEffect } from 'react';
2-
import { useNavigate } from 'react-router-dom';
32
import {
43
ArrowLeft,
54
Monitor,
@@ -17,6 +16,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
1716
import { StreamDestinations } from '@/components/streaming';
1817
import { LIVE_STREAM_CHANGED_EVENT } from '@/lib/liveStream';
1918
import { useAuthStore } from '@/stores/auth';
19+
import { useUIStore } from '@/stores/ui';
2020
import { getElectronAPI, isElectron } from '@/lib/ipc';
2121
import type { RecordingQuality } from '@/hooks/useRecording';
2222
import { useRTMPStreaming } from '@/hooks/useRTMPStreaming';
@@ -59,7 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = {
5959
};
6060

6161
export function SettingsPage() {
62-
const navigate = useNavigate();
62+
const closeSettings = useUIStore((s) => s.closeSettings);
6363
const { user, profile } = useAuthStore();
6464
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
6565
const [recordingsPath, setRecordingsPath] = useState<string>('');
@@ -188,7 +188,9 @@ export function SettingsPage() {
188188
<div className="mb-6 flex items-center justify-between">
189189
<div className="flex items-center gap-4">
190190
<button
191-
onClick={() => void navigate('/')}
191+
onClick={() => {
192+
closeSettings();
193+
}}
192194
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
193195
>
194196
<ArrowLeft className="h-4 w-4" />
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { useUIStore } from './ui';
3+
4+
describe('useUIStore', () => {
5+
beforeEach(() => {
6+
useUIStore.setState({ settingsOpen: false });
7+
});
8+
9+
it('opens and closes the settings overlay', () => {
10+
expect(useUIStore.getState().settingsOpen).toBe(false);
11+
12+
useUIStore.getState().openSettings();
13+
expect(useUIStore.getState().settingsOpen).toBe(true);
14+
15+
useUIStore.getState().closeSettings();
16+
expect(useUIStore.getState().settingsOpen).toBe(false);
17+
});
18+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { create } from 'zustand';
2+
3+
/**
4+
* Transient UI state that must survive route changes.
5+
*
6+
* Settings is shown as an overlay (not a sibling route) so opening it does NOT
7+
* unmount the active session on Home — navigating to a `/settings` route tore
8+
* the HomePage subtree down, which ran CapturePreview's unmount cleanup
9+
* (`stopHosting`) and ended the live session.
10+
*/
11+
interface UIState {
12+
settingsOpen: boolean;
13+
openSettings: () => void;
14+
closeSettings: () => void;
15+
}
16+
17+
export const useUIStore = create<UIState>((set) => ({
18+
settingsOpen: false,
19+
openSettings: () => {
20+
set({ settingsOpen: true });
21+
},
22+
closeSettings: () => {
23+
set({ settingsOpen: false });
24+
},
25+
}));

0 commit comments

Comments
 (0)