Port MCP UI sidecar functionality from origin/mnovich/mcp-ui-sidecar

- Add SidecarContext and SidecarPanel components for MCP UI rendering
- Update MainPanelLayout to integrate resizable sidecar panel
- Modify ToolCallWithResponse to auto-open sidecar for UI resources
- Add Electron IPC support for sidecar window resizing
- Enable interactive MCP UI content in dedicated sidecar panel
This commit is contained in:
John Kennedy
2025-11-04 20:28:46 -08:00
parent 89f7384d57
commit e5ad72b675
7 changed files with 355 additions and 61 deletions
BIN
View File
Binary file not shown.
@@ -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' }) => {
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}
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>
</div>
);
};
return (
<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>
</SidecarProvider>
);
};
@@ -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>;
}
@@ -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">
MCPUI 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>
);
}
@@ -4,15 +4,20 @@ import React, { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import {
Content,
ToolRequestMessageContent,
ToolResponseMessageContent,
ResourceContent,
} from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import { LoadingStatus } from './ui/Dot';
import { NotificationEvent } from '../hooks/useMessageStream';
import { ChevronRight, FlaskConical } from 'lucide-react';
import { ChevronRight, SquareArrowOutUpRight } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
// Inline MCP-UI renderer is unused now (sidecar only)
import { isUIResource } from '@mcp-ui/client';
import { Content, EmbeddedResource } from '../api';
import { useSidecar } from './Sidecar/SidecarContext';
interface ToolCallWithResponseProps {
isCancelledMessage: boolean;
@@ -39,60 +44,83 @@ export default function ToolCallWithResponse({
toolRequest,
toolResponse,
notifications,
isStreamingMessage,
isStreamingMessage = false,
append,
}: ToolCallWithResponseProps) {
// Handle both the wrapped ToolResult format and the unwrapped format
// The server serializes ToolResult<T> as { status: "success", value: T } or { status: "error", error: string }
const toolCallData = toolRequest.toolCall as Record<string, unknown>;
const toolCall =
toolCallData?.status === 'success'
? (toolCallData.value as { name: string; arguments: Record<string, unknown> })
: (toolCallData as { name: string; arguments: Record<string, unknown> });
const sidecar = useSidecar();
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
// Always mount the component to keep hooks order consistent. Render nothing if no toolCall.
const shouldRender = !!toolCall;
if (!toolCall || !toolCall.name) {
return null;
}
// Find UI resource in tool response
const ui = (toolResponse?.toolResult?.value || []).find((c) => isUIResource(c));
// Track if we've already auto-opened for this tool call to prevent double-firing
const hasAutoOpened = React.useRef(false);
// Auto-open sidecar when tool finishes and contains UI resource
React.useEffect(() => {
// Reset flag when streaming starts (new tool call)
if (isStreamingMessage) {
hasAutoOpened.current = false;
return;
}
// Auto-open when tool finishes with UI resource
if (ui && isUIResource(ui) && !hasAutoOpened.current) {
hasAutoOpened.current = true;
// Small delay to ensure the tool response is fully processed
const timer = setTimeout(() => {
sidecar.openWithMCPUI({
resource: ui as ResourceContent,
appendPromptToChat: append,
});
}, 100);
return () => clearTimeout(timer);
}
// Explicit return for TypeScript when no conditions are met
return undefined;
}, [ui, isStreamingMessage, append, sidecar]);
return (
<>
<div
className={cn(
'w-full text-sm font-sans rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
)}
>
<ToolCallView
{...{
isCancelledMessage,
toolCall,
toolResponse,
notifications,
isStreamingMessage,
}}
/>
</div>
{/* MCP UI — Inline */}
{toolResponse?.toolResult &&
getToolResultValue(toolResponse.toolResult)?.map((content, index) => {
const resourceContent = isEmbeddedResource(content)
? { ...content, type: 'resource' as const }
: null;
if (resourceContent && isUIResource(resourceContent)) {
return (
<div key={index} className="mt-3">
<MCPUIResourceRenderer content={resourceContent} appendPromptToChat={append} />
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
MCP UI is experimental and may change at any time.
</div>
</div>
</div>
);
} else {
return null;
}
})}
{shouldRender && (
<div className="relative ">
<div
className={cn(
'w-full text-sm font-sans rounded-lg overflow-hidden border-borderSubtle border bg-background-default p-3'
)}
>
<ToolCallView
{...{
isCancelledMessage,
toolCall: toolCall!,
toolResponse,
notifications,
isStreamingMessage,
openInSidecar: (resource: ResourceContent) =>
sidecar.openWithMCPUI({ resource, appendPromptToChat: append }),
}}
/>
</div>
{ui && isUIResource(ui) ? (
<button
className="absolute right-[-40px] top-0 z-10 p-2 rounded bg-background-default/95 border border-borderSubtle shadow hover:bg-bgSubtle cursor-pointer"
title="Open in side panel"
aria-label="Open in side panel"
onClick={() => {
sidecar.openWithMCPUI({
resource: ui as ResourceContent,
appendPromptToChat: append,
});
}}
>
<SquareArrowOutUpRight size={14} />
</button>
) : null}
</div>
)}
</>
);
}
+27
View File
@@ -10,6 +10,7 @@ import {
MenuItem,
Notification,
powerSaveBlocker,
screen,
session,
shell,
Tray,
@@ -2187,6 +2188,32 @@ async function appMain() {
app.exit(0);
});
// Handle sidecar toggling to resize window
ipcMain.on('sidecar-toggled', (event, isOpen: boolean) => {
try {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const bounds = win.getBounds();
const TARGET_DELTA = 420; // pixels reserved for sidecar
if (isOpen) {
// Enlarge window width by TARGET_DELTA up to available screen
const display = screen.getDisplayMatching(bounds);
const maxWidth = display.workArea.width;
const newWidth = Math.min(bounds.width + TARGET_DELTA, maxWidth);
if (newWidth !== bounds.width) {
win.setSize(newWidth, bounds.height, true);
}
} else {
const newWidth = Math.max(bounds.width - TARGET_DELTA, 750);
if (newWidth !== bounds.width) {
win.setSize(newWidth, bounds.height, true);
}
}
} catch (e) {
console.error('Failed to handle sidecar toggle resize', e);
}
});
// Handler for getting app version
ipcMain.on('get-app-version', (event) => {
event.returnValue = app.getVersion();
+3
View File
@@ -120,6 +120,8 @@ type ElectronAPI = {
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
// Sidecar sizing
setSidecarOpen?: (isOpen: boolean) => void;
};
type AppConfigAPI = {
@@ -256,6 +258,7 @@ const electronAPI: ElectronAPI = {
recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe),
openDirectoryInExplorer: (directoryPath: string) =>
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
setSidecarOpen: (isOpen: boolean) => ipcRenderer.send('sidecar-toggled', isOpen),
};
const appConfigAPI: AppConfigAPI = {