mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
consolidate tools
This commit is contained in:
parent
71c848006c
commit
61fadad5e0
7 changed files with 462 additions and 147 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue