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
12 changes: 12 additions & 0 deletions src/assets/token-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions src/assets/token-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 39 additions & 28 deletions src/components/ChatBox/HeaderBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,63 +12,74 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import tokenDarkIcon from '@/assets/token-dark.svg';
import tokenLightIcon from '@/assets/token-light.svg';
import { Button } from '@/components/ui/button';
import { useAuthStore } from '@/store/authStore';
import { PlayCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AnimatedTokenNumber } from '../TokenUtils';

interface HeaderBoxProps {
/** Token count to display */
tokens: number;
/** Task status for determining what button to show */
/** Total token count for the current project */
totalTokens?: number;
/** Task status – controls visibility of replay button */
status?: 'running' | 'finished' | 'pending' | 'pause';
/** Whether replay is loading */
/** Whether the replay action is in a loading state */
replayLoading?: boolean;
/** Callback when replay button is clicked */
/** Callback fired when the replay button is clicked */
onReplay?: () => void;
/** Optional class name */
/** Optional extra class names for the outer container */
className?: string;
}

export function HeaderBox({
tokens,
totalTokens = 0,
status,
replayLoading = false,
onReplay,
className,
}: HeaderBoxProps) {
const { t } = useTranslation();
const { appearance } = useAuthStore();
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;

// Replay button only appears when task is finished
const showReplayButton = status === 'finished';
// Replay button is disabled when task is running or pending
const isReplayDisabled =
status === 'running' || status === 'pending' || status === 'pause';

return (
<div
className={`flex h-[44px] w-full flex-row items-center justify-between px-3 ${className || ''}`}
>
<div className="flex items-center gap-md">
<div className="text-body-base font-bold leading-relaxed text-text-body">
Chat
</div>
<div className="text-xs font-semibold leading-17 text-text-information">
# {(tokens || 0).toLocaleString()}
</div>
{/* Left: title + replay button */}
<div className="flex items-center gap-2">
<span className="text-body-base font-bold leading-relaxed text-text-body">
{t('chat.chat-title')}
</span>

{showReplayButton && (
<Button
onClick={onReplay}
disabled={isReplayDisabled || replayLoading}
variant="ghost"
size="sm"
className="no-drag rounded-full bg-surface-information font-semibold !text-text-information"
>
<PlayCircle className="mr-1 h-3.5 w-3.5" />
{replayLoading ? t('common.loading') : t('chat.replay')}
</Button>
)}
</div>

{showReplayButton && (
<Button
onClick={onReplay}
disabled={isReplayDisabled || replayLoading}
variant="ghost"
size="sm"
className="no-drag rounded-full bg-surface-information font-semibold !text-text-information"
>
<PlayCircle />
{replayLoading ? t('common.loading') : t('chat.replay')}
</Button>
)}
{/* Right: project total token count */}
<div className="flex items-center gap-1 text-text-label">
<img src={tokenIcon} alt="" className="h-3.5 w-3.5" />
<span className="text-xs font-medium">
{t('chat.token-total-label')}{' '}
<AnimatedTokenNumber value={totalTokens} />
</span>
</div>
</div>
);
}
81 changes: 81 additions & 0 deletions src/components/ChatBox/TokenUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import { animate, useMotionValue } from 'framer-motion';
import React, { useEffect, useState } from 'react';

/**
* Format a raw token count into a compact human-readable string.
* < 1 000 → "950"
* 1 000 – 999 999 → "1.2K"
* ≥ 1 000 000 → "2.3M"
*/
const TOKEN_UNITS = [
{ threshold: 1_000_000_000_000, suffix: 'T' },
{ threshold: 1_000_000_000, suffix: 'B' },
{ threshold: 1_000_000, suffix: 'M' },
{ threshold: 1_000, suffix: 'K' },
] as const;

export function formatTokenCount(n: number): string {
if (!Number.isFinite(n)) return '0';

for (let index = 0; index < TOKEN_UNITS.length; index++) {
const unit = TOKEN_UNITS[index];

if (Math.abs(n) < unit.threshold) {
continue;
}

const rounded = Number((n / unit.threshold).toFixed(1));
const higherUnit = TOKEN_UNITS[index - 1];

if (Math.abs(rounded) >= 1000 && higherUnit) {
return `${(n / higherUnit.threshold).toFixed(1)}${higherUnit.suffix}`;
}

return `${rounded.toFixed(1)}${unit.suffix}`;
}

return String(Math.round(n));
}

interface AnimatedTokenNumberProps {
value: number;
className?: string;
}

