Skip to content
Merged
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
42 changes: 41 additions & 1 deletion packages/desktop/src/agent/MessageStream.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Copy, Check } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useGlobalStore } from '../stores/global.store';
import MessageItem from '../shared/MessageItem';
import UnifiedDiffView from '../shared/UnifiedDiffView';
import type { Item } from '@shared/types';
import type { CheckpointDiff } from '../lib/core-api';
import { useAgentApproval, useAgentRollback } from '../hooks/useAgent';
import { useCopyToClipboard } from '../hooks/useCopyToClipboard';

interface MessageStreamProps {
threadId: string;
Expand Down Expand Up @@ -232,6 +234,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
forkThread,
revertedFilesByTurnId,
} = useAgentRollback();
const { copiedId, copy } = useCopyToClipboard();
const parentRef = useRef<HTMLDivElement>(null);
const markScopeRestored = useGlobalStore((s) => s.markScopeRestored);
const markFileRestored = useGlobalStore((s) => s.markFileRestored);
Expand All @@ -253,7 +256,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
[turns]
);

const { renderEntries, callIdToToolName, entryCountByTurnId, turnById } = useMemo(() => {
const { renderEntries, callIdToToolName, entryCountByTurnId, turnById, assistantContentByTurnId } = useMemo(() => {
const entries: Array<{
item: Item;
turnId: string;
Expand All @@ -264,16 +267,23 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
const nameMap: Record<string, string> = {};
const countMap = new Map<string, number>();
const turnMap = new Map<string, (typeof turns)[number]>();
const contentMap = new Map<string, string>();

for (const turn of turns) {
turnMap.set(turn.id, turn);
const assistantParts: string[] = [];
for (const item of turn.items) {
if (item.type === 'tool_result') {
toolResultByCallId[item.callId] = item as any;
} else if (item.type === 'tool_call') {
nameMap[item.id] = item.name;
} else if (item.type === 'message' && item.role === 'assistant' && item.content) {
assistantParts.push(item.content);
}
}
if (assistantParts.length > 0) {
contentMap.set(turn.id, assistantParts.join('\n\n'));
}
}
for (const turn of turns) {
for (const item of turn.items) {
Expand All @@ -297,6 +307,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
callIdToToolName: nameMap,
entryCountByTurnId: countMap,
turnById: turnMap,
assistantContentByTurnId: contentMap,
};
}, [turnsStructureKey]);

Expand Down Expand Up @@ -526,6 +537,35 @@ export default function MessageStream({ threadId }: MessageStreamProps) {
toolResult={entry.toolResult}
/>
</div>
{isLastInTurn && (() => {
const assistantContent = assistantContentByTurnId.get(entry.turnId);
const isTurnDone = turn?.status === 'completed' || turn?.status === 'error';
if (!assistantContent || !isTurnDone) return null;
const isCopied = copiedId === `turn-${entry.turnId}`;
return (
<div className="px-10 pb-1 group/turnCopy">
<div className="pl-8 flex items-center gap-1.5 opacity-0 group-hover/turnCopy:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(assistantContent, `turn-${entry.turnId}`);
}}
aria-label="复制助手消息"
title="复制"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
</div>
</div>
);
})()}
{isLastInTurn && (
<div className="px-6 pb-2">
<TurnDiffPanel
Expand Down
32 changes: 6 additions & 26 deletions packages/desktop/src/shared/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const MessageItem = memo(function MessageItem({
const { copiedId, copy } = useCopyToClipboard();

const messageContent = item.type === 'message' ? item.content : null;
const isAssistant = item.type === 'message' && item.role === 'assistant';

const isCopied = copiedId === `msg-${item.id}`;

Expand Down Expand Up @@ -142,31 +141,12 @@ const MessageItem = memo(function MessageItem({

return (
<div className="flex flex-col items-start mb-1 pl-8 group">
<div className="max-w-[80%] text-[15px] text-[var(--text-primary)] leading-relaxed">
{isAssistant && messageContent != null && <MarkdownRenderer content={messageContent} />}
{item.partial && (
<span className="inline-block w-1.5 h-[1.1em] bg-[var(--accent-primary)] animate-pulse ml-0.5 align-middle" />
)}
</div>
{isAssistant && !item.partial && messageContent != null && (
<div className="max-w-[80%] mt-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copy(messageContent || '', `msg-${item.id}`);
}}
aria-label="复制消息"
title="复制"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
isCopied
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
{isCopied ? '已复制' : '复制'}
</button>
{messageContent != null && (
<div className="max-w-[80%] text-[15px] text-[var(--text-primary)] leading-relaxed">
<MarkdownRenderer content={messageContent} />
{item.partial && (
<span className="inline-block w-1.5 h-[1.1em] bg-[var(--accent-primary)] animate-pulse ml-0.5 align-middle" />
)}
</div>
)}
</div>
Expand Down
144 changes: 144 additions & 0 deletions packages/desktop/test/assistant-content-by-turn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import type { Item, Turn } from '../shared/types';

function buildAssistantContentByTurnId(turns: Turn[]): Map<string, string> {
const contentMap = new Map<string, string>();
for (const turn of turns) {
const assistantParts: string[] = [];
for (const item of turn.items) {
if (item.type === 'message' && item.role === 'assistant' && item.content) {
assistantParts.push(item.content);
}
}
if (assistantParts.length > 0) {
contentMap.set(turn.id, assistantParts.join('\n\n'));
}
}
return contentMap;
}

function makeMsg(role: 'user' | 'assistant', content: string): Item {
return { id: 'm-' + content, type: 'message', role, content };
}

function makeToolCall(
name: string,
status: 'pending' | 'running' | 'approved' | 'rejected',
id?: string
): Item {
return { id: id ?? 'tc-' + name, type: 'tool_call', name, args: {}, status };
}

function makeToolResult(callId: string, output?: string): Item {
return { id: 'tr-' + callId, type: 'tool_result', callId, output: output ?? 'ok' };
}

describe('assistantContentByTurnId', () => {
it('concatenates multiple assistant messages in one turn with double newline', () => {
const turns: Turn[] = [
{
id: 't1',
items: [
makeMsg('user', 'hello'),
makeMsg('assistant', 'I will help'),
makeToolCall('read_file', 'approved', 'tc-1'),
makeToolResult('tc-1', 'file content'),
makeMsg('assistant', 'Here is the result'),
],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.get('t1')).toBe('I will help\n\nHere is the result');
});

it('returns single message content when only one assistant message exists', () => {
const turns: Turn[] = [
{
id: 't1',
items: [makeMsg('user', 'hi'), makeMsg('assistant', 'hello')],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.get('t1')).toBe('hello');
});

it('does not include turn with no assistant messages', () => {
const turns: Turn[] = [
{
id: 't1',
items: [makeMsg('user', 'hi')],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.has('t1')).toBe(false);
});

it('handles multiple turns independently', () => {
const turns: Turn[] = [
{
id: 't1',
items: [makeMsg('user', 'a'), makeMsg('assistant', 'reply-a')],
status: 'completed',
},
{
id: 't2',
items: [makeMsg('user', 'b'), makeMsg('assistant', 'reply-b1'), makeMsg('assistant', 'reply-b2')],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.get('t1')).toBe('reply-a');
expect(map.get('t2')).toBe('reply-b1\n\nreply-b2');
});

it('ignores empty assistant messages', () => {
const turns: Turn[] = [
{
id: 't1',
items: [
makeMsg('user', 'hi'),
{ id: 'm-empty', type: 'message', role: 'assistant', content: '' },
makeMsg('assistant', 'actual content'),
],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.get('t1')).toBe('actual content');
});

it('handles interrupted turn with error status', () => {
const turns: Turn[] = [
{
id: 't1',
items: [
makeMsg('user', 'do something'),
makeMsg('assistant', 'partial response'),
makeToolCall('bash', 'running', 'tc-1'),
],
status: 'error',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.get('t1')).toBe('partial response');
});

it('handles turn with only tool calls and no assistant text', () => {
const turns: Turn[] = [
{
id: 't1',
items: [
makeMsg('user', 'run it'),
makeToolCall('bash', 'approved', 'tc-1'),
makeToolResult('tc-1', 'done'),
],
status: 'completed',
},
];
const map = buildAssistantContentByTurnId(turns);
expect(map.has('t1')).toBe(false);
});
});
Loading