Skip to content

Commit ddb93d8

Browse files
feat(chat): Clarify token usage in chat header (tooltip + task & project total) (#1501)
1 parent 38f8f2b commit ddb93d8

17 files changed

Lines changed: 279 additions & 61 deletions

File tree

src/assets/token-dark.svg

Lines changed: 12 additions & 0 deletions
Loading

src/assets/token-light.svg

Lines changed: 12 additions & 0 deletions
Loading

src/components/ChatBox/HeaderBox/index.tsx

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,63 +12,74 @@
1212
// limitations under the License.
1313
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
1414

15+
import tokenDarkIcon from '@/assets/token-dark.svg';
16+
import tokenLightIcon from '@/assets/token-light.svg';
1517
import { Button } from '@/components/ui/button';
18+
import { useAuthStore } from '@/store/authStore';
1619
import { PlayCircle } from 'lucide-react';
1720
import { useTranslation } from 'react-i18next';
21+
import { AnimatedTokenNumber } from '../TokenUtils';
1822

1923
interface HeaderBoxProps {
20-
/** Token count to display */
21-
tokens: number;
22-
/** Task status for determining what button to show */
24+
/** Total token count for the current project */
25+
totalTokens?: number;
26+
/** Task status – controls visibility of replay button */
2327
status?: 'running' | 'finished' | 'pending' | 'pause';
24-
/** Whether replay is loading */
28+
/** Whether the replay action is in a loading state */
2529
replayLoading?: boolean;
26-
/** Callback when replay button is clicked */
30+
/** Callback fired when the replay button is clicked */
2731
onReplay?: () => void;
28-
/** Optional class name */
32+
/** Optional extra class names for the outer container */
2933
className?: string;
3034
}
3135

3236
export function HeaderBox({
33-
tokens,
37+
totalTokens = 0,
3438
status,
3539
replayLoading = false,
3640
onReplay,
3741
className,
3842
}: HeaderBoxProps) {
3943
const { t } = useTranslation();
44+
const { appearance } = useAuthStore();
45+
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
4046

41-
// Replay button only appears when task is finished
4247
const showReplayButton = status === 'finished';
43-
// Replay button is disabled when task is running or pending
4448
const isReplayDisabled =
4549
status === 'running' || status === 'pending' || status === 'pause';
4650

4751
return (
4852
<div
4953
className={`flex h-[44px] w-full flex-row items-center justify-between px-3 ${className || ''}`}
5054
>
51-
<div className="flex items-center gap-md">
52-
<div className="text-body-base font-bold leading-relaxed text-text-body">
53-
Chat
54-
</div>
55-
<div className="text-xs font-semibold leading-17 text-text-information">
56-
# {(tokens || 0).toLocaleString()}
57-
</div>
55+
{/* Left: title + replay button */}
56+
<div className="flex items-center gap-2">
57+
<span className="text-body-base font-bold leading-relaxed text-text-body">
58+
{t('chat.chat-title')}
59+
</span>
60+
61+
{showReplayButton && (
62+
<Button
63+
onClick={onReplay}
64+
disabled={isReplayDisabled || replayLoading}
65+
variant="ghost"
66+
size="sm"
67+
className="no-drag rounded-full bg-surface-information font-semibold !text-text-information"
68+
>
69+
<PlayCircle className="mr-1 h-3.5 w-3.5" />
70+
{replayLoading ? t('common.loading') : t('chat.replay')}
71+
</Button>
72+
)}
5873
</div>
5974

60-
{showReplayButton && (
61-
<Button
62-
onClick={onReplay}
63-
disabled={isReplayDisabled || replayLoading}
64-
variant="ghost"
65-
size="sm"
66-
className="no-drag rounded-full bg-surface-information font-semibold !text-text-information"
67-
>
68-
<PlayCircle />
69-
{replayLoading ? t('common.loading') : t('chat.replay')}
70-
</Button>
71-
)}
75+
{/* Right: project total token count */}
76+
<div className="flex items-center gap-1 text-text-label">
77+
<img src={tokenIcon} alt="" className="h-3.5 w-3.5" />
78+
<span className="text-xs font-medium">
79+
{t('chat.token-total-label')}{' '}
80+
<AnimatedTokenNumber value={totalTokens} />
81+
</span>
82+
</div>
7283
</div>
7384
);
7485
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
14+
15+
import { animate, useMotionValue } from 'framer-motion';
16+
import React, { useEffect, useState } from 'react';
17+
18+
/**
19+
* Format a raw token count into a compact human-readable string.
20+
* < 1 000 → "950"
21+
* 1 000 – 999 999 → "1.2K"
22+
* ≥ 1 000 000 → "2.3M"
23+
*/
24+
const TOKEN_UNITS = [
25+
{ threshold: 1_000_000_000_000, suffix: 'T' },
26+
{ threshold: 1_000_000_000, suffix: 'B' },
27+
{ threshold: 1_000_000, suffix: 'M' },
28+
{ threshold: 1_000, suffix: 'K' },
29+
] as const;
30+
31+
export function formatTokenCount(n: number): string {
32+
if (!Number.isFinite(n)) return '0';
33+
34+
for (let index = 0; index < TOKEN_UNITS.length; index++) {
35+
const unit = TOKEN_UNITS[index];
36+
37+
if (Math.abs(n) < unit.threshold) {
38+
continue;
39+
}
40+
41+
const rounded = Number((n / unit.threshold).toFixed(1));
42+
const higherUnit = TOKEN_UNITS[index - 1];
43+
44+
if (Math.abs(rounded) >= 1000 && higherUnit) {
45+
return `${(n / higherUnit.threshold).toFixed(1)}${higherUnit.suffix}`;
46+
}
47+
48+
return `${rounded.toFixed(1)}${unit.suffix}`;
49+
}
50+
51+
return String(Math.round(n));
52+
}
53+
54+
interface AnimatedTokenNumberProps {
55+
value: number;
56+
className?: string;
57+
}
58+
59+
/**
60+
* Renders a formatted token count that smoothly animates on change.
61+
* The underlying integer interpolates via a spring so the formatted
62+
* label updates fluidly without aggressive bouncing.
63+
*/
64+
export const AnimatedTokenNumber: React.FC<AnimatedTokenNumberProps> = ({
65+
value,
66+
className,
67+
}) => {
68+
const motionValue = useMotionValue(value);
69+
const [display, setDisplay] = useState(value);
70+
71+
useEffect(() => {
72+
const controls = animate(motionValue, value, {
73+
duration: 0.5,
74+
ease: [0.16, 1, 0.3, 1],
75+
onUpdate: (v) => setDisplay(Math.round(v)),
76+
});
77+
return controls.stop;
78+
}, [value, motionValue]);
79+
80+
return <span className={className}>{formatTokenCount(display)}</span>;
81+
};

src/components/ChatBox/UserQueryGroup.tsx

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,18 @@
1414

1515
import { VanillaChatStore } from '@/store/chatStore';
1616
import { AgentStep, ChatTaskStatus } from '@/types/constants';
17-
import { motion } from 'framer-motion';
17+
import { AnimatePresence, motion } from 'framer-motion';
1818
import { FileText } from 'lucide-react';
19-
import React, {
20-
useEffect,
21-
useRef,
22-
useState,
23-
useSyncExternalStore,
24-
} from 'react';
19+
import React, { useEffect, useRef, useState } from 'react';
20+
import { useTranslation } from 'react-i18next';
2521
import { AgentMessageCard } from './MessageItem/AgentMessageCard';
2622
import { NoticeCard } from './MessageItem/NoticeCard';
2723
import { TaskCompletionCard } from './MessageItem/TaskCompletionCard';
2824
import { UserMessageCard } from './MessageItem/UserMessageCard';
2925
import { StreamingTaskList } from './TaskBox/StreamingTaskList';
3026
import { TaskCard } from './TaskBox/TaskCard';
3127
import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton';
28+
import { AnimatedTokenNumber } from './TokenUtils';
3229

3330
interface QueryGroup {
3431
queryId: string;
@@ -54,35 +51,25 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
5451
onQueryActive,
5552
index,
5653
}) => {
54+
const { t } = useTranslation();
5755
const groupRef = useRef<HTMLDivElement>(null);
5856
const taskBoxRef = useRef<HTMLDivElement>(null);
5957
const [_isTaskBoxSticky, setIsTaskBoxSticky] = useState(false);
6058
const [isCompletionReady, setIsCompletionReady] = useState(false);
6159
const chatState = chatStore.getState();
6260
const activeTaskId = chatState.activeTaskId;
63-
64-
// Subscribe to streaming decompose text separately for efficient updates
65-
const streamingDecomposeText = useSyncExternalStore(
66-
(callback) => chatStore.subscribe(callback),
67-
() => {
68-
const state = chatStore.getState();
69-
const taskId = state.activeTaskId;
70-
if (!taskId || !state.tasks[taskId]) return '';
71-
return state.tasks[taskId].streamingDecomposeText || '';
72-
}
73-
);
61+
const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : null;
7462

7563
// Show task if this query group has a task message OR if it's the most recent user query during splitting
7664
// During splitting phase (no to_sub_tasks yet), show task for the most recent query only
7765
// Exclude human-reply scenarios (when user is replying to an activeAsk)
7866
const isHumanReply =
7967
queryGroup.userMessage &&
80-
activeTaskId &&
81-
chatState.tasks[activeTaskId] &&
82-
(chatState.tasks[activeTaskId].activeAsk ||
68+
activeTask &&
69+
(activeTask.activeAsk ||
8370
// Check if this user message follows an 'ask' message in the message sequence
8471
(() => {
85-
const messages = chatState.tasks[activeTaskId].messages;
72+
const messages = activeTask.messages;
8673
const userMessageIndex = messages.findIndex(
8774
(m: any) => m.id === queryGroup.userMessage.id
8875
);
@@ -99,29 +86,27 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
9986
const isLastUserQuery =
10087
!queryGroup.taskMessage &&
10188
!isHumanReply &&
102-
activeTaskId &&
103-
chatState.tasks[activeTaskId] &&
89+
activeTask &&
10490
queryGroup.userMessage &&
10591
queryGroup.userMessage.id ===
106-
chatState.tasks[activeTaskId].messages
107-
.filter((m: any) => m.role === 'user')
108-
.pop()?.id &&
92+
activeTask.messages.filter((m: any) => m.role === 'user').pop()?.id &&
10993
// Only show during active phases (not finished)
110-
chatState.tasks[activeTaskId].status !== ChatTaskStatus.FINISHED;
94+
activeTask.status !== ChatTaskStatus.FINISHED;
11195

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

122107
const task =
123-
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTaskId
124-
? chatState.tasks[activeTaskId]
108+
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTask
109+
? activeTask
125110
: null;
126111

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

280+
{/* Live token count – visible only while the task is running */}
281+
<AnimatePresence>
282+
{task && task.status === ChatTaskStatus.RUNNING && (
283+
<motion.div
284+
key="live-token-count"
285+
initial={{ opacity: 0, y: 4 }}
286+
animate={{ opacity: 1, y: 0 }}
287+
exit={{ opacity: 0, y: -4 }}
288+
transition={{ duration: 0.25, ease: 'easeOut' }}
289+
className="mt-6 flex items-center justify-end gap-1 px-sm py-1 text-xs text-text-label"
290+
>
291+
<span>{t('chat.current-task')}</span>
292+
<span>·</span>
293+
<AnimatedTokenNumber value={task.tokens || 0} />
294+
<span>{t('chat.tokens')}</span>
295+
</motion.div>
296+
)}
297+
</AnimatePresence>
298+
295299
{/* Other Messages */}
296300
{queryGroup.otherMessages.map((message) => {
297301
if (message.content.length > 0) {

0 commit comments

Comments
 (0)