Skip to content
80 changes: 25 additions & 55 deletions browser/src/components/AssistantFooter/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
import { CheckOutlined } from '@ant-design/icons';
import { Button, Flex, Spin } from 'antd';
import { Button, Flex } from 'antd';
import { useEffect, useState } from 'react';
import { useSnapshot } from 'valtio';
import { useClipboard } from '@/hooks/useClipboard';
import CopyIcon from '@/icons/copy.svg?react';
import DislikeIcon from '@/icons/dislike.svg?react';
import LikeIcon from '@/icons/like.svg?react';
import RefreshIcon from '@/icons/refresh.svg?react';
import type { AppStatus } from '@/state/chat';
import { state } from '@/state/sender';
import { actions, state } from '@/state/chat';
import type { Message } from '@/types/chat';
import ActivityIndicator from '../ActivityIndicator';
import styles from './index.module.css';

interface AssistantFooterProps {
message: Message;
status: AppStatus;
}

const AssistantFooter: React.FC<AssistantFooterProps> = ({
message,
status,
}) => {
const { mode } = useSnapshot(state);
const AssistantFooter: React.FC<AssistantFooterProps> = ({ message }) => {
const { writeText } = useClipboard();
const { status } = useSnapshot(state);
const [isCopySuccess, setIsCopySuccess] = useState(false);

/**
* Handle retry functionality
*/
const handleRetry = async () => {
if (status === 'processing') {
return; // Don't retry if already processing
}

try {
await actions.retry();
} catch (error) {
console.error('Retry failed:', error);
}
};

/**
* read all Text Message and copy to clipboard
*/
Expand Down Expand Up @@ -57,73 +66,34 @@ const AssistantFooter: React.FC<AssistantFooterProps> = ({
}
}, [isCopySuccess]);

if (mode === 'plan' && status === 'idle') {
// const lastMessage = message;
// if (
// lastMessage?.type === UIMessageType.Text &&
// lastMessage.mode === 'plan'
// ) {
// return (
// <div className="w-full p-2 border-t border-gray-100 bg-gray-50/50">
// <Flex justify="space-between" align="center" className="w-full">
// <Text
// type="secondary"
// className="text-sm text-gray-600 flex-1 mr-4"
// >
// {t('plan.approveDescription')}
// </Text>
// <Button
// type="primary"
// size="middle"
// icon={<RefreshIcon />}
// className="shrink-0"
// onClick={async () => {
// actions.updateMode('agent');
// console.log('approvePlan', message);
// }}
// >
// {t('plan.approve')}
// </Button>
// </Flex>
// </div>
// );
// }
}

if (status !== 'idle') {
return (
<div className="flex items-center space-x-2 pt-2">
<Spin size="small" />
<ActivityIndicator />
</div>
);
}

return (
<Flex className={styles.assistantFooter}>
<Button
className={styles.assistantFooterIcon}
type="text"
icon={<RefreshIcon />}
onClick={() => {
console.log('onRetry');
}}
onClick={handleRetry}
disabled={status === 'processing'}
title="Retry"
/>
<Button
className={styles.assistantFooterIcon}
type="text"
icon={isCopySuccess ? <CheckOutlined /> : <CopyIcon />}
onClick={handleCopy}
title="Copy"
/>
<Button
className={styles.assistantFooterIcon}
type="text"
icon={<LikeIcon />}
title="Like"
/>
<Button
className={styles.assistantFooterIcon}
type="text"
icon={<DislikeIcon />}
title="Dislike"
/>
</Flex>
);
Expand Down
106 changes: 5 additions & 101 deletions browser/src/components/AssistantMessage/AssistantToolMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type React from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { UIToolPart } from '@/types/chat';
import {
BashRender,
Expand All @@ -15,12 +13,11 @@ import {
WriteRender,
} from '../ToolRender';

const ToolResultItem: React.FC<{ part: UIToolPart }> = ({ part }) => {
if (part.state !== 'tool_result') {
return null;
}

const { name, result } = part;
const AssistantToolMessage: React.FC<{
part: UIToolPart;
}> = ({ part }) => {
const name = part.type === 'tool-result' ? part.toolName : part.name;
const { result } = part;
if (result?.isError) {
return <FailRender part={part} />;
}
Expand Down Expand Up @@ -50,97 +47,4 @@ const ToolResultItem: React.FC<{ part: UIToolPart }> = ({ part }) => {
}
};

const AssistantToolMessage: React.FC<{
part: UIToolPart;
}> = ({ part }) => {
const { name } = part;
const { t } = useTranslation();
const [isResultExpanded, setIsResultExpanded] = useState(false);

const toolIcon = useMemo(() => {
switch (name) {
case 'grep':
return '🔍';
case 'read':
return '📖';
case 'write':
return '✏️';
case 'bash':
return '💻';
case 'edit':
return '🔧';
case 'fetch':
return '🌐';
case 'ls':
return '📁';
case 'glob':
return '🎯';
case 'todoRead':
case 'todoWrite':
return '📄';
default:
return '🔧';
}
}, [name]);

const statusInfo = useMemo(() => {
if (part.result?.isError) {
return {
icon: '❌',
iconColor: 'text-red-500',
statusText: t('tool.status.failed'),
};
}
switch (part.state) {
case 'tool_use':
return {
icon: '🔄',
iconColor: 'text-blue-500 animate-spin',
statusText: t('tool.status.executing'),
};
case 'tool_result':
return {
icon: '✓',
iconColor: 'text-green-500',
statusText: t('tool.status.completed'),
};
default:
return {
icon: '?',
iconColor: 'text-gray-500',
statusText: t('tool.status.unknown'),
};
}
}, [part.state]);

return (
<div className="py-2 px-1">
<div className="flex items-center gap-2 group">
<span className="text-base flex-shrink-0">{toolIcon}</span>
<span className={`text-sm flex-shrink-0 ${statusInfo.iconColor}`}>
{statusInfo.icon}
</span>
<span className="text-sm text-gray-700 font-medium">{name}</span>
{part.description && (
<span className="text-xs text-gray-400">{part.description}</span>
)}
<span className="text-xs text-gray-400 ml-auto">
{statusInfo.statusText}
</span>
<button
onClick={() => setIsResultExpanded(!isResultExpanded)}
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
{isResultExpanded ? '▲' : '▼'}
</button>
</div>
{isResultExpanded && (
<div className="mt-2">
<ToolResultItem part={part} />
</div>
)}
</div>
);
};

export default AssistantToolMessage;
27 changes: 24 additions & 3 deletions browser/src/components/AssistantMessage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import type {
TextPart,
UIAssistantMessage,
UIToolPart,
UIToolPair,
} from '@/types/chat';
import MarkdownRenderer from '../MarkdownRenderer';
import ToolPairRender from '../ToolPairRender';
import ApprovalModal from './ApprovalModal';
import AssistantTextMessage from './AssistantTextMessage';
import AssistantThinkingMessage from './AssistantThinkingMessage';
import AssistantToolMessage from './AssistantToolMessage';
import styles from './index.module.css';

interface MessagePartProps {
part: TextPart | ReasoningPart | UIToolPart;
part: TextPart | ReasoningPart | UIToolPart | UIToolPair;
uuid: string;
}

Expand All @@ -31,6 +33,21 @@ const MessagePart: React.FC<MessagePartProps> = memo(({ part, uuid }) => {
<ApprovalModal part={part} />
</>
);
case 'tool-result':
return (
<>
<AssistantToolMessage key={`${uuid}-${part.state}`} part={part} />
<ApprovalModal part={part} />
</>
);
case 'tool-pair':
return (
<ToolPairRender
key={`${uuid}-pair-${part.id}`}
pair={part}
uuid={uuid}
/>
);
default:
return (
<div key={uuid}>
Expand Down Expand Up @@ -62,8 +79,12 @@ const AssistantMessage: React.FC<MessageProps> = ({ message }) => {

return (
<div className={styles.assistantMessage}>
{messageParts.map((part) => (
<MessagePart key={message.uuid} part={part} uuid={message.uuid} />
{messageParts.map((part, index) => (
<MessagePart
key={`${message.uuid}-${index}`}
part={part}
uuid={`${message.uuid}-${index}`}
/>
))}
</div>
);
Expand Down
9 changes: 8 additions & 1 deletion browser/src/components/ChatContent/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@
}

.bubbleList {
height: 100%;
/* Remove height: 100% to prevent internal scrolling */
max-width: 800px;
margin-inline: auto;
/* Ensure content flows naturally */
min-height: auto;
}

.skeletonContainer {
width: 600px;
}

/* Override Ant Design bubble list gap */
.chatList :global(.ant-bubble-list) {
gap: 0;
}
Loading
Loading