diff --git a/src/assets/token-dark.svg b/src/assets/token-dark.svg
new file mode 100644
index 000000000..09afc3ede
--- /dev/null
+++ b/src/assets/token-dark.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/assets/token-light.svg b/src/assets/token-light.svg
new file mode 100644
index 000000000..441e18e5f
--- /dev/null
+++ b/src/assets/token-light.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/components/ChatBox/HeaderBox/index.tsx b/src/components/ChatBox/HeaderBox/index.tsx
index eead12444..3c12df89d 100644
--- a/src/components/ChatBox/HeaderBox/index.tsx
+++ b/src/components/ChatBox/HeaderBox/index.tsx
@@ -12,35 +12,39 @@
// 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';
@@ -48,27 +52,34 @@ export function HeaderBox({
-
-
- Chat
-
-
- # {(tokens || 0).toLocaleString()}
-
+ {/* Left: title + replay button */}
+
+
+ {t('chat.chat-title')}
+
+
+ {showReplayButton && (
+
+ )}
- {showReplayButton && (
-
- )}
+ {/* Right: project total token count */}
+
+

+
+ {t('chat.token-total-label')}{' '}
+
+
+
);
}
diff --git a/src/components/ChatBox/TokenUtils.tsx b/src/components/ChatBox/TokenUtils.tsx
new file mode 100644
index 000000000..3464e2998
--- /dev/null
+++ b/src/components/ChatBox/TokenUtils.tsx
@@ -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
= ({
+ 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 {formatTokenCount(display)};
+};
diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx
index 61de64e5e..08c106b8d 100644
--- a/src/components/ChatBox/UserQueryGroup.tsx
+++ b/src/components/ChatBox/UserQueryGroup.tsx
@@ -14,14 +14,10 @@
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';
@@ -29,6 +25,7 @@ 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;
@@ -54,35 +51,25 @@ export const UserQueryGroup: React.FC = ({
onQueryActive,
index,
}) => {
+ const { t } = useTranslation();
const groupRef = useRef(null);
const taskBoxRef = useRef(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
);
@@ -99,29 +86,27 @@ export const UserQueryGroup: React.FC = ({
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
@@ -292,6 +277,25 @@ export const UserQueryGroup: React.FC = ({
)}
+ {/* Live token count – visible only while the task is running */}
+
+ {task && task.status === ChatTaskStatus.RUNNING && (
+
+ {t('chat.current-task')}
+ ·
+
+ {t('chat.tokens')}
+
+ )}
+
+
{/* Other Messages */}
{queryGroup.otherMessages.map((message) => {
if (message.content.length > 0) {
diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx
index ad44c9636..3e646d041 100644
--- a/src/components/ChatBox/index.tsx
+++ b/src/components/ChatBox/index.tsx
@@ -23,6 +23,7 @@ import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { generateUniqueId, replayActiveTask } from '@/lib';
import { proxyUpdateTriggerExecution } from '@/service/triggerApi';
import { useAuthStore } from '@/store/authStore';
+import type { VanillaChatStore } from '@/store/chatStore';
import { ExecutionStatus } from '@/types';
import { AgentStep, ChatTaskStatus } from '@/types/constants';
import { TriangleAlert } from 'lucide-react';
@@ -34,6 +35,15 @@ import BottomBox from './BottomBox';
import { HeaderBox } from './HeaderBox';
import { ProjectChatContainer } from './ProjectChatContainer';
+const getChatStoreTotalTokens = (chatStore: VanillaChatStore): number => {
+ const chatState = chatStore.getState();
+ return Object.values(chatState.tasks).reduce(
+ (total, task) =>
+ total + (typeof task.tokens === 'number' ? task.tokens : 0),
+ 0
+ );
+};
+
export default function ChatBox(): JSX.Element {
const [message, setMessage] = useState('');
@@ -143,6 +153,7 @@ export default function ChatBox(): JSX.Element {
const [loading, setLoading] = useState(false);
const [isReplayLoading, setIsReplayLoading] = useState(false);
const [isPauseResumeLoading, setIsPauseResumeLoading] = useState(false);
+ const [projectTotalTokens, setProjectTotalTokens] = useState(0);
const activeTaskId = chatStore?.activeTaskId;
const activeTaskMessages = chatStore?.tasks[activeTaskId as string]?.messages;
@@ -192,6 +203,49 @@ export default function ChatBox(): JSX.Element {
return projectStore.getAllChatStores(projectStore.activeProjectId);
}, [projectStore]);
+ useEffect(() => {
+ if (!projectStore.activeProjectId) {
+ setProjectTotalTokens(0);
+ return;
+ }
+
+ const chatTotals = new Map();
+ let nextProjectTotalTokens = 0;
+
+ getAllChatStoresMemoized.forEach(({ chatId, chatStore }) => {
+ const chatTotalTokens = getChatStoreTotalTokens(chatStore);
+ chatTotals.set(chatId, chatTotalTokens);
+ nextProjectTotalTokens += chatTotalTokens;
+ });
+
+ setProjectTotalTokens(nextProjectTotalTokens);
+
+ const unsubscribers = getAllChatStoresMemoized.map(
+ ({ chatId, chatStore }) =>
+ chatStore.subscribe((state) => {
+ const nextChatTotalTokens = Object.values(state.tasks).reduce(
+ (total, task) =>
+ total + (typeof task.tokens === 'number' ? task.tokens : 0),
+ 0
+ );
+ const previousChatTotalTokens = chatTotals.get(chatId) ?? 0;
+
+ if (nextChatTotalTokens === previousChatTotalTokens) {
+ return;
+ }
+
+ chatTotals.set(chatId, nextChatTotalTokens);
+ nextProjectTotalTokens +=
+ nextChatTotalTokens - previousChatTotalTokens;
+ setProjectTotalTokens(nextProjectTotalTokens);
+ })
+ );
+
+ return () => {
+ unsubscribers.forEach((unsubscribe) => unsubscribe());
+ };
+ }, [projectStore.activeProjectId, getAllChatStoresMemoized]);
+
// Check if any chat store in the project has messages
const hasAnyMessages = useMemo(() => {
if (!chatStore) return false;
@@ -984,7 +1038,7 @@ export default function ChatBox(): JSX.Element {
{/* Header Box - Always visible */}
{chatStore.activeTaskId && (