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
31 changes: 25 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TodoDetailView } from './pages/TodoDetailView';
import { TaskBoard } from './pages/TaskBoard';
import { ErrorBoundary } from './components/ErrorBoundary';
import { MobileShell } from './components/MobileShell';
import { DesktopShell } from './components/DesktopShell';
import { useIsDesktop } from './hooks/useMediaQuery';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
Expand Down Expand Up @@ -41,6 +42,12 @@ function ChatRoute() {
return isDesktop ? <DesktopChatView /> : <ChatView />;
}

function PageRoute({ children }: { children: React.ReactNode }) {
const isDesktop = useIsDesktop();
if (!isDesktop) return <>{children}</>;
return <DesktopShell center={children} />;
}

function dismissKeyboard(e: React.MouseEvent | React.TouchEvent) {
const target = e.target as HTMLElement;
const tag = target.tagName;
Expand Down Expand Up @@ -96,39 +103,49 @@ export function App() {
path="/inbox"
element={
<ProtectedRoute>
<InboxView />
<PageRoute>
<InboxView />
</PageRoute>
</ProtectedRoute>
}
/>
<Route
path="/calendar"
element={
<ProtectedRoute>
<CalendarView />
<PageRoute>
<CalendarView />
</PageRoute>
</ProtectedRoute>
}
/>
<Route
path="/todos"
element={
<ProtectedRoute>
<TodoView />
<PageRoute>
<TodoView />
</PageRoute>
</ProtectedRoute>
}
/>
<Route
path="/todos/:id"
element={
<ProtectedRoute>
<TodoDetailView />
<PageRoute>
<TodoDetailView />
</PageRoute>
</ProtectedRoute>
}
/>
<Route
path="/tasks"
element={
<ProtectedRoute>
<TaskBoard />
<PageRoute>
<TaskBoard />
</PageRoute>
</ProtectedRoute>
}
/>
Expand All @@ -137,7 +154,9 @@ export function App() {
element={
<ProtectedRoute>
<ErrorBoundary>
<FileViewer />
<PageRoute>
<FileViewer />
</PageRoute>
</ErrorBoundary>
</ProtectedRoute>
}
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/DesktopNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useTabBadges } from '../hooks/useTabBadges';

interface NavItem {
label: string;
path: string;
match: (pathname: string) => boolean;
badge?: number;
}

export function DesktopNav() {
const location = useLocation();
const navigate = useNavigate();
const { inboxCount, todoCount } = useTabBadges();

const items: NavItem[] = [
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The items array (with its closures) is re-allocated on every render. For 6 items this is negligible, but wrapping it in useMemo(() => [...], [inboxCount, todoCount]) would make dependencies explicit and avoid the allocation when only location changes. [fixable]

{
label: 'Chat',
path: '/',
match: (p) => p === '/' || p === '/chat' || p.startsWith('/chat/'),
},
{ label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') },
{ label: 'Inbox', path: '/inbox', match: (p) => p === '/inbox', badge: inboxCount },
{
label: 'Telos',
path: '/todos',
match: (p) => p === '/todos' || p.startsWith('/todos/'),
badge: todoCount,
},
{ label: 'Tasks', path: '/tasks', match: (p) => p.startsWith('/tasks') },
{ label: 'Files', path: '/files', match: (p) => p.startsWith('/files') },
];

return (
<nav className="desktop-nav">
{items.map((item) => (
<button
key={item.label}
className={`desktop-nav-item${item.match(location.pathname) ? ' desktop-nav-item--active' : ''}`}
onClick={() => navigate(item.path)}
>
<span>{item.label}</span>
{item.badge ? <span className="desktop-nav-badge">{item.badge}</span> : null}
</button>
))}
</nav>
);
}
38 changes: 23 additions & 15 deletions frontend/src/components/DesktopShell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useCallback, type ReactNode } from 'react';
import { DesktopNav } from './DesktopNav';

export interface DesktopShellProps {
left: ReactNode;
left?: ReactNode;
center: ReactNode;
right: ReactNode;
right?: ReactNode;
statusBar?: ReactNode;
}

Expand Down Expand Up @@ -55,25 +56,32 @@ export function DesktopShell({ left, center, right, statusBar }: DesktopShellPro
<button
className="desktop-collapse-btn"
onClick={toggleLeft}
title={leftCollapsed ? 'Show sessions' : 'Hide sessions'}
title={leftCollapsed ? 'Show sidebar' : 'Hide sidebar'}
>
{leftCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!leftCollapsed && left}
{!leftCollapsed && (
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: When left is undefined (the PageRoute case), the left sidebar still renders DesktopNav and the collapse toggle — which is the desired behavior. However, when the sidebar is collapsed and then expanded, the fragment renders <DesktopNav /> followed by {undefined}. This is harmless in React (undefined children are ignored), but worth noting: there's no visual separator or empty-state handling between the nav and a missing session panel. If the left sidebar should be entirely hidden when no left content exists, wrap the whole sidebar in a conditional too.

<>
<DesktopNav />
{left}
</>
)}
</div>
<div className="desktop-center">{center}</div>
<div
className={`desktop-sidebar-right${rightCollapsed ? ' desktop-sidebar--collapsed' : ''}`}
>
<button
className="desktop-collapse-btn"
onClick={toggleRight}
title={rightCollapsed ? 'Show context' : 'Hide context'}
{right && (
<div
className={`desktop-sidebar-right${rightCollapsed ? ' desktop-sidebar--collapsed' : ''}`}
>
{rightCollapsed ? '\u25C0' : '\u25B6'}
</button>
{!rightCollapsed && right}
</div>
<button
className="desktop-collapse-btn"
onClick={toggleRight}
title={rightCollapsed ? 'Show context' : 'Hide context'}
>
{rightCollapsed ? '\u25C0' : '\u25B6'}
</button>
{!rightCollapsed && right}
</div>
)}
</div>
{statusBar && <div className="desktop-status-row">{statusBar}</div>}
</div>
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/__tests__/DesktopShell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';

vi.mock('../DesktopNav', () => ({
DesktopNav: () => <nav data-testid="desktop-nav">Nav</nav>,
}));

import { DesktopShell } from '../DesktopShell';

beforeEach(() => {
Expand Down Expand Up @@ -31,7 +36,7 @@ describe('DesktopShell', () => {
right={<div>Right</div>}
/>,
);
const toggleBtn = screen.getByTitle('Hide sessions');
const toggleBtn = screen.getByTitle('Hide sidebar');
fireEvent.click(toggleBtn);
expect(screen.queryByText('Left Panel')).toBeNull();
expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('1');
Expand Down Expand Up @@ -61,7 +66,7 @@ describe('DesktopShell', () => {
/>,
);
expect(screen.queryByText('Left Panel')).toBeNull();
expect(screen.getByTitle('Show sessions')).toBeTruthy();
expect(screen.getByTitle('Show sidebar')).toBeTruthy();
});

it('expands collapsed sidebar on toggle', () => {
Expand All @@ -73,7 +78,7 @@ describe('DesktopShell', () => {
right={<div>Right</div>}
/>,
);
fireEvent.click(screen.getByTitle('Show sessions'));
fireEvent.click(screen.getByTitle('Show sidebar'));
expect(screen.getByText('Left Panel')).toBeTruthy();
expect(localStorage.getItem('mitzo-sidebar-left-collapsed')).toBe('0');
});
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/pages/DesktopChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ export function DesktopChatView() {
}, []);

const handleSelectSession = useCallback((id: string) => navigate(`/chat/${id}`), [navigate]);
const handleNewChat = useCallback(() => navigate('/chat'), [navigate]);
const handleNewChat = useCallback(() => {
storeNewSession();
navigate('/chat');
}, [navigate, storeNewSession]);

return (
<DesktopShell
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/styles/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,60 @@
font-weight: 600;
}

/* === Desktop Nav === */
.desktop-nav {
display: flex;
flex-direction: column;
gap: 1px;
padding: var(--space-2);
padding-bottom: var(--space-2);
margin-bottom: var(--space-1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}

.desktop-nav-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1h) var(--space-2);
border-radius: 6px;
font-size: var(--text-s);
color: var(--text-dim);
background: none;
border: none;
cursor: pointer;
width: 100%;
text-align: left;
}

.desktop-nav-item:hover {
background: var(--border);
color: var(--text);
}

.desktop-nav-item--active {
color: var(--accent);
background: rgba(108, 99, 255, 0.1);
}

.desktop-nav-item--active:hover {
background: rgba(108, 99, 255, 0.15);
}

.desktop-nav-badge {
background: var(--danger);
color: #fff;
font-size: var(--text-2xs);
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}

/* === Session Panel === */
.session-panel {
display: flex;
Expand Down
Loading