From 3c5290eec1a3ce1221bd84c52cf9e660c18af1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 8 Apr 2026 23:19:31 +0800 Subject: [PATCH] fix: reduce copy ambiguity in chat sessions The session view exposed copy affordances in a way that implied the whole conversation would be copied, while user prompts had no copy path at all. This changes copy to a per-message footer action so the scope is explicit for both prompts and assistant replies, and reuses the shared clipboard helper instead of maintaining a second copy path. Constraint: The worktree contains unrelated in-progress changes that must stay uncommitted Rejected: Keep a floating action above each message | it interrupted reading order and still looked session-scoped Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep copy affordances attached to individual message blocks so the copied scope stays obvious Tested: bun run test src/components/chat/MessageList.test.tsx; bun run lint Not-tested: Manual desktop visual QA across dense multi-message sessions --- .../src/components/chat/AssistantMessage.tsx | 34 +--- .../src/components/chat/MessageActionBar.tsx | 31 +++ .../src/components/chat/MessageList.test.tsx | 179 ++++++++++++++++++ desktop/src/components/chat/UserMessage.tsx | 20 +- desktop/src/components/shared/CopyButton.tsx | 39 ++-- 5 files changed, 257 insertions(+), 46 deletions(-) create mode 100644 desktop/src/components/chat/MessageActionBar.tsx create mode 100644 desktop/src/components/chat/MessageList.test.tsx diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 62a8a622..835fd7c0 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -1,5 +1,5 @@ import { MarkdownRenderer } from '../markdown/MarkdownRenderer' -import { useState } from 'react' +import { MessageActionBar } from './MessageActionBar' type Props = { content: string @@ -7,35 +7,19 @@ type Props = { } export function AssistantMessage({ content, isStreaming }: Props) { - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(content) - setCopied(true) - window.setTimeout(() => setCopied(false), 1500) - } catch { - setCopied(false) - } - } - return ( -
- {/* 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} +
+ + )}
diff --git a/desktop/src/components/shared/CopyButton.tsx b/desktop/src/components/shared/CopyButton.tsx index f353debc..d829c684 100644 --- a/desktop/src/components/shared/CopyButton.tsx +++ b/desktop/src/components/shared/CopyButton.tsx @@ -1,12 +1,23 @@ import { useEffect, useState } from 'react' +import { copyTextToClipboard } from '../chat/clipboard' type Props = { text: string label?: string + copiedLabel?: string + displayLabel?: string + displayCopiedLabel?: string className?: string } -export function CopyButton({ text, label = 'Copy', className = '' }: Props) { +export function CopyButton({ + text, + label = 'Copy', + copiedLabel = 'Copied', + displayLabel, + displayCopiedLabel, + className = '', +}: Props) { const [copied, setCopied] = useState(false) useEffect(() => { @@ -17,17 +28,10 @@ export function CopyButton({ text, label = 'Copy', className = '' }: Props) { const handleCopy = async () => { try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - } else { - const textarea = document.createElement('textarea') - textarea.value = text - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) + const ok = await copyTextToClipboard(text) + if (!ok) { + setCopied(false) + return } setCopied(true) } catch { @@ -35,15 +39,20 @@ export function CopyButton({ text, label = 'Copy', className = '' }: Props) { } } + const currentLabel = copied ? copiedLabel : label + const buttonText = copied + ? (displayCopiedLabel ?? copiedLabel) + : (displayLabel ?? label) + return ( ) }