mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
Executable
BIN
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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user