- {/* Copy button — absolute positioned, no reserved space */}
- {!isStreaming && content.trim() && (
-
+
+
{isStreaming && (
-
+
)}
+
+
)
}
diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx
new file mode 100644
index 00000000..0586f9fb
--- /dev/null
+++ b/desktop/src/components/chat/MessageActionBar.tsx
@@ -0,0 +1,31 @@
+import { CopyButton } from '../shared/CopyButton'
+
+type Props = {
+ copyText?: string
+ copyLabel: string
+ align?: 'start' | 'end'
+}
+
+export function MessageActionBar({
+ copyText,
+ copyLabel,
+ align = 'start',
+}: Props) {
+ const hasCopy = Boolean(copyText?.trim())
+
+ if (!hasCopy) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
new file mode 100644
index 00000000..818ea3c3
--- /dev/null
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MessageList, buildRenderItems } from './MessageList'
+import { useChatStore } from '../../stores/chatStore'
+import type { UIMessage } from '../../types/chat'
+
+describe('MessageList nested tool calls', () => {
+ beforeEach(() => {
+ useChatStore.setState({
+ messages: [],
+ chatState: 'idle',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ elapsedSeconds: 0,
+ })
+ })
+
+ it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
+ useChatStore.setState({
+ messages: [
+ {
+ id: 'tool-agent',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-1',
+ input: { description: 'Inspect src/components' },
+ timestamp: 1,
+ },
+ {
+ id: 'tool-read',
+ type: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-1',
+ input: { file_path: '/tmp/example.ts' },
+ timestamp: 2,
+ parentToolUseId: 'agent-1',
+ },
+ {
+ id: 'result-read',
+ type: 'tool_result',
+ toolUseId: 'read-1',
+ content: 'const answer = 42',
+ isError: false,
+ timestamp: 3,
+ parentToolUseId: 'agent-1',
+ },
+ ],
+ })
+
+ const { container } = render(
)
+
+ expect(screen.getByText('Running')).toBeTruthy()
+ expect(screen.getByText(/Read .*example\.ts.*done/i)).toBeTruthy()
+ expect(container.textContent).toContain('Agent')
+ })
+
+ it('keeps root tool runs split when nested child tool calls appear between them', () => {
+ const messages: UIMessage[] = [
+ {
+ id: 'tool-agent',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-1',
+ input: { description: 'Inspect src/components' },
+ timestamp: 1,
+ },
+ {
+ id: 'tool-read',
+ type: 'tool_use',
+ toolName: 'Read',
+ toolUseId: 'read-1',
+ input: { file_path: '/tmp/example.ts' },
+ timestamp: 2,
+ parentToolUseId: 'agent-1',
+ },
+ {
+ id: 'result-read',
+ type: 'tool_result',
+ toolUseId: 'read-1',
+ content: 'const answer = 42',
+ isError: false,
+ timestamp: 3,
+ parentToolUseId: 'agent-1',
+ },
+ {
+ id: 'tool-write',
+ type: 'tool_use',
+ toolName: 'Write',
+ toolUseId: 'write-1',
+ input: { file_path: '/tmp/out.ts', content: 'export const value = 1' },
+ timestamp: 4,
+ },
+ ]
+
+ const toolUseIds = new Set(messages.filter((message) => message.type === 'tool_use').map((message) => message.toolUseId))
+ const renderItems = buildRenderItems(messages, toolUseIds)
+ const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
+
+ expect(toolGroups).toHaveLength(2)
+ expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1'])
+ })
+
+ it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
+ useChatStore.setState({
+ messages: [
+ {
+ id: 'tool-agent',
+ type: 'tool_use',
+ toolName: 'Agent',
+ toolUseId: 'agent-1',
+ input: { description: '探索整体架构', subagent_type: 'Explore' },
+ timestamp: 1,
+ },
+ {
+ id: 'result-agent',
+ type: 'tool_result',
+ toolUseId: 'agent-1',
+ content: `Agent type 'Explore' not found. Available agents: general-purpose`,
+ isError: true,
+ timestamp: 2,
+ },
+ ],
+ })
+
+ render(
)
+
+ expect(screen.getByText('Failed')).toBeTruthy()
+ expect(screen.getByText('Explore agent unavailable in this session')).toBeTruthy()
+ })
+
+ it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.assign(navigator, {
+ clipboard: {
+ writeText,
+ },
+ })
+
+ useChatStore.setState({
+ messages: [
+ {
+ id: 'user-1',
+ type: 'user_text',
+ content: '请帮我探索整体架构',
+ timestamp: 1,
+ },
+ {
+ id: 'assistant-1',
+ type: 'assistant_text',
+ content: '先看 CLI 和服务端入口。',
+ timestamp: 2,
+ },
+ {
+ id: 'assistant-2',
+ type: 'assistant_text',
+ content: '再看 desktop 前后端边界。',
+ timestamp: 3,
+ },
+ ],
+ })
+
+ render(
)
+
+ expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeTruthy()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Copy reply' })[1]!)
+
+ await waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('再看 desktop 前后端边界。')
+ })
+ expect(writeText).not.toHaveBeenCalledWith(
+ '先看 CLI 和服务端入口。\n再看 desktop 前后端边界。'
+ )
+ })
+})
diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx
index adf182df..3599b55c 100644
--- a/desktop/src/components/chat/UserMessage.tsx
+++ b/desktop/src/components/chat/UserMessage.tsx
@@ -1,5 +1,6 @@
import type { UIAttachment } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
+import { MessageActionBar } from './MessageActionBar'
type Props = {
content: string
@@ -17,12 +18,19 @@ export function UserMessage({ content, attachments }: Props) {
)}
{hasText && (
-
- {content}
-
+ <>
+
+ {content}
+
+
+ >
)}