/**
* Renders a formatted token count that smoothly animates on change.
* The underlying integer interpolates via a spring so the formatted
* label updates fluidly without aggressive bouncing.
*/
export const AnimatedTokenNumber: React.FC<AnimatedTokenNumberProps> = ({
value,
className,
}) => {
const motionValue = useMotionValue(value);
const [display, setDisplay] = useState(value);

useEffect(() => {
const controls = animate(motionValue, value, {
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
onUpdate: (v) => setDisplay(Math.round(v)),
});
return controls.stop;
}, [value, motionValue]);

return <span className={className}>{formatTokenCount(display)}</span>;
};
68 changes: 36 additions & 32 deletions src/components/ChatBox/UserQueryGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@

import { VanillaChatStore } from '@/store/chatStore';
import { AgentStep, ChatTaskStatus } from '@/types/constants';
import { motion } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { FileText } from 'lucide-react';
import React, {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentMessageCard } from './MessageItem/AgentMessageCard';
import { NoticeCard } from './MessageItem/NoticeCard';
import { TaskCompletionCard } from './MessageItem/TaskCompletionCard';
import { UserMessageCard } from './MessageItem/UserMessageCard';
import { StreamingTaskList } from './TaskBox/StreamingTaskList';
import { TaskCard } from './TaskBox/TaskCard';
import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton';
import { AnimatedTokenNumber } from './TokenUtils';

interface QueryGroup {
queryId: string;
Expand All @@ -54,35 +51,25 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
onQueryActive,
index,
}) => {
const { t } = useTranslation();
const groupRef = useRef<HTMLDivElement>(null);
const taskBoxRef = useRef<HTMLDivElement>(null);
const [_isTaskBoxSticky, setIsTaskBoxSticky] = useState(false);
const [isCompletionReady, setIsCompletionReady] = useState(false);
const chatState = chatStore.getState();
const activeTaskId = chatState.activeTaskId;

// Subscribe to streaming decompose text separately for efficient updates
const streamingDecomposeText = useSyncExternalStore(
(callback) => chatStore.subscribe(callback),
() => {
const state = chatStore.getState();
const taskId = state.activeTaskId;
if (!taskId || !state.tasks[taskId]) return '';
return state.tasks[taskId].streamingDecomposeText || '';
}
);
const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : null;

// Show task if this query group has a task message OR if it's the most recent user query during splitting
// During splitting phase (no to_sub_tasks yet), show task for the most recent query only
// Exclude human-reply scenarios (when user is replying to an activeAsk)
const isHumanReply =
queryGroup.userMessage &&
activeTaskId &&
chatState.tasks[activeTaskId] &&
(chatState.tasks[activeTaskId].activeAsk ||
activeTask &&
(activeTask.activeAsk ||
// Check if this user message follows an 'ask' message in the message sequence
(() => {
const messages = chatState.tasks[activeTaskId].messages;
const messages = activeTask.messages;
const userMessageIndex = messages.findIndex(
(m: any) => m.id === queryGroup.userMessage.id
);
Expand All @@ -99,29 +86,27 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
const isLastUserQuery =
!queryGroup.taskMessage &&
!isHumanReply &&
activeTaskId &&
chatState.tasks[activeTaskId] &&
activeTask &&
queryGroup.userMessage &&
queryGroup.userMessage.id ===
chatState.tasks[activeTaskId].messages
.filter((m: any) => m.role === 'user')
.pop()?.id &&
activeTask.messages.filter((m: any) => m.role === 'user').pop()?.id &&
// Only show during active phases (not finished)
chatState.tasks[activeTaskId].status !== ChatTaskStatus.FINISHED;
activeTask.status !== ChatTaskStatus.FINISHED;

// Only show the fallback task box for the newest query while the agent is still splitting work.
// Simple Q&A sessions set hasWaitComfirm to true, so we should not render an empty task box there.
// Also, do not show fallback task if we are currently decomposing (streaming text).
const streamingDecomposeText = activeTask?.streamingDecomposeText || '';
const isDecomposing = streamingDecomposeText.length > 0;
const shouldShowFallbackTask =
isLastUserQuery &&
activeTaskId &&
!chatState.tasks[activeTaskId].hasWaitComfirm &&
activeTask &&
!activeTask.hasWaitComfirm &&
!isDecomposing;

const task =
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTaskId
? chatState.tasks[activeTaskId]
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTask
? activeTask
: null;

// Reset completion flag when active task or query group changes
Expand Down Expand Up @@ -292,6 +277,25 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
</motion.div>
)}

{/* Live token count – visible only while the task is running */}
<AnimatePresence>
{task && task.status === ChatTaskStatus.RUNNING && (
<motion.div
key="live-token-count"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="mt-6 flex items-center justify-end gap-1 px-sm py-1 text-xs text-text-label"
>
<span>{t('chat.current-task')}</span>
<span>·</span>
<AnimatedTokenNumber value={task.tokens || 0} />
<span>{t('chat.tokens')}</span>
</motion.div>
)}
</AnimatePresence>

{/* Other Messages */}
{queryGroup.otherMessages.map((message) => {
if (message.content.length > 0) {
Expand Down
Loading
Loading