Skip to content
Closed
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
90 changes: 82 additions & 8 deletions ui/desktop/src/components/Layout/MainPanelLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,92 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';
import SidecarPanel from '../Sidecar/SidecarPanel';
import { SidecarProvider, useSidecar } from '../Sidecar/SidecarContext';

export const MainPanelLayout: React.FC<{
children: React.ReactNode;
removeTopPadding?: boolean;
backgroundColor?: string;
}> = ({ children, removeTopPadding = false, backgroundColor = 'bg-background-default' }) => {
const ResizableSeparator = () => {
const { isOpen, setWidthPct, close } = useSidecar();
const dragging = useRef(false);

const onMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isOpen) return;
dragging.current = true;
document.body.style.cursor = 'col-resize';
e.preventDefault();
e.stopPropagation();
},
[isOpen]
);

const onMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging.current) return;
const container = document.querySelector('#main-panel-layout-container') as HTMLDivElement;
if (!container) return;
const rect = container.getBoundingClientRect();
const totalWidth = rect.width || 1;
const distanceFromRight = Math.max(0, Math.min(rect.width, rect.right - e.clientX));
const next = distanceFromRight / totalWidth; // fraction of container taken by sidecar
// Auto-collapse if below 12% (similar feel to left rail)
if (next < 0.12) {
setWidthPct(0.3); // store a sensible default when re-opened
close();
dragging.current = false;
document.body.style.cursor = '';
return;
}
setWidthPct(next);
},
[setWidthPct, close]
);

const onMouseUp = useCallback(() => {
if (!dragging.current) return;
dragging.current = false;
document.body.style.cursor = '';
}, []);

