Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/desktop/src/renderer/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { AppLayout } from './AppLayout';
import { useUIStore } from '@/stores/ui';

vi.mock('./TitleBar', () => ({ TitleBar: () => null }));
vi.mock('@/routes/settings', () => ({
SettingsPage: () => <div data-testid="settings-overlay" />,
}));

describe('AppLayout', () => {
beforeEach(() => {
useUIStore.setState({ settingsOpen: false });
});

it('keeps children mounted and only shows the settings overlay when open', () => {
render(
<AppLayout>
<div data-testid="home">home</div>
</AppLayout>
);

expect(screen.getByTestId('home')).toBeInTheDocument();
expect(screen.queryByTestId('settings-overlay')).toBeNull();

act(() => {
useUIStore.getState().openSettings();
});

// Home stays mounted underneath the overlay — the session is never torn down.
expect(screen.getByTestId('home')).toBeInTheDocument();
expect(screen.getByTestId('settings-overlay')).toBeInTheDocument();

act(() => {
useUIStore.getState().closeSettings();
});
expect(screen.queryByTestId('settings-overlay')).toBeNull();
expect(screen.getByTestId('home')).toBeInTheDocument();
});
});
17 changes: 16 additions & 1 deletion apps/desktop/src/renderer/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import type { ReactNode } from 'react';
import { TitleBar } from './TitleBar';
import { SettingsPage } from '@/routes/settings';
import { useUIStore } from '@/stores/ui';

interface AppLayoutProps {
children: ReactNode;
}

export function AppLayout({ children }: AppLayoutProps) {
const settingsOpen = useUIStore((s) => s.settingsOpen);

return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<TitleBar />
<main className="flex flex-1 flex-col">{children}</main>
<main className="relative flex flex-1 flex-col">
{children}

{/* Settings is an overlay rather than a route so opening it keeps Home
(and the active session) mounted underneath. Scoped to the content
area so the title bar / window controls stay usable. */}
{settingsOpen && (
<div className="absolute inset-0 z-50 flex flex-col overflow-auto bg-background">
<SettingsPage />
</div>
)}
</main>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/components/layout/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { useNavigate } from 'react-router-dom';
import { LogOut, User, ChevronDown, Settings } from 'lucide-react';
import { isElectron, getElectronAPI } from '../../lib/ipc';
import { useAuthStore } from '@/stores/auth';
import { useUIStore } from '@/stores/ui';

export function TitleBar() {
const platform = isElectron() ? getElectronAPI().platform : 'unknown';
const isMac = platform === 'darwin';
const isLinux = platform === 'linux';
const { user, logout } = useAuthStore();
const openSettings = useUIStore((s) => s.openSettings);
const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false);

Expand Down Expand Up @@ -70,7 +72,7 @@ export function TitleBar() {
<button
onClick={() => {
setShowMenu(false);
void navigate('/settings');
openSettings();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-muted"
>
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import { getElectronAPI, isElectron } from './lib/ipc';
import { useUIStore } from './stores/ui';
import './styles/globals.css';

// Listen for navigation events from main process (menu, tray, etc.)
if (isElectron()) {
const api = getElectronAPI();
api.on('navigate', (path) => {
// Settings is an overlay, not a route — routing to it would unmount the
// active session on Home and end it. Open the overlay instead.
if (path === '/settings') {
useUIStore.getState().openSettings();
return;
}
void router.navigate(path);
});
}
Expand Down
7 changes: 2 additions & 5 deletions apps/desktop/src/renderer/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { LoginPage } from './login';
import { HomePage } from './home';
import { JoinPage } from './join';
import { ViewerPage } from './viewer';
import { SettingsPage } from './settings';
import { AppLayout } from '@/components/layout/AppLayout';
import { ProtectedRoute } from '@/components/layout/ProtectedRoute';

Expand Down Expand Up @@ -36,10 +35,8 @@ export const router = createHashRouter([
path: 'viewer/:sessionId',
element: <ViewerPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
// Settings is rendered as an overlay (see AppLayout) instead of a sibling
// route, so opening it does not unmount the active session on Home.
],
},
{
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/renderer/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft,
Monitor,
Expand All @@ -17,6 +16,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { StreamDestinations } from '@/components/streaming';
import { LIVE_STREAM_CHANGED_EVENT } from '@/lib/liveStream';
import { useAuthStore } from '@/stores/auth';
import { useUIStore } from '@/stores/ui';
import { getElectronAPI, isElectron } from '@/lib/ipc';
import type { RecordingQuality } from '@/hooks/useRecording';
import { useRTMPStreaming } from '@/hooks/useRTMPStreaming';
Expand Down Expand Up @@ -59,7 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = {
};

export function SettingsPage() {
const navigate = useNavigate();
const closeSettings = useUIStore((s) => s.closeSettings);
const { user, profile } = useAuthStore();
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [recordingsPath, setRecordingsPath] = useState<string>('');
Expand Down Expand Up @@ -188,7 +188,9 @@ export function SettingsPage() {
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => void navigate('/')}
onClick={() => {
closeSettings();
}}
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"
>
<ArrowLeft className="h-4 w-4" />
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/renderer/stores/ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useUIStore } from './ui';

describe('useUIStore', () => {
beforeEach(() => {
useUIStore.setState({ settingsOpen: false });
});

it('opens and closes the settings overlay', () => {
expect(useUIStore.getState().settingsOpen).toBe(false);

useUIStore.getState().openSettings();
expect(useUIStore.getState().settingsOpen).toBe(true);

useUIStore.getState().closeSettings();
expect(useUIStore.getState().settingsOpen).toBe(false);
});
});
25 changes: 25 additions & 0 deletions apps/desktop/src/renderer/stores/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { create } from 'zustand';

/**
* Transient UI state that must survive route changes.
*
* Settings is shown as an overlay (not a sibling route) so opening it does NOT
* unmount the active session on Home — navigating to a `/settings` route tore
* the HomePage subtree down, which ran CapturePreview's unmount cleanup
* (`stopHosting`) and ended the live session.
*/
interface UIState {
settingsOpen: boolean;
openSettings: () => void;
closeSettings: () => void;
}

export const useUIStore = create<UIState>((set) => ({
settingsOpen: false,
openSettings: () => {
set({ settingsOpen: true });
},
closeSettings: () => {
set({ settingsOpen: false });
},
}));
Loading