refactor(chat): streamline AssistantMessage and ToolInvocations components
- Moved the Markdown rendering for content in AssistantMessage to a new position for better structure. - Updated ToolInvocations component to enhance UI with improved spacing and keyboard shortcut handling for tool execution. - Added state management for expanded tool details and integrated keyboard shortcuts for approving and rejecting tool calls.
This commit is contained in:
@@ -176,6 +176,9 @@ export const AssistantMessage = memo(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
||||||
|
{content}
|
||||||
|
</Markdown>
|
||||||
{toolInvocations && toolInvocations.length > 0 && (
|
{toolInvocations && toolInvocations.length > 0 && (
|
||||||
<ToolInvocations
|
<ToolInvocations
|
||||||
toolInvocations={toolInvocations}
|
toolInvocations={toolInvocations}
|
||||||
@@ -183,9 +186,6 @@ export const AssistantMessage = memo(
|
|||||||
addToolResult={addToolResult}
|
addToolResult={addToolResult}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
|
||||||
{content}
|
|
||||||
</Markdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
import type { ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState, useEffect } from 'react';
|
||||||
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
|
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import {
|
import {
|
||||||
@@ -102,18 +102,17 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tool-invocation border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150 mt-4 mb-4">
|
<div className="tool-invocation border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
|
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
aria-label={showDetails ? 'Collapse details' : 'Expand details'}
|
aria-label={showDetails ? 'Collapse details' : 'Expand details'}
|
||||||
>
|
>
|
||||||
<div className="p-2">
|
<div className="p-2.5">
|
||||||
<div className="i-ph:wrench text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"></div>
|
<div className="i-ph:wrench text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
<div className="border-l border-bolt-elements-borderColor p-2.5 w-full text-left">
|
||||||
<div className="px-5 p-2 w-full text-left">
|
|
||||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
||||||
MCP Tool Invocations{' '}
|
MCP Tool Invocations{' '}
|
||||||
{hasToolResults && (
|
{hasToolResults && (
|
||||||
@@ -124,7 +123,6 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasToolResults && (
|
{hasToolResults && (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -155,7 +153,7 @@ export const ToolInvocations = memo(({ toolInvocations, toolCallAnnotations, add
|
|||||||
>
|
>
|
||||||
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
||||||
|
|
||||||
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
<div className="px-3 py-3 text-left bg-bolt-elements-actions-background">
|
||||||
<ToolCallsList
|
<ToolCallsList
|
||||||
toolInvocations={toolCalls}
|
toolInvocations={toolCalls}
|
||||||
toolCallAnnotations={toolCallAnnotations}
|
toolCallAnnotations={toolCallAnnotations}
|
||||||
@@ -276,6 +274,71 @@ interface ToolCallsListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ToolCallsList = memo(({ toolInvocations, toolCallAnnotations, addToolResult, theme }: ToolCallsListProps) => {
|
const ToolCallsList = memo(({ toolInvocations, toolCallAnnotations, addToolResult, theme }: ToolCallsListProps) => {
|
||||||
|
const [expanded, setExpanded] = useState<{ [id: string]: boolean }>({});
|
||||||
|
|
||||||
|
// OS detection for shortcut display
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
|
||||||
|
const toggleExpand = (toolCallId: string) => {
|
||||||
|
setExpanded((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[toolCallId]: !prev[toolCallId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const expandedState: { [id: string]: boolean } = {};
|
||||||
|
toolInvocations.forEach((inv) => {
|
||||||
|
if (inv.toolInvocation.state === 'call') {
|
||||||
|
expandedState[inv.toolInvocation.toolCallId] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setExpanded(expandedState);
|
||||||
|
}, [toolInvocations]);
|
||||||
|
|
||||||
|
// Keyboard shortcut logic
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if focus is in an input/textarea/contenteditable
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(expanded).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openId = Object.keys(expanded).find((id) => expanded[id]);
|
||||||
|
|
||||||
|
if (!openId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel: Cmd/Ctrl + Backspace
|
||||||
|
if ((isMac ? e.metaKey : e.ctrlKey) && e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
addToolResult({
|
||||||
|
toolCallId: openId,
|
||||||
|
result: TOOL_EXECUTION_APPROVAL.REJECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tool: Cmd/Ctrl + Enter
|
||||||
|
if ((isMac ? e.metaKey : e.ctrlKey) && (e.key === 'Enter' || e.key === 'Return')) {
|
||||||
|
e.preventDefault();
|
||||||
|
addToolResult({
|
||||||
|
toolCallId: openId,
|
||||||
|
result: TOOL_EXECUTION_APPROVAL.APPROVE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [expanded, addToolResult, isMac]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
<ul className="list-none space-y-4">
|
<ul className="list-none space-y-4">
|
||||||
@@ -287,10 +350,7 @@ const ToolCallsList = memo(({ toolInvocations, toolCallAnnotations, addToolResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { toolName, toolCallId } = tool.toolInvocation;
|
const { toolName, toolCallId } = tool.toolInvocation;
|
||||||
|
const annotation = toolCallAnnotations.find((annotation) => annotation.toolCallId === toolCallId);
|
||||||
const annotation = toolCallAnnotations.find((annotation) => {
|
|
||||||
return annotation.toolCallId === toolCallId;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.li
|
<motion.li
|
||||||
@@ -298,51 +358,55 @@ const ToolCallsList = memo(({ toolInvocations, toolCallAnnotations, addToolResul
|
|||||||
variants={toolVariants}
|
variants={toolVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
transition={{
|
transition={{ duration: 0.2, ease: cubicEasingFn }}
|
||||||
duration: 0.2,
|
|
||||||
ease: cubicEasingFn,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="ml-6 mb-2">
|
<div className="">
|
||||||
<div key={toolCallId}>
|
<div key={toolCallId} className="flex flex-col gap-1">
|
||||||
<div className="text-bolt-elements-textPrimary">Bolt wants to use a tool.</div>
|
<div className="flex items-center text-bolt-elements-textSecondary font-semibold text-sm">
|
||||||
<div className="text-bolt-elements-textSecondary text-xs mb-1">
|
|
||||||
Server:{' '}
|
|
||||||
<span className="text-bolt-elements-textPrimary font-semibold">{annotation?.serverName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-bolt-elements-textSecondary text-xs mb-1">
|
|
||||||
Tool: <span className="text-bolt-elements-textPrimary font-semibold">{toolName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-bolt-elements-textSecondary text-xs mb-1">
|
|
||||||
Description:{' '}
|
|
||||||
<span className="text-bolt-elements-textPrimary font-semibold">{annotation?.toolDescription}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-bolt-elements-textSecondary text-xs mb-1">Parameters:</div>
|
|
||||||
<div className="bg-[#FAFAFA] dark:bg-[#0A0A0A] p-3 rounded-md">
|
|
||||||
<JsonCodeBlock className="mb-0" code={JSON.stringify(tool.toolInvocation.args)} theme={theme} />
|
|
||||||
</div>{' '}
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
onClick={() => toggleExpand(toolCallId)}
|
||||||
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
className="mr-1 focus:outline-none bg-transparent"
|
||||||
'bg-purple-500 hover:bg-purple-600',
|
aria-label={expanded[toolCallId] ? 'Collapse' : 'Expand'}
|
||||||
'text-white',
|
tabIndex={0}
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
type="button"
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
addToolResult({
|
|
||||||
toolCallId,
|
|
||||||
result: TOOL_EXECUTION_APPROVAL.APPROVE,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Approve
|
<span
|
||||||
|
className={`i-ph:caret-down-bold inline-block transition-transform duration-150 ${expanded[toolCallId] ? '' : '-rotate-90'}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
Calling MCP tool{' '}
|
||||||
|
<span className="ml-0.5 font-light font-mono text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3 px-1.5 py-0.5 rounded-md">
|
||||||
|
{toolName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expanded[toolCallId] && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-[0.1px] min-h-[40px] bg-bolt-elements-background-depth-3 ml-1.5" />
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<div className="text-bolt-elements-textSecondary text-xs mb-1">
|
||||||
|
Description:{' '}
|
||||||
|
<span className="text-bolt-elements-textPrimary font-semibold">
|
||||||
|
{annotation?.toolDescription}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-stretch space-x-2">
|
||||||
|
<div className="w-full rounded-md bg-bolt-elements-background-depth-3 p-3 ml-0 border-l-2 border-bolt-elements-borderColor">
|
||||||
|
<JsonCodeBlock
|
||||||
|
className="mb-0"
|
||||||
|
code={JSON.stringify(tool.toolInvocation.args, null, 2)}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-2.5">
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'px-3 py-1.5 rounded-lg text-sm',
|
'px-2.5 py-1.5 rounded-lg text-xs',
|
||||||
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
|
'bg-transparent',
|
||||||
'text-bolt-elements-textPrimary',
|
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
||||||
'transition-all duration-200',
|
'transition-all duration-200',
|
||||||
'flex items-center gap-2',
|
'flex items-center gap-2',
|
||||||
)}
|
)}
|
||||||
@@ -353,7 +417,23 @@ const ToolCallsList = memo(({ toolInvocations, toolCallAnnotations, addToolResul
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Reject
|
Cancel <span className="opacity-70 text-xs ml-1">{isMac ? '⌘⌫' : 'Ctrl+Backspace'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
'inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-normal rounded-lg transition-colors',
|
||||||
|
'bg-accent-500 hover:bg-accent-600',
|
||||||
|
'text-black',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
addToolResult({
|
||||||
|
toolCallId,
|
||||||
|
result: TOOL_EXECUTION_APPROVAL.APPROVE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Run tool <span className="opacity-70 text-xs ml-1">{isMac ? '⌘↵' : 'Ctrl+Enter'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user