React.useEffect(() => {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [onMouseMove, onMouseUp]);

return (
<div className={`relative ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<div className="absolute inset-y-0 -left-[3px] w-[6px]" />
<div
role="separator"
aria-orientation="vertical"
className={`w-[6px] cursor-col-resize bg-transparent hover:bg-borderSubtle/60 active:bg-borderSubtle transition-colors`}
onMouseDown={onMouseDown}
title="Resize side panel"
/>
</div>
);
};

return (
<div className={`h-dvh`}>
{/* Padding top matches the app toolbar drag area height - can be removed for full bleed */}
<div
className={`flex flex-col ${backgroundColor} flex-1 min-w-0 h-full min-h-0 ${removeTopPadding ? '' : 'pt-[32px]'}`}
>
{children}
<SidecarProvider>
<div className={`h-dvh`}>
<div
id="main-panel-layout-container"
className={`flex ${backgroundColor} flex-1 min-w-0 h-full min-h-0`}
>
<div className={`flex flex-col flex-1 min-w-0 ${removeTopPadding ? '' : 'pt-[32px]'}`}>
{children}
</div>
<ResizableSeparator />
<SidecarPanel />
</div>
</div>
</div>
</SidecarProvider>
);
};
14 changes: 13 additions & 1 deletion ui/desktop/src/components/MCPUIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,21 @@ export default function MCPUIResourceRenderer({
): Promise<UIActionHandlerResult> => {
const { prompt } = actionEvent.payload;

// Validate prompt before forwarding
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
return {
status: 'error' as const,
error: {
code: UIActionErrorCode.INVALID_PARAMS,
message: 'Prompt must be a non-empty string',
details: actionEvent.payload,
},
};
}

if (appendPromptToChat) {
try {
appendPromptToChat(prompt);
appendPromptToChat(prompt.trim());
window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom'));
return {
status: 'success' as const,
Expand Down
110 changes: 110 additions & 0 deletions ui/desktop/src/components/Sidecar/SidecarContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import type { ResourceContent } from '../../types/message';

export type SidecarContent =
| { kind: 'none' }
| { kind: 'mcp-ui'; resource: ResourceContent; appendPromptToChat?: (value: string) => void };

type SidecarContextValue = {
isOpen: boolean;
content: SidecarContent;
widthPct: number;
setWidthPct: (pct: number) => void;
open: () => void;
openWithMCPUI: (payload: {
resource: ResourceContent;
appendPromptToChat?: (value: string) => void;
}) => void;
toggleMCPUI: (payload: {
resource: ResourceContent;
appendPromptToChat?: (value: string) => void;
}) => void;
close: () => void;
};

const SidecarContext = createContext<SidecarContextValue | null>(null);

export function useSidecar() {
const ctx = useContext(SidecarContext);
if (!ctx) throw new Error('useSidecar must be used within SidecarProvider');
return ctx;
}

export function SidecarProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [content, setContent] = useState<SidecarContent>({ kind: 'none' });
const [widthPct, _setWidthPct] = useState<number>(() => {
const stored = localStorage.getItem('sidecar_width_pct');
const value = stored ? parseFloat(stored) : 0.5;
if (Number.isFinite(value) && value > 0 && value < 1) return value;
return 0.5; // initial 50%
});

const setWidthPct = useCallback((pct: number) => {
// clamp between 0.1 and 0.75 (min enforced in panel by minWidth too)
const clamped = Math.max(0.1, Math.min(0.75, pct));
_setWidthPct(clamped);
try {
localStorage.setItem('sidecar_width_pct', String(clamped));
} catch {
/* ignore storage failures (private mode, etc.) */
}
}, []);

const close = useCallback(() => {
setIsOpen(false);
if (window?.electron && 'setSidecarOpen' in window.electron) {
// notify main process to restore window size
// @ts-expect-error exposed in preload
window.electron.setSidecarOpen(false);
}
}, []);

const open = useCallback(() => {
setIsOpen(true);
if (window?.electron && 'setSidecarOpen' in window.electron) {
// notify main process to enlarge window
// @ts-expect-error exposed in preload
window.electron.setSidecarOpen(true);
}
}, []);

const openWithMCPUI = useCallback(
(payload: { resource: ResourceContent; appendPromptToChat?: (value: string) => void }) => {
setContent({
kind: 'mcp-ui',
resource: payload.resource,
appendPromptToChat: payload.appendPromptToChat,
});
// Only resize window if sidecar wasn't already open
if (!isOpen && window?.electron && 'setSidecarOpen' in window.electron) {
console.log('Resizing window for sidecar open');
// notify main process to enlarge window
// @ts-expect-error exposed in preload
window.electron.setSidecarOpen(true);
}
setIsOpen(true);
},
[isOpen]
);

const toggleMCPUI = useCallback(
(payload: { resource: ResourceContent; appendPromptToChat?: (value: string) => void }) => {
const currentUri = content.kind === 'mcp-ui' ? content.resource.resource.uri : undefined;
const nextUri = payload.resource.resource.uri;
if (isOpen && currentUri && nextUri && currentUri === nextUri) {
close();
return;
}
openWithMCPUI(payload);
},
[content, isOpen, close, openWithMCPUI]
);

const value = useMemo<SidecarContextValue>(
() => ({ isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, toggleMCPUI, close }),
[isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, toggleMCPUI, close]
);

return <SidecarContext.Provider value={value}>{children}</SidecarContext.Provider>;
}
52 changes: 52 additions & 0 deletions ui/desktop/src/components/Sidecar/SidecarPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { useSidecar } from './SidecarContext';
import MCPUIResourceRenderer from '../MCPUIResourceRenderer';

export default function SidecarPanel() {
const { isOpen, content, close, widthPct } = useSidecar();

const style = useMemo(() => {
return isOpen
? {
width: `${Math.min(75, Math.max(10, widthPct * 100))}%`,
minWidth: 320,
}
: { width: 0 };
}, [isOpen, widthPct]);

return (
<div
className={`transition-[width,opacity] duration-200 ease-in-out bg-background-default border-l border-borderSubtle h-full ${
isOpen ? 'opacity-100' : 'opacity-0'
} overflow-hidden flex-shrink-0`}
style={style as React.CSSProperties}
aria-hidden={!isOpen}
>
{isOpen && (
<div className="h-full flex flex-col">
<div className="sticky top-0 z-100 flex items-center justify-between px-3 py-2 border-b border-borderSubtle bg-background-default/95 backdrop-blur supports-[backdrop-filter]:bg-background-default/70">
<div className="text-xs font-sans text-textSubtle uppercase tracking-wide">
MCP‑UI sidecar
</div>
<button
className="no-drag inline-flex items-center justify-center w-6 h-6 cursor-pointer rounded hover:bg-bgSubtle text-textSubtle hover:text-textStandard relative z-50 pointer-events-auto"
onClick={close}
aria-label="Close side panel"
title="Close"
>
×
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-3">
{content.kind === 'mcp-ui' && (
<MCPUIResourceRenderer
content={content.resource}
appendPromptToChat={content.appendPromptToChat}
/>
)}
</div>
</div>
)}
</div>
);
}
Loading
Loading