mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
feat: group consecutive tool calls into collapsible summary
Consecutive plain tool calls are now grouped into a single collapsible row instead of rendering as individual items. - Header shows the currently-executing tool name live with a vertical ticker animation, then switches to "Ran N tools" on completion - Expanding the group reveals each tool call individually collapsible - Tool calls with pending permission requests render individually - Special cards (web search, composio connect, app actions) excluded
This commit is contained in:
parent
f14f3b0347
commit
4ca03daa4c
4 changed files with 189 additions and 4 deletions
|
|
@ -35,7 +35,7 @@ import {
|
||||||
|
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||||
import { useSmoothedText } from './hooks/useSmoothedText';
|
import { useSmoothedText } from './hooks/useSmoothedText';
|
||||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||||
|
|
@ -76,10 +76,12 @@ import {
|
||||||
getAppActionCardData,
|
getAppActionCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
|
groupConversationItems,
|
||||||
inferRunTitleFromMessage,
|
inferRunTitleFromMessage,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
|
isToolGroup,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
|
|
@ -4578,7 +4580,20 @@ function App() {
|
||||||
</ConversationEmptyState>
|
</ConversationEmptyState>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{tabState.conversation.map(item => {
|
{groupConversationItems(
|
||||||
|
tabState.conversation,
|
||||||
|
(id) => !!tabState.allPermissionRequests.get(id)
|
||||||
|
).map(item => {
|
||||||
|
if (isToolGroup(item)) {
|
||||||
|
return (
|
||||||
|
<ToolGroupComponent
|
||||||
|
key={item.groupId}
|
||||||
|
group={item}
|
||||||
|
isToolOpen={(toolId) => isToolOpenForTab(tab.id, toolId)}
|
||||||
|
onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
const rendered = renderConversationItem(item, tab.id)
|
||||||
if (isToolCall(item)) {
|
if (isToolCall(item)) {
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import {
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||||
|
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||||
|
|
||||||
const formatToolValue = (value: unknown) => {
|
const formatToolValue = (value: unknown) => {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
|
|
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolGroupProps = {
|
||||||
|
group: ToolGroupType
|
||||||
|
isToolOpen: (toolId: string) => boolean
|
||||||
|
onToolOpenChange: (toolId: string, open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => {
|
||||||
|
if (tools.some(t => t.status === 'error')) return 'output-error'
|
||||||
|
if (tools.some(t => t.status === 'running')) return 'input-available'
|
||||||
|
if (tools.some(t => t.status === 'pending')) return 'input-streaming'
|
||||||
|
return 'output-available'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const state = getGroupState(group.items)
|
||||||
|
const isCompleted = state === 'output-available' || state === 'output-error'
|
||||||
|
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
||||||
|
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
||||||
|
const summary = isCompleted
|
||||||
|
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
||||||
|
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
className="not-prose mb-4 w-full rounded-md border"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={summary}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||||
|
title={summary}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
{getStatusBadge(state)}
|
||||||
|
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="border-t">
|
||||||
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
{group.items.map((tool) => {
|
||||||
|
const toolState = toToolState(tool.status)
|
||||||
|
const isOpen = isToolOpen(tool.id)
|
||||||
|
return (
|
||||||
|
<Tool
|
||||||
|
key={tool.id}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
||||||
|
className="mb-0 border-border/60"
|
||||||
|
>
|
||||||
|
<ToolHeader
|
||||||
|
title={getToolDisplayName(tool)}
|
||||||
|
type={`tool-${tool.name}`}
|
||||||
|
state={toolState}
|
||||||
|
/>
|
||||||
|
<ToolContent>
|
||||||
|
<ToolTabbedContent
|
||||||
|
input={tool.input as ToolUIPart["input"]}
|
||||||
|
output={tool.result as ToolUIPart["output"]}
|
||||||
|
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||||
|
/>
|
||||||
|
</ToolContent>
|
||||||
|
</Tool>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from '@/components/ai-elements/message'
|
} from '@/components/ai-elements/message'
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
|
|
@ -40,9 +40,11 @@ import {
|
||||||
getWebSearchCardData,
|
getWebSearchCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
|
groupConversationItems,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
|
isToolGroup,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
|
|
@ -591,7 +593,20 @@ export function ChatSidebar({
|
||||||
</ConversationEmptyState>
|
</ConversationEmptyState>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{tabState.conversation.map((item) => {
|
{groupConversationItems(
|
||||||
|
tabState.conversation,
|
||||||
|
(id) => !!tabState.allPermissionRequests.get(id)
|
||||||
|
).map((item) => {
|
||||||
|
if (isToolGroup(item)) {
|
||||||
|
return (
|
||||||
|
<ToolGroupComponent
|
||||||
|
key={item.groupId}
|
||||||
|
group={item}
|
||||||
|
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
|
||||||
|
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
const rendered = renderConversationItem(item, tab.id)
|
||||||
if (isToolCall(item) && onPermissionResponse) {
|
if (isToolCall(item) && onPermissionResponse) {
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToolGroup = {
|
||||||
|
type: 'tool-group'
|
||||||
|
items: ToolCall[]
|
||||||
|
groupId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupedConversationItem = ConversationItem | ToolGroup
|
||||||
|
|
||||||
|
export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
||||||
|
'type' in item && (item as ToolGroup).type === 'tool-group'
|
||||||
|
|
||||||
|
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||||
|
if (!isToolCall(item)) return false
|
||||||
|
if (getWebSearchCardData(item)) return false
|
||||||
|
if (getComposioConnectCardData(item)) return false
|
||||||
|
if (getAppActionCardData(item)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupConversationItems = (
|
||||||
|
items: ConversationItem[],
|
||||||
|
hasPermissionRequest: (id: string) => boolean
|
||||||
|
): GroupedConversationItem[] => {
|
||||||
|
const result: GroupedConversationItem[] = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < items.length) {
|
||||||
|
const item = items[i]
|
||||||
|
if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) {
|
||||||
|
const group: ToolCall[] = [item]
|
||||||
|
i++
|
||||||
|
while (
|
||||||
|
i < items.length &&
|
||||||
|
isPlainToolCall(items[i] as ConversationItem) &&
|
||||||
|
!hasPermissionRequest((items[i] as ToolCall).id)
|
||||||
|
) {
|
||||||
|
group.push(items[i] as ToolCall)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (group.length === 1) {
|
||||||
|
result.push(group[0])
|
||||||
|
} else {
|
||||||
|
result.push({ type: 'tool-group', items: group, groupId: group[0].id })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(item)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getToolGroupSummary = (tools: ToolCall[]): string => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const names: string[] = []
|
||||||
|
for (const tool of tools) {
|
||||||
|
const name = getToolDisplayName(tool)
|
||||||
|
if (!seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
names.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||||
const { message } = parseAttachedFiles(content)
|
const { message } = parseAttachedFiles(content)
|
||||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue