mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-22 14:30:53 +08:00
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
This commit is contained in:
parent
90b5772fb4
commit
3c5290eec1
@ -1,5 +1,5 @@
|
|||||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||||
import { useState } from 'react'
|
import { MessageActionBar } from './MessageActionBar'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
@ -7,35 +7,19 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({ content, isStreaming }: 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 (
|
return (
|
||||||
<div className="group relative mb-3 ml-10">
|
<div className="mb-5 ml-10 max-w-[calc(100%-2.5rem)]">
|
||||||
{/* Copy button — absolute positioned, no reserved space */}
|
<div className="rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm">
|
||||||
{!isStreaming && content.trim() && (
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="absolute -right-1 -top-1 rounded-md border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-2 py-0.5 text-[10px] text-[var(--color-text-tertiary)] opacity-0 shadow-sm transition-opacity hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
{copied ? 'Copied' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-[var(--color-text-primary)]">
|
|
||||||
<MarkdownRenderer content={content} />
|
<MarkdownRenderer content={content} />
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="inline-block w-0.5 h-4 bg-[var(--color-brand)] animate-shimmer ml-0.5 align-text-bottom" />
|
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MessageActionBar
|
||||||
|
copyText={isStreaming ? undefined : content}
|
||||||
|
copyLabel="Copy reply"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
31
desktop/src/components/chat/MessageActionBar.tsx
Normal file
31
desktop/src/components/chat/MessageActionBar.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`mt-1.5 flex ${align === 'end' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<CopyButton
|
||||||
|
text={copyText!}
|
||||||
|
label={copyLabel}
|
||||||
|
displayLabel="Copy"
|
||||||
|
displayCopiedLabel="Copied"
|
||||||
|
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
desktop/src/components/chat/MessageList.test.tsx
Normal file
179
desktop/src/components/chat/MessageList.test.tsx
Normal file
@ -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(<MessageList />)
|
||||||
|
|
||||||
|
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(<MessageList />)
|
||||||
|
|
||||||
|
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(<MessageList />)
|
||||||
|
|
||||||
|
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 前后端边界。'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import type { UIAttachment } from '../../types/chat'
|
import type { UIAttachment } from '../../types/chat'
|
||||||
import { AttachmentGallery } from './AttachmentGallery'
|
import { AttachmentGallery } from './AttachmentGallery'
|
||||||
|
import { MessageActionBar } from './MessageActionBar'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
@ -17,12 +18,19 @@ export function UserMessage({ content, attachments }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasText && (
|
{hasText && (
|
||||||
<div
|
<>
|
||||||
className="bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
|
<div
|
||||||
style={{ borderRadius: '18px 4px 18px 18px' }}
|
className="bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
|
||||||
>
|
style={{ borderRadius: '18px 4px 18px 18px' }}
|
||||||
{content}
|
>
|
||||||
</div>
|
{content}
|
||||||
|
</div>
|
||||||
|
<MessageActionBar
|
||||||
|
copyText={content}
|
||||||
|
copyLabel="Copy prompt"
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string
|
text: string
|
||||||
label?: string
|
label?: string
|
||||||
|
copiedLabel?: string
|
||||||
|
displayLabel?: string
|
||||||
|
displayCopiedLabel?: string
|
||||||
className?: 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)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -17,17 +28,10 @@ export function CopyButton({ text, label = 'Copy', className = '' }: Props) {
|
|||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
const ok = await copyTextToClipboard(text)
|
||||||
await navigator.clipboard.writeText(text)
|
if (!ok) {
|
||||||
} else {
|
setCopied(false)
|
||||||
const textarea = document.createElement('textarea')
|
return
|
||||||
textarea.value = text
|
|
||||||
textarea.style.position = 'fixed'
|
|
||||||
textarea.style.opacity = '0'
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
}
|
}
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={className}
|
className={className}
|
||||||
aria-label={copied ? 'Copied' : label}
|
aria-label={currentLabel}
|
||||||
title={copied ? 'Copied' : label}
|
title={currentLabel}
|
||||||
>
|
>
|
||||||
{copied ? 'Copied' : label}
|
{buttonText}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user