mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
feat: enhance tool call UI with improved state management and permission request handling in App and ChatSidebar components
This commit is contained in:
parent
64903adcaf
commit
ea2176db84
3 changed files with 157 additions and 81 deletions
|
|
@ -35,7 +35,7 @@ import {
|
|||
} from '@/components/ai-elements/prompt-input';
|
||||
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 { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolStatus } 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';
|
||||
|
|
@ -1318,6 +1318,9 @@ function App() {
|
|||
if (!userMessage) return
|
||||
|
||||
setMessage('')
|
||||
setIsProcessing(true)
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
|
||||
const userMessageId = `user-${Date.now()}`
|
||||
setConversation(prev => [...prev, {
|
||||
|
|
@ -1336,7 +1339,11 @@ function App() {
|
|||
})
|
||||
currentRunId = run.id
|
||||
setRunId(currentRunId)
|
||||
runIdRef.current = currentRunId
|
||||
isNewRun = true
|
||||
} else {
|
||||
// Ensure event filtering accepts events for the active run immediately.
|
||||
runIdRef.current = currentRunId
|
||||
}
|
||||
|
||||
// Read mentioned file contents and format message with XML context
|
||||
|
|
@ -1372,6 +1379,7 @@ function App() {
|
|||
loadRuns()
|
||||
}
|
||||
} catch (error) {
|
||||
setIsProcessing(false)
|
||||
console.error('Failed to send message:', error)
|
||||
}
|
||||
}
|
||||
|
|
@ -2006,6 +2014,21 @@ function App() {
|
|||
}
|
||||
}, [isGraphOpen, knowledgeFilePaths])
|
||||
|
||||
const getToolUiState = useCallback((toolCall: ToolCall): ToolStatus => {
|
||||
const permRequest = allPermissionRequests.get(toolCall.id)
|
||||
const response = permissionResponses.get(toolCall.id) ?? null
|
||||
|
||||
if (!permRequest) {
|
||||
return toToolState(toolCall.status)
|
||||
}
|
||||
|
||||
if (response === 'deny') return 'output-denied'
|
||||
if (!response) return 'approval-requested'
|
||||
if (toolCall.status === 'error') return 'output-error'
|
||||
if (toolCall.status === 'completed') return toToolState(toolCall.status)
|
||||
return 'approval-responded'
|
||||
}, [allPermissionRequests, permissionResponses])
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
|
|
@ -2039,24 +2062,39 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
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} />
|
||||
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)
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
const response = permissionResponses.get(item.id) ?? null
|
||||
|
||||
return (
|
||||
<Tool key={item.id} defaultOpen={getToolUiState(item) === 'approval-requested'}>
|
||||
<ToolHeader
|
||||
title={display.title}
|
||||
subtitle={display.subtitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={getToolUiState(item)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? (
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
) : null}
|
||||
{permRequest ? (
|
||||
<div className="px-4 pb-4">
|
||||
<PermissionRequest
|
||||
className="mb-0"
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -2075,6 +2113,15 @@ function App() {
|
|||
}
|
||||
|
||||
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
|
||||
const hasActiveToolCalls = conversation.some(
|
||||
(item) => isToolCall(item) && (item.status === 'pending' || item.status === 'running')
|
||||
)
|
||||
const hasPendingRequests = pendingPermissionRequests.size > 0 || pendingAskHumanRequests.size > 0
|
||||
const showThinkingIndicator =
|
||||
(isProcessing || hasActiveToolCalls)
|
||||
&& !hasPendingRequests
|
||||
&& !currentAssistantMessage
|
||||
&& !currentReasoning
|
||||
const conversationContentClassName = hasConversation
|
||||
? "mx-auto w-full max-w-4xl pb-28"
|
||||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||
|
|
@ -2095,29 +2142,38 @@ function App() {
|
|||
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
|
||||
const display = getToolDisplay(toolCall.name)
|
||||
const errorText = toolCall.status === 'error' ? 'Tool error' : ''
|
||||
const permRequest = allPermissionRequests.get(toolCall.id)
|
||||
const response = permissionResponses.get(toolCall.id) ?? null
|
||||
return {
|
||||
id: toolCall.id,
|
||||
title: display.title,
|
||||
subtitle: display.subtitle,
|
||||
state: toToolState(toolCall.status),
|
||||
state: getToolUiState(toolCall),
|
||||
input: normalizeToolInput(toolCall.input),
|
||||
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
|
||||
errorText,
|
||||
defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error',
|
||||
extra: permRequest ? (
|
||||
<PermissionRequest
|
||||
className="mb-0"
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
// Group consecutive tool calls into a single compact "activity" block.
|
||||
if (isToolCall(item)) {
|
||||
const group: ToolCall[] = [item]
|
||||
let j = i + 1
|
||||
while (
|
||||
j < conversation.length
|
||||
&& isToolCall(conversation[j])
|
||||
&& !allPermissionRequests.get((conversation[j] as ToolCall).id)
|
||||
) {
|
||||
while (j < conversation.length && isToolCall(conversation[j])) {
|
||||
group.push(conversation[j] as ToolCall)
|
||||
j += 1
|
||||
}
|
||||
|
|
@ -2130,6 +2186,10 @@ function App() {
|
|||
title={getToolGroupTitle(group.map((t) => t.name))}
|
||||
items={group.map(toActivityItem)}
|
||||
summary={titles}
|
||||
defaultOpen={group.some((t) => {
|
||||
const state = getToolUiState(t)
|
||||
return state === 'approval-requested' || state === 'output-error'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
i = j - 1
|
||||
|
|
@ -2140,27 +2200,6 @@ function App() {
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -2339,7 +2378,7 @@ function App() {
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isProcessing && !currentAssistantMessage && !currentReasoning && (
|
||||
{showThinkingIndicator && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export type ToolActivityItem = {
|
|||
input: ToolUIPart["input"];
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
extra?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
function getGroupState(items: ToolActivityItem[]): ToolStatus {
|
||||
|
|
@ -79,7 +81,7 @@ export function ToolActivity({
|
|||
<CollapsibleContent className="border-t">
|
||||
<div className="divide-y">
|
||||
{items.map((item) => (
|
||||
<Collapsible key={item.id} className="w-full">
|
||||
<Collapsible key={item.id} className="w-full" defaultOpen={item.defaultOpen}>
|
||||
<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>
|
||||
|
|
@ -97,6 +99,9 @@ export function ToolActivity({
|
|||
<CollapsibleContent className="bg-background">
|
||||
<ToolInput input={item.input ?? {}} className="p-3" />
|
||||
<ToolOutput output={item.output} errorText={item.errorText} className="p-3 pt-0" />
|
||||
{item.extra ? (
|
||||
<div className="p-3 pt-0">{item.extra}</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '@/components/ai-elements/message'
|
||||
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 { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolStatus } 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'
|
||||
|
|
@ -187,6 +187,21 @@ export function ChatSidebar({
|
|||
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
|
||||
const lastSelectedPathRef = useRef<string | null>(null)
|
||||
|
||||
const getToolUiState = useCallback((toolCall: ToolCall): ToolStatus => {
|
||||
const permRequest = allPermissionRequests.get(toolCall.id)
|
||||
const response = permissionResponses.get(toolCall.id) ?? null
|
||||
|
||||
if (!permRequest) {
|
||||
return toToolState(toolCall.status)
|
||||
}
|
||||
|
||||
if (response === 'deny') return 'output-denied'
|
||||
if (!response) return 'approval-requested'
|
||||
if (toolCall.status === 'error') return 'output-error'
|
||||
if (toolCall.status === 'completed') return toToolState(toolCall.status)
|
||||
return 'approval-responded'
|
||||
}, [allPermissionRequests, permissionResponses])
|
||||
|
||||
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
|
||||
const mentionLabels = useMemo(() => {
|
||||
if (knowledgeFiles.length === 0) return []
|
||||
|
|
@ -329,6 +344,18 @@ export function ChatSidebar({
|
|||
}, [selectedPath, message, onMessageChange])
|
||||
|
||||
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
|
||||
const hasActiveToolCalls = conversation.some(
|
||||
(item) => isToolCall(item) && (item.status === 'pending' || item.status === 'running')
|
||||
)
|
||||
const hasPendingPermissionRequests = Array.from(allPermissionRequests.keys()).some(
|
||||
(id) => !permissionResponses.has(id)
|
||||
)
|
||||
const showThinkingIndicator =
|
||||
(isProcessing || hasActiveToolCalls)
|
||||
&& !hasPendingPermissionRequests
|
||||
&& pendingAskHumanRequests.size === 0
|
||||
&& !currentAssistantMessage
|
||||
&& !currentReasoning
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
|
||||
const handleSubmit = () => {
|
||||
|
|
@ -413,19 +440,33 @@ export function ChatSidebar({
|
|||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
const display = getToolDisplay(item.name)
|
||||
const permRequest = allPermissionRequests.get(item.id)
|
||||
const response = permissionResponses.get(item.id) ?? null
|
||||
return (
|
||||
<Tool key={item.id}>
|
||||
<Tool key={item.id} defaultOpen={getToolUiState(item) === 'approval-requested'}>
|
||||
<ToolHeader
|
||||
title={display.title}
|
||||
subtitle={display.subtitle}
|
||||
type={`tool-${item.name}`}
|
||||
state={toToolState(item.status)}
|
||||
state={getToolUiState(item)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
{output !== null ? (
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
) : null}
|
||||
{permRequest ? (
|
||||
<div className="px-4 pb-4">
|
||||
<PermissionRequest
|
||||
className="mb-0"
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve') : undefined}
|
||||
onDeny={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') : undefined}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
|
|
@ -449,31 +490,39 @@ export function ChatSidebar({
|
|||
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
|
||||
const display = getToolDisplay(toolCall.name)
|
||||
const errorText = toolCall.status === 'error' ? 'Tool error' : ''
|
||||
const permRequest = allPermissionRequests.get(toolCall.id)
|
||||
const response = permissionResponses.get(toolCall.id) ?? null
|
||||
return {
|
||||
id: toolCall.id,
|
||||
title: display.title,
|
||||
subtitle: display.subtitle,
|
||||
state: toToolState(toolCall.status),
|
||||
state: getToolUiState(toolCall),
|
||||
input: normalizeToolInput(toolCall.input),
|
||||
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
|
||||
errorText,
|
||||
defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error',
|
||||
extra: permRequest ? (
|
||||
<PermissionRequest
|
||||
className="mb-0"
|
||||
toolCall={permRequest.toolCall}
|
||||
onApprove={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve') : undefined}
|
||||
onDeny={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') : undefined}
|
||||
isProcessing={isProcessing}
|
||||
response={response}
|
||||
/>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Group consecutive tool calls into a single compact "activity" block.
|
||||
if (isToolCall(item)) {
|
||||
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)
|
||||
group.push(conversation[j] as ToolCall)
|
||||
j += 1
|
||||
}
|
||||
|
||||
|
|
@ -485,6 +534,10 @@ export function ChatSidebar({
|
|||
title={getToolGroupTitle(group.map((t) => t.name))}
|
||||
items={group.map(toActivityItem)}
|
||||
summary={titles}
|
||||
defaultOpen={group.some((t) => {
|
||||
const state = getToolUiState(t)
|
||||
return state === 'approval-requested' || state === 'output-error'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
i = j - 1
|
||||
|
|
@ -495,27 +548,6 @@ export function ChatSidebar({
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -611,7 +643,7 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isProcessing && !currentAssistantMessage && !currentReasoning && (
|
||||
{showThinkingIndicator && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue