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
52 changes: 47 additions & 5 deletions ui/desktop/src/components/Layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IpcRendererEvent } from 'electron';
import { Outlet, useLocation } from 'react-router-dom';
import { motion } from 'framer-motion';
Expand All @@ -9,7 +9,7 @@ import ChatSessionsContainer from '../ChatSessionsContainer';
import { useChatContext } from '../../contexts/ChatContext';
import { NavigationProvider, useNavigationContext } from './NavigationContext';
import { Navigation } from './NavigationPanel';
import { NAV_DIMENSIONS, Z_INDEX } from './constants';
import { Z_INDEX } from './constants';
import { cn } from '../../utils';
import { UserInput } from '../../types/message';

Expand Down Expand Up @@ -54,7 +54,41 @@ const AppLayoutContent: React.FC<AppLayoutContentProps> = ({ activeSessions }) =
return () => window.electron.off('fullscreen-change', handler);
}, [safeIsMacOS]);

const { isNavExpanded, setIsNavExpanded } = useNavigationContext();
const { isNavExpanded, setIsNavExpanded, navWidth, setNavWidth } = useNavigationContext();
const [isDragging, setIsDragging] = useState(false);
const isResizing = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);

const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
isResizing.current = true;
startX.current = e.clientX;
startWidth.current = navWidth;
setIsDragging(true);
e.preventDefault();
},
[navWidth]
);

useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.current) return;
setNavWidth(startWidth.current + (e.clientX - startX.current));
};
const handleMouseUp = () => {
if (isResizing.current) {
isResizing.current = false;
setIsDragging(false);
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
Comment on lines +85 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep resize events out of embedded MCP iframes

When resizing across a chat that contains an MCP app, the cursor can enter the sandboxed iframe created in ui/desktop/src/components/McpApps/McpAppRenderer.tsx:357. Mouse events over an iframe are delivered to the iframe's own window, so these parent-window listeners can miss the final mouseup; isResizing/isDragging then stay true and later mouse moves continue resizing. Add a temporary drag overlay or capture/lost-capture cleanup instead of relying only on parent window events.

Useful? React with 👍 / 👎.

return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [setNavWidth]);

if (!chatContext) {
throw new Error('AppLayoutContent must be used within ChatProvider');
Expand Down Expand Up @@ -93,14 +127,22 @@ const AppLayoutContent: React.FC<AppLayoutContentProps> = ({ activeSessions }) =
<motion.div
key="nav"
initial={false}
animate={{ width: isNavExpanded ? NAV_DIMENSIONS.NAV_WIDTH : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 40 }}
animate={{ width: isNavExpanded ? navWidth : 0 }}
transition={
isDragging ? { duration: 0 } : { type: 'spring', stiffness: 400, damping: 40 }
}
style={{ height: '100%' }}
className="relative flex-shrink-0 overflow-hidden h-full p-2"
>
<div className="w-full h-full overflow-hidden rounded-xl border border-border-primary">
<Navigation />
</div>
{isNavExpanded && (
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-border-primary/30 transition-colors"
onMouseDown={handleResizeMouseDown}
/>
)}
</motion.div>

{/* Main content — no border / no card; just flows on the canvas. */}
Expand Down
25 changes: 25 additions & 0 deletions ui/desktop/src/components/Layout/NavigationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
useRef,
useState,
} from 'react';
import { NAV_DIMENSIONS } from './constants';

/**
* When the window is narrower than this many CSS pixels, we auto-collapse
Expand All @@ -15,9 +16,14 @@ import React, {
*/
const NARROW_WINDOW_THRESHOLD = 700;

export const MIN_NAV_WIDTH = 160;
export const MAX_NAV_WIDTH = 400;

interface NavigationContextValue {
isNavExpanded: boolean;
setIsNavExpanded: (expanded: boolean) => void;
navWidth: number;
setNavWidth: (width: number) => void;
}

const NavigationContext = createContext<NavigationContextValue | null>(null);
Expand Down Expand Up @@ -49,6 +55,23 @@ export const NavigationProvider: React.FC<NavigationProviderProps> = ({ children
localStorage.setItem('navigation_expanded', String(expanded));
}, []);

const [navWidth, setNavWidthState] = useState<number>(() => {
const stored = localStorage.getItem('navigation_width');
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= MIN_NAV_WIDTH && parsed <= MAX_NAV_WIDTH) {
return parsed;
}
}
return NAV_DIMENSIONS.NAV_WIDTH;
});

const setNavWidth = useCallback((width: number) => {
const clamped = Math.min(MAX_NAV_WIDTH, Math.max(MIN_NAV_WIDTH, width));
setNavWidthState(clamped);
localStorage.setItem('navigation_width', String(clamped));
}, []);

const isNavExpandedRef = useRef(isNavExpanded);
useEffect(() => {
isNavExpandedRef.current = isNavExpanded;
Expand Down Expand Up @@ -90,6 +113,8 @@ export const NavigationProvider: React.FC<NavigationProviderProps> = ({ children
const value: NavigationContextValue = {
isNavExpanded,
setIsNavExpanded,
navWidth,
setNavWidth,
};

return <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
Expand Down