From 17f347dcfc1dec0defe321a4a107945da75a26db Mon Sep 17 00:00:00 2001 From: Max Novich Date: Thu, 21 Aug 2025 15:07:24 -0700 Subject: [PATCH 1/6] chore(ui): silence unused catch var to satisfy eslint zero-warning policy --- .../src/components/Layout/MainPanelLayout.tsx | 90 +++++++++++++++-- .../src/components/Sidecar/SidecarContext.tsx | 91 +++++++++++++++++ .../src/components/Sidecar/SidecarPanel.tsx | 52 ++++++++++ .../src/components/ToolCallWithResponse.tsx | 99 +++++++++++-------- ui/desktop/src/main.ts | 27 +++++ ui/desktop/src/preload.ts | 5 + 6 files changed, 314 insertions(+), 50 deletions(-) create mode 100644 ui/desktop/src/components/Sidecar/SidecarContext.tsx create mode 100644 ui/desktop/src/components/Sidecar/SidecarPanel.tsx diff --git a/ui/desktop/src/components/Layout/MainPanelLayout.tsx b/ui/desktop/src/components/Layout/MainPanelLayout.tsx index 29e169797674..158806f458d0 100644 --- a/ui/desktop/src/components/Layout/MainPanelLayout.tsx +++ b/ui/desktop/src/components/Layout/MainPanelLayout.tsx @@ -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 ( +
+
+
+
+ ); + }; + return ( -
- {/* Padding top matches the app toolbar drag area height - can be removed for full bleed */} -
- {children} + +
+
+
+ {children} +
+ + +
-
+ ); }; diff --git a/ui/desktop/src/components/Sidecar/SidecarContext.tsx b/ui/desktop/src/components/Sidecar/SidecarContext.tsx new file mode 100644 index 000000000000..2f199f138bcb --- /dev/null +++ b/ui/desktop/src/components/Sidecar/SidecarContext.tsx @@ -0,0 +1,91 @@ +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; + close: () => void; +}; + +const SidecarContext = createContext(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({ kind: 'none' }); + const [widthPct, _setWidthPct] = useState(() => { + 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, + }); + 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 value = useMemo( + () => ({ isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, close }), + [isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, close] + ); + + return {children}; +} diff --git a/ui/desktop/src/components/Sidecar/SidecarPanel.tsx b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx new file mode 100644 index 000000000000..6610d4174fd1 --- /dev/null +++ b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx @@ -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 ( +
+ {isOpen && ( +
+
+
+ MCP‑UI sidecar +
+ +
+
+ {content.kind === 'mcp-ui' && ( + + )} +
+
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 85b461be1247..bfcac8a90bd6 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -4,14 +4,20 @@ import React, { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments'; import MarkdownContent from './MarkdownContent'; -import { Content, 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, LoaderCircle } 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 { useSidecar } from './Sidecar/SidecarContext'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -30,47 +36,34 @@ export default function ToolCallWithResponse({ isStreamingMessage = false, append, }: ToolCallWithResponseProps) { + const sidecar = useSidecar(); const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; - if (!toolCall) { - return null; - } + // Always mount the component to keep hooks order consistent. Render nothing if no toolCall. + const shouldRender = !!toolCall; + + // Manual open via per-result action; no auto-open to allow selection return ( <> -
- -
- {/* MCP UI — Inline */} - {toolResponse?.toolResult?.value && - toolResponse.toolResult.value.map((content, index) => { - if (isUIResource(content)) { - return ( -
- -
- -
- MCP UI is experimental and may change at any time. -
-
-
- ); - } else { - return null; - } - })} + {shouldRender && ( +
+ + sidecar.openWithMCPUI({ resource, appendPromptToChat: append }), + }} + /> +
+ )} ); } @@ -126,6 +119,7 @@ interface ToolCallViewProps { toolResponse?: ToolResponseMessageContent; notifications?: NotificationEvent[]; isStreamingMessage?: boolean; + openInSidecar: (resource: ResourceContent) => void; } interface Progress { @@ -172,6 +166,7 @@ function ToolCallView({ toolResponse, notifications, isStreamingMessage = false, + openInSidecar, }: ToolCallViewProps) { const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style')); @@ -513,7 +508,15 @@ function ToolCallView({ {toolResults.map(({ result, isExpandToolResults }, index) => { return (
- + openInSidecar(result as ResourceContent) + : undefined + } + />
); })} @@ -549,15 +552,27 @@ function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) { interface ToolResultViewProps { result: Content; isStartExpanded: boolean; + onOpenInSidecar?: () => void; } -function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { +function ToolResultView({ result, isStartExpanded, onOpenInSidecar }: ToolResultViewProps) { return ( Output} isStartExpanded={isStartExpanded} >
+ {onOpenInSidecar && ( +
+ +
+ )} {result.type === 'text' && result.text && ( { 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.setBounds({ ...bounds, width: newWidth }); + } + } else { + const newWidth = Math.max(bounds.width - TARGET_DELTA, 750); + if (newWidth !== bounds.width) { + win.setBounds({ ...bounds, width: newWidth }); + } + } + } 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(); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index b6ce7a9d6abe..afdc546a072a 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -113,6 +113,8 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise; recordRecipeHash: (recipeConfig: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; + // Sidecar sizing + setSidecarOpen?: (isOpen: boolean) => void; }; type AppConfigAPI = { @@ -236,6 +238,9 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('record-recipe-hash', recipeConfig), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + setSidecarOpen: (isOpen: boolean) => { + ipcRenderer.send('sidecar-toggled', isOpen); + }, }; const appConfigAPI: AppConfigAPI = { From 0ef60ba76e65b23bfdcc3b775b1d61d328c2d99c Mon Sep 17 00:00:00 2001 From: Max Novich Date: Thu, 21 Aug 2025 15:36:51 -0700 Subject: [PATCH 2/6] feat(ui): floating sidecar toggle button; clean up inline button; style toolcall card; sidecar toggle logic --- .../src/components/Sidecar/SidecarContext.tsx | 21 ++++- .../src/components/Sidecar/SidecarPanel.tsx | 2 +- .../src/components/ToolCallWithResponse.tsx | 85 ++++++++++--------- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/ui/desktop/src/components/Sidecar/SidecarContext.tsx b/ui/desktop/src/components/Sidecar/SidecarContext.tsx index 2f199f138bcb..e8e87448943e 100644 --- a/ui/desktop/src/components/Sidecar/SidecarContext.tsx +++ b/ui/desktop/src/components/Sidecar/SidecarContext.tsx @@ -15,6 +15,10 @@ type SidecarContextValue = { resource: ResourceContent; appendPromptToChat?: (value: string) => void; }) => void; + toggleMCPUI: (payload: { + resource: ResourceContent; + appendPromptToChat?: (value: string) => void; + }) => void; close: () => void; }; @@ -82,9 +86,22 @@ export function SidecarProvider({ children }: { children: React.ReactNode }) { [] ); + 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( - () => ({ isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, close }), - [isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, close] + () => ({ isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, toggleMCPUI, close }), + [isOpen, content, widthPct, setWidthPct, open, openWithMCPUI, toggleMCPUI, close] ); return {children}; diff --git a/ui/desktop/src/components/Sidecar/SidecarPanel.tsx b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx index 6610d4174fd1..868c354a38b3 100644 --- a/ui/desktop/src/components/Sidecar/SidecarPanel.tsx +++ b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx @@ -16,7 +16,7 @@ export default function SidecarPanel() { return (
{shouldRender && ( -
- - sidecar.openWithMCPUI({ resource, appendPromptToChat: append }), - }} - /> +
+
+ + sidecar.toggleMCPUI({ resource, appendPromptToChat: append }), + }} + /> +
+ {(() => { + const ui = (toolResponse?.toolResult?.value || []).find((c) => isUIResource(c)); + return ui && isUIResource(ui) ? ( + + ) : null; + })()}
)} @@ -119,7 +144,6 @@ interface ToolCallViewProps { toolResponse?: ToolResponseMessageContent; notifications?: NotificationEvent[]; isStreamingMessage?: boolean; - openInSidecar: (resource: ResourceContent) => void; } interface Progress { @@ -166,7 +190,6 @@ function ToolCallView({ toolResponse, notifications, isStreamingMessage = false, - openInSidecar, }: ToolCallViewProps) { const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style')); @@ -508,15 +531,7 @@ function ToolCallView({ {toolResults.map(({ result, isExpandToolResults }, index) => { return (
- openInSidecar(result as ResourceContent) - : undefined - } - /> +
); })} @@ -552,27 +567,15 @@ function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) { interface ToolResultViewProps { result: Content; isStartExpanded: boolean; - onOpenInSidecar?: () => void; } -function ToolResultView({ result, isStartExpanded, onOpenInSidecar }: ToolResultViewProps) { +function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { return ( Output} isStartExpanded={isStartExpanded} >
- {onOpenInSidecar && ( -
- -
- )} {result.type === 'text' && result.text && ( Date: Thu, 21 Aug 2025 17:14:47 -0700 Subject: [PATCH 3/6] chore(ui): MCP-UI prompt validation; floating sidecar toggle positioning; sidecar toggle logic --- .../src/components/MCPUIResourceRenderer.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 3e5ad494a564..b4942e74b05e 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -160,9 +160,21 @@ export default function MCPUIResourceRenderer({ ): Promise => { 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, From daa8fd23664355c58449176c9a4f06813eaea7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=96=B2PETE?= Date: Thu, 28 Aug 2025 16:59:47 -0700 Subject: [PATCH 4/6] auto-open and close button fix --- .../src/components/Sidecar/SidecarContext.tsx | 8 +- .../src/components/Sidecar/SidecarPanel.tsx | 4 +- .../src/components/ToolCallWithResponse.tsx | 73 ++++++++++++------- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/ui/desktop/src/components/Sidecar/SidecarContext.tsx b/ui/desktop/src/components/Sidecar/SidecarContext.tsx index e8e87448943e..7a52388d76a5 100644 --- a/ui/desktop/src/components/Sidecar/SidecarContext.tsx +++ b/ui/desktop/src/components/Sidecar/SidecarContext.tsx @@ -76,14 +76,16 @@ export function SidecarProvider({ children }: { children: React.ReactNode }) { resource: payload.resource, appendPromptToChat: payload.appendPromptToChat, }); - setIsOpen(true); - if (window?.electron && 'setSidecarOpen' in window.electron) { + // 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( diff --git a/ui/desktop/src/components/Sidecar/SidecarPanel.tsx b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx index 868c354a38b3..b9797b6cf115 100644 --- a/ui/desktop/src/components/Sidecar/SidecarPanel.tsx +++ b/ui/desktop/src/components/Sidecar/SidecarPanel.tsx @@ -24,12 +24,12 @@ export default function SidecarPanel() { > {isOpen && (
-
+
MCP‑UI sidecar
- ) : null; - })()} + {ui && isUIResource(ui) ? ( + + ) : null}
)} From 836c7906b1bcb6231b9e97098506c23f9d827a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=96=B2PETE?= Date: Fri, 29 Aug 2025 14:23:53 -0700 Subject: [PATCH 5/6] use setSize to animate size-change instead of setBounds --- ui/desktop/src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 810e8fc7cdf5..b82274420910 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2153,12 +2153,12 @@ app.whenReady().then(async () => { const maxWidth = display.workArea.width; const newWidth = Math.min(bounds.width + TARGET_DELTA, maxWidth); if (newWidth !== bounds.width) { - win.setBounds({ ...bounds, width: newWidth }); + win.setSize(newWidth, bounds.height, true); } } else { const newWidth = Math.max(bounds.width - TARGET_DELTA, 750); if (newWidth !== bounds.width) { - win.setBounds({ ...bounds, width: newWidth }); + win.setSize(newWidth, bounds.height, true); } } } catch (e) { From 1bfff0f06b9d5756f7ba9970a30fee5a549f3788 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Fri, 5 Sep 2025 13:10:00 -0700 Subject: [PATCH 6/6] fix lint error --- ui/desktop/src/components/ToolCallWithResponse.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index f4e1e66e9bd3..2bbad80857e1 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -13,7 +13,7 @@ import { import { cn, snakeToTitleCase } from '../utils'; import { LoadingStatus } from './ui/Dot'; import { NotificationEvent } from '../hooks/useMessageStream'; -import { ChevronRight, LoaderCircle, SquareArrowOutUpRight } from 'lucide-react'; +import { ChevronRight, SquareArrowOutUpRight } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; // Inline MCP-UI renderer is unused now (sidecar only) import { isUIResource } from '@mcp-ui/client';