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 { 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 { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||
|
|
@ -76,10 +76,12 @@ import {
|
|||
getAppActionCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
|
|
@ -4578,7 +4580,20 @@ function App() {
|
|||
</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)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import {
|
|||
XCircleIcon,
|
||||
} from "lucide-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) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
|
|||
</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,
|
||||
} from '@/components/ai-elements/message'
|
||||
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 { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
|
|
@ -40,9 +40,11 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
|
|
@ -591,7 +593,20 @@ export function ChatSidebar({
|
|||
</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)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
|
|
|
|||
|
|
@ -586,6 +586,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat
|
|||
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 => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue