consolidate tools

This commit is contained in:
Arjun 2026-04-07 09:15:05 +05:30
parent 71c848006c
commit 61fadad5e0
7 changed files with 462 additions and 147 deletions

View file

@ -38,9 +38,9 @@ import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/a
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
import { ToolCallStack } from '@/components/ai-elements/tool-call-stack';
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import {
SidebarInset,
@ -67,6 +67,7 @@ import {
type ConversationItem,
type ToolCall,
createEmptyChatTabViewState,
groupConversationItems,
getWebSearchCardData,
getAppActionCardData,
getComposioConnectCardData,
@ -842,6 +843,7 @@ function App() {
const chatDraftsRef = useRef(new Map<string, string>())
const chatScrollTopByTabRef = useRef(new Map<string, number>())
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
const [toolGroupOpenByTab, setToolGroupOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
const activeChatTabIdRef = useRef(activeChatTabId)
activeChatTabIdRef.current = activeChatTabId
@ -868,6 +870,22 @@ function App() {
}
})
}, [])
const getToolGroupOpenForTab = useCallback((tabId: string, groupId: string): boolean | undefined => {
return toolGroupOpenByTab[tabId]?.[groupId]
}, [toolGroupOpenByTab])
const setToolGroupOpenForTab = useCallback((tabId: string, groupId: string, open: boolean) => {
setToolGroupOpenByTab((prev) => {
const prevForTab = prev[tabId] ?? {}
if (prevForTab[groupId] === open) return prev
return {
...prev,
[tabId]: {
...prevForTab,
[groupId]: open,
},
}
})
}, [])
const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => {
setChatViewportAnchorByTab((prev) => {
const prevForTab = prev[tabId]
@ -997,6 +1015,22 @@ function App() {
})
}, [chatTabs])
useEffect(() => {
const tabIds = new Set(chatTabs.map((tab) => tab.id))
setToolGroupOpenByTab((prev) => {
let changed = false
const next: Record<string, Record<string, boolean>> = {}
for (const [tabId, state] of Object.entries(prev)) {
if (tabIds.has(tabId)) {
next[tabId] = state
} else {
changed = true
}
}
return changed ? next : prev
})
}, [chatTabs])
// Workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
@ -2477,6 +2511,12 @@ function App() {
delete next[tabId]
return next
})
setToolGroupOpenByTab((prev) => {
if (!(tabId in prev)) return prev
const next = { ...prev }
delete next[tabId]
return next
})
if (tabId === activeChatTabId && nextTabs.length > 0) {
const newIdx = Math.min(idx, nextTabs.length - 1)
@ -3808,6 +3848,63 @@ function App() {
}
}, [isGraphOpen, knowledgeFilePaths])
const renderToolCallItem = (item: ToolCall, tabId: string, className?: string) => {
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} className={cn('mb-0', className)} data={appActionData} status={item.status} />
}
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
className={cn('mb-0', className)}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
// Skip rendering if this is a duplicate "already connected" card
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
className={cn('mb-0', className)}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool
key={item.id}
className={cn('mb-0', className)}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader
title={toolTitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
}
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
@ -3854,57 +3951,7 @@ function App() {
}
if (isToolCall(item)) {
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
}
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
// Skip rendering if this is a duplicate "already connected" card
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool
key={item.id}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader
title={toolTitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
return renderToolCallItem(item, tabId)
}
if (isErrorMessage(item)) {
@ -4369,29 +4416,23 @@ function App() {
</ConversationEmptyState>
) : (
<>
{tabState.conversation.map(item => {
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item)) {
const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment>
)
}
{groupConversationItems(tabState.conversation).map((block) => {
if (block.kind === 'tool-group') {
return (
<ToolCallStack
key={block.key}
tools={block.tools}
open={getToolGroupOpenForTab(tab.id, block.key)}
onOpenChange={(open) => setToolGroupOpenForTab(tab.id, block.key, open)}
allPermissionRequests={tabState.allPermissionRequests}
permissionResponses={tabState.permissionResponses}
isProcessing={isActive && isProcessing}
onPermissionResponse={handlePermissionResponse}
renderToolCall={(tool) => renderToolCallItem(tool, tab.id)}
/>
)
}
return rendered
return renderConversationItem(block.item, tab.id)
})}
{Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
@ -4518,6 +4559,8 @@ function App() {
onAskHumanResponse={handleAskHumanResponse}
isToolOpenForTab={isToolOpenForTab}
onToolOpenChangeForTab={setToolOpenForTab}
getToolGroupOpenForTab={getToolGroupOpenForTab}
onToolGroupOpenChangeForTab={setToolGroupOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
onActivate={() => setActiveShortcutPane('right')}
isRecording={isRecording}

View file

@ -1,5 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import {
CheckCircleIcon,
FileTextIcon,
@ -10,8 +11,9 @@ import {
PlusCircleIcon,
} from "lucide-react";
import type { AppActionCardData } from "@/lib/chat-conversation";
import type { ComponentProps } from "react";
interface AppActionCardProps {
interface AppActionCardProps extends ComponentProps<"div"> {
data: AppActionCardData;
status: "pending" | "running" | "completed" | "error";
}
@ -23,12 +25,15 @@ const actionIcons: Record<string, React.ReactNode> = {
"create-base": <PlusCircleIcon className="size-4" />,
};
export function AppActionCard({ data, status }: AppActionCardProps) {
export function AppActionCard({ className, data, status, ...props }: AppActionCardProps) {
const isRunning = status === "pending" || status === "running";
const isError = status === "error";
return (
<div className="not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2">
<div
className={cn("not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2", className)}
{...props}
>
<span className="text-muted-foreground">
{actionIcons[data.action] || <LayoutGridIcon className="size-4" />}
</span>

View file

@ -8,8 +8,10 @@ import {
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
interface ComposioConnectCardProps {
interface ComposioConnectCardProps extends ComponentProps<"div"> {
toolkitSlug: string;
toolkitDisplayName: string;
status: "pending" | "running" | "completed" | "error";
@ -18,11 +20,13 @@ interface ComposioConnectCardProps {
}
export function ComposioConnectCard({
className,
toolkitSlug,
toolkitDisplayName,
status,
alreadyConnected,
onConnected,
...props
}: ComposioConnectCardProps) {
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "connected" | "error"
@ -73,7 +77,10 @@ export function ComposioConnectCard({
const displayName = toolkitDisplayName || toolkitSlug;
return (
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
<div
className={cn("not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5", className)}
{...props}
>
{/* Toolkit initial */}
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-muted-foreground">

View file

@ -0,0 +1,191 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ToolPermissionRequestEvent } from "@x/shared/src/runs.js";
import {
AlertTriangleIcon,
CheckCircleIcon,
ChevronDownIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import z from "zod";
import {
type PermissionResponse,
type ToolCall,
getToolDisplayName,
} from "@/lib/chat-conversation";
import { PermissionRequest } from "./permission-request";
type PermissionScope = "once" | "session" | "always";
export type ToolCallStackProps = ComponentProps<typeof Collapsible> & {
tools: ToolCall[];
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>;
permissionResponses?: Map<string, PermissionResponse>;
isProcessing?: boolean;
onPermissionResponse?: (
toolCallId: string,
subflow: string[],
response: PermissionResponse,
scope?: PermissionScope,
) => void;
renderToolCall: (tool: ToolCall) => ReactNode;
};
const GROUP_PREVIEW_LIMIT = 3;
const getGroupPreview = (tools: ToolCall[]) => {
const labels = Array.from(new Set(tools.map((tool) => getToolDisplayName(tool))));
if (labels.length === 0) return "No tool activity";
if (labels.length <= GROUP_PREVIEW_LIMIT) return labels.join(" • ");
return `${labels.slice(0, GROUP_PREVIEW_LIMIT).join(" • ")} • +${labels.length - GROUP_PREVIEW_LIMIT} more`;
};
const getGroupStatus = (
tools: ToolCall[],
hasPendingPermission: boolean,
): {
icon: ReactNode;
label: string;
className: string;
} => {
if (hasPendingPermission) {
return {
icon: <AlertTriangleIcon className="size-3.5 text-amber-600" />,
label: "Awaiting approval",
className: "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300",
};
}
if (tools.some((tool) => tool.status === "error")) {
return {
icon: <XCircleIcon className="size-3.5 text-red-600" />,
label: "Error",
className: "bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300",
};
}
if (tools.some((tool) => tool.status === "pending" || tool.status === "running")) {
return {
icon: <ClockIcon className="size-3.5 animate-pulse text-blue-600" />,
label: "Running",
className: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300",
};
}
return {
icon: <CheckCircleIcon className="size-3.5 text-green-600" />,
label: "Completed",
className: "bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-300",
};
};
export const ToolCallStack = ({
tools,
allPermissionRequests = new Map(),
permissionResponses = new Map(),
isProcessing = false,
onPermissionResponse,
renderToolCall,
className,
open,
onOpenChange,
...props
}: ToolCallStackProps) => {
const hasPendingPermission = tools.some((tool) => {
if (!allPermissionRequests.has(tool.id)) return false;
return !permissionResponses.has(tool.id);
});
const defaultOpen =
hasPendingPermission ||
tools.some((tool) => tool.status === "pending" || tool.status === "running");
const effectiveOpen = hasPendingPermission || (open ?? defaultOpen);
const groupStatus = getGroupStatus(tools, hasPendingPermission);
const handleOpenChange = (nextOpen: boolean) => {
if (hasPendingPermission && !nextOpen) return;
onOpenChange?.(nextOpen);
};
return (
<Collapsible
className={cn(
"not-prose mb-4 w-full overflow-hidden rounded-xl border bg-background/90 shadow-xs",
className,
)}
open={effectiveOpen}
onOpenChange={handleOpenChange}
{...props}
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-muted/20">
<div className="min-w-0">
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">Tool activity</span>
<Badge variant="secondary" className={cn("gap-1.5 rounded-full text-xs", groupStatus.className)}>
{groupStatus.icon}
{groupStatus.label}
</Badge>
</div>
<p className="mt-1 truncate text-muted-foreground text-xs">
{getGroupPreview(tools)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="outline" className="rounded-full px-2 py-0 text-xs">
{tools.length}
</Badge>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=open]:animate-in">
<div className="border-t bg-muted/10 p-3">
<div className="space-y-3">
{tools.map((tool) => {
const permissionRequest = allPermissionRequests.get(tool.id);
const response = permissionResponses.get(tool.id) ?? null;
const renderedTool = renderToolCall(tool);
if (!renderedTool && !permissionRequest) return null;
return (
<div
key={tool.id}
className={cn(
"space-y-3 rounded-lg border bg-background/85 p-3",
permissionRequest && !response && "border-amber-300/60",
)}
>
{renderedTool}
{permissionRequest && onPermissionResponse && (
<PermissionRequest
className="mb-0"
toolCall={permissionRequest.toolCall}
onApprove={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve")}
onApproveSession={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve", "session")}
onApproveAlways={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve", "always")}
onDeny={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "deny")}
isProcessing={isProcessing}
response={response}
/>
)}
</div>
);
})}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -1,5 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
@ -11,8 +12,9 @@ import {
GlobeIcon,
LoaderIcon,
} from "lucide-react";
import type { ComponentProps } from "react";
interface WebSearchResultProps {
interface WebSearchResultProps extends ComponentProps<typeof Collapsible> {
query: string;
results: Array<{ title: string; url: string; description: string }>;
status: "pending" | "running" | "completed" | "error";
@ -27,11 +29,22 @@ function getDomain(url: string): string {
}
}
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
export function WebSearchResult({
className,
query,
results,
status,
title = "Searched the web",
...props
}: WebSearchResultProps) {
const isRunning = status === "pending" || status === "running";
return (
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
<Collapsible
defaultOpen
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4 text-muted-foreground" />

View file

@ -16,10 +16,11 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { AppActionCard } from '@/components/ai-elements/app-action-card'
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { ToolCallStack } from '@/components/ai-elements/tool-call-stack'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
@ -34,10 +35,13 @@ import {
type ChatTabViewState,
type ConversationItem,
type PermissionResponse,
type ToolCall,
createEmptyChatTabViewState,
getAppActionCardData,
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
groupConversationItems,
isChatMessage,
isErrorMessage,
isToolCall,
@ -111,6 +115,8 @@ interface ChatSidebarProps {
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
getToolGroupOpenForTab?: (tabId: string, groupId: string) => boolean | undefined
onToolGroupOpenChangeForTab?: (tabId: string, groupId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void
onActivate?: () => void
// Voice / TTS props
@ -164,6 +170,8 @@ export function ChatSidebar({
onAskHumanResponse,
isToolOpenForTab,
onToolOpenChangeForTab,
getToolGroupOpenForTab,
onToolGroupOpenChangeForTab,
onOpenKnowledgeFile,
onActivate,
isRecording,
@ -287,6 +295,58 @@ export function ChatSidebar({
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
const renderToolCallItem = (item: ToolCall, tabId: string, className?: string) => {
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} className={cn('mb-0', className)} data={appActionData} status={item.status} />
}
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
className={cn('mb-0', className)}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
className={cn('mb-0', className)}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool
key={item.id}
className={cn('mb-0', className)}
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
}
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
@ -333,48 +393,7 @@ export function ChatSidebar({
}
if (isToolCall(item)) {
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
if (composioConnectData.hidden) return null
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
const toolTitle = getToolDisplayName(item)
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool
key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
return renderToolCallItem(item, tabId)
}
if (isErrorMessage(item)) {
@ -501,29 +520,23 @@ export function ChatSidebar({
</ConversationEmptyState>
) : (
<>
{tabState.conversation.map((item) => {
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item) && onPermissionResponse) {
const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment>
)
}
{groupConversationItems(tabState.conversation).map((block) => {
if (block.kind === 'tool-group') {
return (
<ToolCallStack
key={block.key}
tools={block.tools}
open={getToolGroupOpenForTab?.(tab.id, block.key)}
onOpenChange={(open) => onToolGroupOpenChangeForTab?.(tab.id, block.key, open)}
allPermissionRequests={tabState.allPermissionRequests}
permissionResponses={tabState.permissionResponses}
isProcessing={isActive && isProcessing}
onPermissionResponse={onPermissionResponse}
renderToolCall={(tool) => renderToolCallItem(tool, tab.id)}
/>
)
}
return rendered
return renderConversationItem(block.item, tab.id)
})}
{onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (

View file

@ -37,6 +37,17 @@ export interface ErrorMessage {
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
export type PermissionResponse = 'approve' | 'deny'
export type ConversationRenderBlock =
| {
kind: 'item'
key: string
item: ChatMessage | ErrorMessage
}
| {
kind: 'tool-group'
key: string
tools: ToolCall[]
}
export type ChatTabViewState = {
runId: string | null
@ -68,6 +79,38 @@ export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' i
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
'kind' in item && item.kind === 'error'
export const groupConversationItems = (items: ConversationItem[]): ConversationRenderBlock[] => {
const blocks: ConversationRenderBlock[] = []
let pendingTools: ToolCall[] = []
const flushPendingTools = () => {
if (pendingTools.length === 0) return
blocks.push({
kind: 'tool-group',
key: pendingTools[0].id,
tools: pendingTools,
})
pendingTools = []
}
for (const item of items) {
if (isToolCall(item)) {
pendingTools.push(item)
continue
}
flushPendingTools()
blocks.push({
kind: 'item',
key: item.id,
item,
})
}
flushPendingTools()
return blocks
}
export const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
case 'pending':