mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
New tool call UI: tool activity, tool display, and chat sidebar updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
27c1142bb5
commit
64903adcaf
5 changed files with 417 additions and 77 deletions
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
||||
import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity';
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||
|
|
@ -52,6 +53,7 @@ import { OnboardingModal } from '@/components/onboarding-modal'
|
|||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { getToolDisplay, getToolGroupTitle } from '@/components/ai-elements/tool-display'
|
||||
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
||||
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||
|
||||
|
|
@ -2037,19 +2039,21 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
if (isToolCall(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}>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
if (isToolCall(item)) {
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
const display = getToolDisplay(item.name)
|
||||
return (
|
||||
<Tool key={item.id}>
|
||||
<ToolHeader
|
||||
title={display.title}
|
||||
subtitle={display.subtitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? (
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
) : null}
|
||||
|
|
@ -2085,6 +2089,84 @@ function App() {
|
|||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
|
||||
const renderedConversationItems = (() => {
|
||||
const nodes: React.ReactNode[] = []
|
||||
|
||||
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
|
||||
const display = getToolDisplay(toolCall.name)
|
||||
const errorText = toolCall.status === 'error' ? 'Tool error' : ''
|
||||
return {
|
||||
id: toolCall.id,
|
||||
title: display.title,
|
||||
subtitle: display.subtitle,
|
||||
state: toToolState(toolCall.status),
|
||||
input: normalizeToolInput(toolCall.input),
|
||||
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
|
||||
errorText,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < conversation.length; i++) {
|
||||
const item = conversation[i]
|
||||
|
||||
// Group consecutive tool calls into a single compact "activity" block when there are no permission prompts.
|
||||
if (isToolCall(item) && !allPermissionRequests.get(item.id)) {
|
||||
const group: ToolCall[] = [item]
|
||||
let j = i + 1
|
||||
while (
|
||||
j < conversation.length
|
||||
&& isToolCall(conversation[j])
|
||||
&& !allPermissionRequests.get((conversation[j] as ToolCall).id)
|
||||
) {
|
||||
group.push(conversation[j] as ToolCall)
|
||||
j += 1
|
||||
}
|
||||
|
||||
if (group.length > 1) {
|
||||
const titles = group.map((t) => getToolDisplay(t.name).title).join(' · ')
|
||||
nodes.push(
|
||||
<ToolActivity
|
||||
key={`tool-activity-${group[0].id}`}
|
||||
title={getToolGroupTitle(group.map((t) => t.name))}
|
||||
items={group.map(toActivityItem)}
|
||||
summary={titles}
|
||||
/>
|
||||
)
|
||||
i = j - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = renderConversationItem(item)
|
||||
if (!rendered) continue
|
||||
|
||||
// If this is a tool call, check for permission request (pending or responded)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = permissionResponses.get(item.id) || null
|
||||
nodes.push(
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(rendered)
|
||||
}
|
||||
|
||||
return nodes
|
||||
})()
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
|
||||
|
|
@ -2228,35 +2310,13 @@ function App() {
|
|||
What are we working on?
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{conversation.map(item => {
|
||||
const rendered = renderConversationItem(item)
|
||||
// If this is a tool call, check for permission request (pending or responded)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
})}
|
||||
) : (
|
||||
<>
|
||||
{renderedConversationItems}
|
||||
|
||||
{/* Render pending ask-human requests */}
|
||||
{Array.from(pendingAskHumanRequests.values()).map((request) => (
|
||||
<AskHumanRequest
|
||||
{/* Render pending ask-human requests */}
|
||||
{Array.from(pendingAskHumanRequests.values()).map((request) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { ChevronDownIcon, WrenchIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ToolInput, ToolOutput, ToolStatusBadge, type ToolStatus } from "@/components/ai-elements/tool";
|
||||
|
||||
export type ToolActivityItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
state: ToolStatus;
|
||||
input: ToolUIPart["input"];
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
function getGroupState(items: ToolActivityItem[]): ToolStatus {
|
||||
const states = items.map((i) => i.state);
|
||||
|
||||
if (states.includes("output-error")) return "output-error";
|
||||
if (states.includes("output-denied")) return "output-denied";
|
||||
if (states.includes("approval-requested")) return "approval-requested";
|
||||
if (states.includes("input-available")) return "input-available";
|
||||
if (states.includes("input-streaming")) return "input-streaming";
|
||||
if (states.includes("approval-responded")) return "approval-responded";
|
||||
return "output-available";
|
||||
}
|
||||
|
||||
export type ToolActivityProps = {
|
||||
title: string;
|
||||
items: ToolActivityItem[];
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
summary?: ReactNode;
|
||||
};
|
||||
|
||||
export function ToolActivity({
|
||||
title,
|
||||
items,
|
||||
className,
|
||||
defaultOpen = false,
|
||||
summary,
|
||||
}: ToolActivityProps) {
|
||||
const groupState = getGroupState(items);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
defaultOpen={defaultOpen}
|
||||
className={cn("not-prose mb-2 w-full rounded-md border bg-background/50", className)}
|
||||
>
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{items.length} step{items.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
<ToolStatusBadge status={groupState} />
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground truncate">{summary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="border-t">
|
||||
<div className="divide-y">
|
||||
{items.map((item) => (
|
||||
<Collapsible key={item.id} className="w-full">
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-3 px-3 py-2 hover:bg-muted/30">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 truncate text-sm">{item.title}</div>
|
||||
{item.subtitle ? (
|
||||
<div className="mt-0.5 min-w-0 truncate text-xs text-muted-foreground font-mono">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ToolStatusBadge status={item.state} />
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="bg-background">
|
||||
<ToolInput input={item.input ?? {}} className="p-3" />
|
||||
<ToolOutput output={item.output} errorText={item.errorText} className="p-3 pt-0" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
export type ToolDisplay = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const SPECIAL_TITLES: Record<string, string> = {
|
||||
loadSkill: 'Understanding MCP tools',
|
||||
listMcpServers: 'Listing MCP servers',
|
||||
listMcpTools: 'Listing MCP tools',
|
||||
listMcpResources: 'Listing MCP resources',
|
||||
listMcpResourceTemplates: 'Listing MCP resource templates',
|
||||
readMcpResource: 'Reading MCP resource',
|
||||
}
|
||||
|
||||
function toWords(name: string): string[] {
|
||||
if (!name) return []
|
||||
|
||||
// Split on separators first (workspace:readFile, runs:list, etc.)
|
||||
const normalized = name
|
||||
.replace(/[:/_.-]+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const parts: string[] = []
|
||||
for (const token of normalized.split(/\s+/)) {
|
||||
// Split camelCase/PascalCase within each token
|
||||
const camel = token
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
parts.push(...camel.split(/\s+/).filter(Boolean))
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function titleCase(words: string[]): string {
|
||||
const acronyms = new Set(['mcp', 'url', 'id', 'api', 'ipc', 'json'])
|
||||
return words
|
||||
.map((w) => {
|
||||
const lower = w.toLowerCase()
|
||||
if (acronyms.has(lower)) return lower.toUpperCase()
|
||||
if (w.length <= 1) return w.toUpperCase()
|
||||
return w[0].toUpperCase() + w.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function verbToGerund(verb: string): string {
|
||||
const lower = verb.toLowerCase()
|
||||
const irregular: Record<string, string> = {
|
||||
run: 'Running',
|
||||
get: 'Getting',
|
||||
set: 'Setting',
|
||||
list: 'Listing',
|
||||
load: 'Loading',
|
||||
read: 'Reading',
|
||||
write: 'Writing',
|
||||
create: 'Creating',
|
||||
update: 'Updating',
|
||||
delete: 'Deleting',
|
||||
remove: 'Removing',
|
||||
rename: 'Renaming',
|
||||
open: 'Opening',
|
||||
close: 'Closing',
|
||||
toggle: 'Toggling',
|
||||
fetch: 'Fetching',
|
||||
search: 'Searching',
|
||||
provide: 'Providing',
|
||||
}
|
||||
if (irregular[lower]) return irregular[lower]
|
||||
return titleCase([lower + 'ing'])
|
||||
}
|
||||
|
||||
export function getToolDisplay(name: string): ToolDisplay {
|
||||
const special = SPECIAL_TITLES[name]
|
||||
if (special) return { title: special, subtitle: name }
|
||||
|
||||
const words = toWords(name)
|
||||
if (words.length === 0) return { title: 'Tool', subtitle: name }
|
||||
|
||||
const [first, ...rest] = words
|
||||
const title =
|
||||
rest.length > 0
|
||||
? `${verbToGerund(first)} ${titleCase(rest).replace(/\s+/g, ' ')}`
|
||||
: titleCase(words)
|
||||
|
||||
return { title, subtitle: name }
|
||||
}
|
||||
|
||||
export function getToolGroupTitle(toolNames: string[]): string {
|
||||
const joined = toolNames.join(' ')
|
||||
if (/mcp/i.test(joined)) return 'MCP activity'
|
||||
if (toolNames.some((n) => /search/i.test(n))) return 'Search activity'
|
||||
return 'Tool activity'
|
||||
}
|
||||
|
||||
|
|
@ -49,23 +49,31 @@ export type ToolProps = ComponentProps<typeof Collapsible>;
|
|||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
className={cn("not-prose mb-2 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// AI SDK v5's ToolUIPart["state"] is narrower than what we want to render in the UI.
|
||||
// Keep this type permissive so we can display additional states when present.
|
||||
export type ToolStatus =
|
||||
| ToolUIPart["state"]
|
||||
| "approval-requested"
|
||||
| "approval-responded"
|
||||
| "output-denied";
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
state: ToolStatus;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
export const ToolStatusBadge = ({ status }: { status: ToolStatus }) => {
|
||||
const labels: Record<ToolStatus, string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
|
|
@ -73,10 +81,9 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
|
|||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
const icons: Record<ToolStatus, ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
|
|
@ -95,23 +102,33 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
|
|||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
subtitle,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
"group flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
<ToolStatusBadge status={state} />
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground font-mono truncate">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity'
|
||||
import { getToolDisplay, getToolGroupTitle } from '@/components/ai-elements/tool-display'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
|
|
@ -410,10 +412,12 @@ export function ChatSidebar({
|
|||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
const display = getToolDisplay(item.name)
|
||||
return (
|
||||
<Tool key={item.id}>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
title={display.title}
|
||||
subtitle={display.subtitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
|
|
@ -439,6 +443,85 @@ export function ChatSidebar({
|
|||
return null
|
||||
}
|
||||
|
||||
const renderedConversationItems = (() => {
|
||||
const nodes: React.ReactNode[] = []
|
||||
|
||||
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
|
||||
const display = getToolDisplay(toolCall.name)
|
||||
const errorText = toolCall.status === 'error' ? 'Tool error' : ''
|
||||
return {
|
||||
id: toolCall.id,
|
||||
title: display.title,
|
||||
subtitle: display.subtitle,
|
||||
state: toToolState(toolCall.status),
|
||||
input: normalizeToolInput(toolCall.input),
|
||||
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
|
||||
errorText,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < conversation.length; i++) {
|
||||
const item = conversation[i]
|
||||
|
||||
const hasPermissionPrompt = isToolCall(item) && onPermissionResponse && allPermissionRequests.get(item.id)
|
||||
|
||||
// Group consecutive tool calls into a single compact "activity" block when there are no permission prompts.
|
||||
if (isToolCall(item) && !hasPermissionPrompt) {
|
||||
const group: ToolCall[] = [item]
|
||||
let j = i + 1
|
||||
while (j < conversation.length && isToolCall(conversation[j])) {
|
||||
const next = conversation[j] as ToolCall
|
||||
const nextHasPrompt = onPermissionResponse && allPermissionRequests.get(next.id)
|
||||
if (nextHasPrompt) break
|
||||
group.push(next)
|
||||
j += 1
|
||||
}
|
||||
|
||||
if (group.length > 1) {
|
||||
const titles = group.map((t) => getToolDisplay(t.name).title).join(' · ')
|
||||
nodes.push(
|
||||
<ToolActivity
|
||||
key={`tool-activity-${group[0].id}`}
|
||||
title={getToolGroupTitle(group.map((t) => t.name))}
|
||||
items={group.map(toActivityItem)}
|
||||
summary={titles}
|
||||
/>
|
||||
)
|
||||
i = j - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = renderConversationItem(item)
|
||||
if (!rendered) continue
|
||||
|
||||
// If this is a tool call, check for permission request (pending or responded)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = permissionResponses.get(item.id) || null
|
||||
nodes.push(
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(rendered)
|
||||
}
|
||||
|
||||
return nodes
|
||||
})()
|
||||
|
||||
const displayWidth = isOpen ? width : 0
|
||||
|
||||
return (
|
||||
|
|
@ -501,29 +584,7 @@ export function ChatSidebar({
|
|||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{conversation.map(item => {
|
||||
const rendered = renderConversationItem(item)
|
||||
// If this is a tool call, check for permission request (pending or responded)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
})}
|
||||
{renderedConversationItems}
|
||||
|
||||
{/* Render pending ask-human requests */}
|
||||
{onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue