Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 96 additions & 4 deletions frontend/src/features/tasks/components/MessagesArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTaskContext } from '../contexts/taskContext';
import type { TaskDetail, TaskDetailSubtask, Team, GitRepoInfo, GitBranch } from '@/types/api';
import { Bot, User, Copy, Check, Download } from 'lucide-react';
import { Bot, User, Copy, Check, Download, ChevronDown, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
import MarkdownEditor from '@uiw/react-markdown-editor';
Expand Down Expand Up @@ -124,6 +124,57 @@ const BubbleTools = ({
);
};

// Collapsible tool calls component
const CollapsibleToolCalls = ({ toolCalls, t }: { toolCalls: string[]; t: (key: string) => string }) => {
const [isExpanded, setIsExpanded] = useState(false);

// Parse tool call to extract tool name
const parseToolName = (toolCall: string): string => {
const match = toolCall.match(/<tool_call>\s*(\w+)/);
return match ? match[1] : 'Unknown';
};

return (
<div className="border-l-2 border-primary/30 pl-3 my-2 bg-muted/30 rounded-r py-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-2 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<span className="font-medium">
{t('messages.tool_calls_summary')
.replace('{count}', toolCalls.length.toString())
.replace(
'{tools}',
toolCalls.map(parseToolName).join(', ')
)}
</span>
</button>
{isExpanded && (
<div className="mt-2 space-y-2">
{toolCalls.map((call, idx) => {
const toolName = parseToolName(call);
return (
<div key={idx} className="text-xs bg-surface/50 p-2 rounded border border-border/50">
<div className="font-medium text-primary mb-1">
{t('messages.tool_call')}: {toolName}
</div>
<pre className="text-text-muted overflow-x-auto whitespace-pre-wrap break-words">
{call}
</pre>
</div>
);
})}
</div>
)}
</div>
);
};

interface MessagesAreaProps {
selectedTeam?: Team | null;
selectedRepo?: GitRepoInfo | null;
Expand All @@ -146,6 +197,29 @@ export default function MessagesArea({
const isUserNearBottomRef = useRef(true);
const AUTO_SCROLL_THRESHOLD = 32;

// Helper function to detect and extract tool calls from content
const detectToolCalls = (
content: string
): { hasToolCalls: boolean; toolCallsCount: number; toolCalls: string[]; cleanedContent: string } => {
const toolCallRegex = /<tool_call>[\s\S]*?<\/tool_call>/g;
const matches = content.match(toolCallRegex);

return {
hasToolCalls: matches !== null && matches.length > 0,
toolCallsCount: matches?.length || 0,
toolCalls: matches || [],
cleanedContent: matches ? content.replace(toolCallRegex, '').trim() : content,
};
};

// Helper function to remove metadata lines from content
const removeMetaInfo = (content: string): string => {
return content
.replace(/^Current working directory:.*$/m, '')
.replace(/^project url:.*$/m, '')
.trim();
};

useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;

Expand Down Expand Up @@ -632,7 +706,10 @@ export default function MessagesArea({
};

const renderAiMessage = (msg: Message, messageIndex: number) => {
const content = msg.content ?? '';
let content = msg.content ?? '';

// Remove metadata first
content = removeMetaInfo(content);

Comment on lines 708 to 713
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

AI message tool‑call rendering is on point; add a small guard for ${$$}$ edge cases

The new renderAiMessage flow correctly:

  • Strips noisy meta via removeMetaInfo,
  • Detects tool calls in both plain and ${$$}$-separated messages,
  • Renders a clean prompt/result with collapsible tool call details, which aligns well with the PR goal.

There is one defensive gap plus an optional UX tweak:

  1. Guard against missing result after the ${$$}$ separator

If a future message ever contains the ${$$}$ delimiter without a second segment (e.g. due to a backend change), const [prompt, result] = content.split('${$$}$') will set result to undefined, and detectToolCalls(result) will throw at runtime.

You can harden this with a trivial default:

-    const [prompt, result] = content.split('${$$}$');
-
-    // Check for tool calls in result
-    const { hasToolCalls, toolCalls, cleanedContent } = detectToolCalls(result);
+    const [prompt, resultRaw] = content.split('${$$}$');
+    const result = resultRaw ?? '';
+
+    // Check for tool calls in result
+    const { hasToolCalls, toolCalls, cleanedContent } = detectToolCalls(result);
  1. Optional: keep the copy toolbar for plain messages that contain tool calls

In the !content.includes('${$$}$') branch, when hasToolCalls is true you render cleanedContent and CollapsibleToolCalls, but no BubbleTools. Previously, plain AI messages would always get the copy button via renderPlainMessage. If you want to preserve that UX, you could wrap the cleaned content in a group div and include BubbleTools just like renderPlainMessage does:

-      if (hasToolCalls) {
-        return (
-          <>
-            {cleanedContent && <div className="text-sm whitespace-pre-line mb-2">{cleanedContent}</div>}
-            <CollapsibleToolCalls toolCalls={toolCalls} t={t} />
-          </>
-        );
-      }
-      return renderPlainMessage({ ...msg, content: cleanedContent });
+      if (hasToolCalls) {
+        return (
+          <>
+            {cleanedContent && (
+              <div className="group pb-4">
+                <BubbleTools contentToCopy={cleanedContent} tools={[]} />
+                <div className="text-sm whitespace-pre-line mb-2">{cleanedContent}</div>
+              </div>
+            )}
+            <CollapsibleToolCalls toolCalls={toolCalls} t={t} />
+          </>
+        );
+      }
+      return renderPlainMessage({ ...msg, content: cleanedContent });

Based on learnings, this keeps everything in functional React components with hooks while improving resilience and preserving the existing copy behavior for AI messages.

Also applies to: 753-766, 769-778

🤖 Prompt for AI Agents
frontend/src/features/tasks/components/MessagesArea.tsx around lines 708-713
(also applies to 753-766 and 769-778): guard against content.split('${$$}$')
producing undefined for the result and optionally restore the copy toolbar for
plain messages with detected tool calls; specifically, after splitting do const
[prompt, result = ''] = content.split('${$$}$') (or otherwise default result to
an empty string) before calling detectToolCalls(result) to avoid runtime throws,
and in the branch where !content.includes('${$$}$') and hasToolCalls is true,
wrap the cleaned content in the same group that renders BubbleTools (or call
renderPlainMessage behavior) so the copy button is preserved for plain AI
messages with tool calls.

// Try to parse as clarification or final_prompt data
try {
Expand Down Expand Up @@ -675,14 +752,29 @@ export default function MessagesArea({

// Default rendering for normal messages
if (!content.includes('${$$}$')) {
return renderPlainMessage(msg);
// Check for tool calls in plain messages
const { hasToolCalls, toolCalls, cleanedContent } = detectToolCalls(content);
if (hasToolCalls) {
return (
<>
{cleanedContent && <div className="text-sm whitespace-pre-line mb-2">{cleanedContent}</div>}
<CollapsibleToolCalls toolCalls={toolCalls} t={t} />
</>
);
}
return renderPlainMessage({ ...msg, content: cleanedContent });
}

const [prompt, result] = content.split('${$$}$');

// Check for tool calls in result
const { hasToolCalls, toolCalls, cleanedContent } = detectToolCalls(result);

return (
<>
{prompt && <div className="text-sm whitespace-pre-line mb-2">{prompt}</div>}
{result && renderMarkdownResult(result, prompt)}
{hasToolCalls && <CollapsibleToolCalls toolCalls={toolCalls} t={t} />}
{cleanedContent && renderMarkdownResult(cleanedContent, prompt)}
</>
);
};
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"subtask_completed": "Subtask completed",
"subtask_failed": "Subtask failed",
"unknown_error": "Unknown error",
"bot": "Bot"
"bot": "Bot",
"tool_calls_summary": "Called {count} tool(s): {tools}",
"tool_call": "Tool Call"
},
"settings": {
"model": "Model",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"subtask_completed": "子任务已完成",
"subtask_failed": "子任务失败",
"unknown_error": "未知错误",
"bot": "机器人"
"bot": "机器人",
"tool_calls_summary": "调用了 {count} 个工具:{tools}",
"tool_call": "工具调用"
},
"settings": {
"model": "模型",
Expand Down