From 9ebb6553ee01db17fba12bd8fb9eed5b48d1cdd9 Mon Sep 17 00:00:00 2001 From: dataCenter430 Date: Mon, 16 Mar 2026 22:31:33 +0100 Subject: [PATCH 1/3] feat(chat): clarify token usage in header with tooltip and project total --- src/components/ChatBox/HeaderBox/index.tsx | 125 +++++++++++++-------- src/components/ChatBox/index.tsx | 7 ++ src/i18n/locales/ar/chat.json | 3 + src/i18n/locales/de/chat.json | 3 + src/i18n/locales/en-us/chat.json | 3 + src/i18n/locales/es/chat.json | 3 + src/i18n/locales/fr/chat.json | 3 + src/i18n/locales/it/chat.json | 3 + src/i18n/locales/ja/chat.json | 3 + src/i18n/locales/ko/chat.json | 3 + src/i18n/locales/ru/chat.json | 3 + src/i18n/locales/zh-Hans/chat.json | 3 + src/i18n/locales/zh-Hant/chat.json | 3 + 13 files changed, 118 insertions(+), 47 deletions(-) diff --git a/src/components/ChatBox/HeaderBox/index.tsx b/src/components/ChatBox/HeaderBox/index.tsx index 704dbd1ad..250953c28 100644 --- a/src/components/ChatBox/HeaderBox/index.tsx +++ b/src/components/ChatBox/HeaderBox/index.tsx @@ -1,57 +1,88 @@ -import { Button } from "@/components/ui/button"; -import { PlayCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; +// ========= 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 { Button } from '@/components/ui/button'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { PlayCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface HeaderBoxProps { - /** Token count to display */ - tokens: number; - /** Task status for determining what button to show */ - status?: 'running' | 'finished' | 'pending' | 'pause'; - /** Whether replay is loading */ - replayLoading?: boolean; - /** Callback when replay button is clicked */ - onReplay?: () => void; - /** Optional class name */ - className?: string; + /** Token count for the current task */ + tokens: number; + /** Total token count across the project (optional; when provided, tooltip shows both) */ + totalTokens?: number; + /** Task status for determining what button to show */ + status?: 'running' | 'finished' | 'pending' | 'pause'; + /** Whether replay is loading */ + replayLoading?: boolean; + /** Callback when replay button is clicked */ + onReplay?: () => void; + /** Optional class name */ + className?: string; } export function HeaderBox({ - tokens, - status, - replayLoading = false, - onReplay, - className, + tokens, + totalTokens, + status, + replayLoading = false, + onReplay, + className, }: HeaderBoxProps) { - const { t } = useTranslation(); + const { t } = useTranslation(); - // 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'; + const taskTokens = tokens ?? 0; + const hasTotal = totalTokens !== undefined && totalTokens !== null; + const tooltipContent = + t('chat.token-usage') + + (hasTotal + ? ` — ${t('chat.token-usage-this-task')}: ${taskTokens.toLocaleString()} · ${t('chat.token-usage-project-total')}: ${(totalTokens ?? 0).toLocaleString()}` + : ` — ${t('chat.token-usage-this-task')}: ${taskTokens.toLocaleString()}`); - return ( -
-
-
- Chat -
-
- # {tokens || 0} -
-
+ // 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'; - {showReplayButton && ( - - )} + return ( +
+
+
+ Chat
- ); -} \ No newline at end of file + +
+ # {taskTokens.toLocaleString()} +
+
+
+ + {showReplayButton && ( + + )} +
+ ); +} diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index ad44c9636..7ba8a1be3 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -985,6 +985,13 @@ export default function ChatBox(): JSX.Element { {chatStore.activeTaskId && ( Date: Wed, 18 Mar 2026 18:21:04 +0100 Subject: [PATCH 2/3] feat: improve the token usage with new design --- src/assets/token-dark.svg | 12 ++++ src/assets/token-light.svg | 12 ++++ src/components/ChatBox/HeaderBox/index.tsx | 79 +++++++++++----------- src/components/ChatBox/TokenUtils.tsx | 57 ++++++++++++++++ src/components/ChatBox/UserQueryGroup.tsx | 51 +++++++++++++- src/components/ChatBox/index.tsx | 3 +- src/i18n/locales/ar/chat.json | 7 +- src/i18n/locales/de/chat.json | 7 +- src/i18n/locales/en-us/chat.json | 7 +- src/i18n/locales/es/chat.json | 7 +- src/i18n/locales/fr/chat.json | 7 +- src/i18n/locales/it/chat.json | 7 +- src/i18n/locales/ja/chat.json | 7 +- src/i18n/locales/ko/chat.json | 7 +- src/i18n/locales/ru/chat.json | 7 +- src/i18n/locales/zh-Hans/chat.json | 7 +- src/i18n/locales/zh-Hant/chat.json | 7 +- 17 files changed, 214 insertions(+), 77 deletions(-) create mode 100644 src/assets/token-dark.svg create mode 100644 src/assets/token-light.svg create mode 100644 src/components/ChatBox/TokenUtils.tsx 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 250953c28..3c12df89d 100644 --- a/src/components/ChatBox/HeaderBox/index.tsx +++ b/src/components/ChatBox/HeaderBox/index.tsx @@ -12,47 +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 { TooltipSimple } from '@/components/ui/tooltip'; +import { useAuthStore } from '@/store/authStore'; import { PlayCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { AnimatedTokenNumber } from '../TokenUtils'; interface HeaderBoxProps { - /** Token count for the current task */ - tokens: number; - /** Total token count across the project (optional; when provided, tooltip shows both) */ + /** Total token count for the current project */ totalTokens?: number; - /** Task status for determining what button to show */ + /** 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, + totalTokens = 0, status, replayLoading = false, onReplay, className, }: HeaderBoxProps) { const { t } = useTranslation(); + const { appearance } = useAuthStore(); + const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; - const taskTokens = tokens ?? 0; - const hasTotal = totalTokens !== undefined && totalTokens !== null; - const tooltipContent = - t('chat.token-usage') + - (hasTotal - ? ` — ${t('chat.token-usage-this-task')}: ${taskTokens.toLocaleString()} · ${t('chat.token-usage-project-total')}: ${(totalTokens ?? 0).toLocaleString()}` - : ` — ${t('chat.token-usage-this-task')}: ${taskTokens.toLocaleString()}`); - - // 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'; @@ -60,29 +52,34 @@ export function HeaderBox({
-
-
- Chat -
- -
- # {taskTokens.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..7e85c9536 --- /dev/null +++ b/src/components/ChatBox/TokenUtils.tsx @@ -0,0 +1,57 @@ +// ========= 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" + */ +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; + 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..9d8e8873b 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -12,9 +12,12 @@ // 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 { useAuthStore } from '@/store/authStore'; 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, @@ -22,6 +25,7 @@ import React, { useState, useSyncExternalStore, } from 'react'; +import { useTranslation } from 'react-i18next'; import { AgentMessageCard } from './MessageItem/AgentMessageCard'; import { NoticeCard } from './MessageItem/NoticeCard'; import { TaskCompletionCard } from './MessageItem/TaskCompletionCard'; @@ -29,6 +33,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,6 +59,9 @@ export const UserQueryGroup: React.FC = ({ onQueryActive, index, }) => { + const { t } = useTranslation(); + const { appearance } = useAuthStore(); + const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; const groupRef = useRef(null); const taskBoxRef = useRef(null); const [_isTaskBoxSticky, setIsTaskBoxSticky] = useState(false); @@ -72,6 +80,28 @@ export const UserQueryGroup: React.FC = ({ } ); + // Subscribe to live token count for the current task + const liveTokens = useSyncExternalStore( + (callback) => chatStore.subscribe(callback), + () => { + const state = chatStore.getState(); + const taskId = state.activeTaskId; + if (!taskId || !state.tasks[taskId]) return 0; + return state.tasks[taskId].tokens || 0; + } + ); + + // Subscribe to task status for visibility control + const taskStatus = useSyncExternalStore( + (callback) => chatStore.subscribe(callback), + () => { + const state = chatStore.getState(); + const taskId = state.activeTaskId; + if (!taskId || !state.tasks[taskId]) return ''; + return state.tasks[taskId].status || ''; + } + ); + // 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) @@ -292,6 +322,25 @@ export const UserQueryGroup: React.FC = ({ )} + {/* Live token count – visible only while the task is running */} + + {task && taskStatus === 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 7ba8a1be3..0d05a81a9 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -984,13 +984,12 @@ export default function ChatBox(): JSX.Element { {/* Header Box - Always visible */} {chatStore.activeTaskId && ( Date: Fri, 20 Mar 2026 16:39:12 +0800 Subject: [PATCH 3/3] update --- src/components/ChatBox/TokenUtils.tsx | 28 ++++++++- src/components/ChatBox/UserQueryGroup.tsx | 75 +++++------------------ src/components/ChatBox/index.tsx | 62 ++++++++++++++++--- 3 files changed, 96 insertions(+), 69 deletions(-) diff --git a/src/components/ChatBox/TokenUtils.tsx b/src/components/ChatBox/TokenUtils.tsx index 7e85c9536..3464e2998 100644 --- a/src/components/ChatBox/TokenUtils.tsx +++ b/src/components/ChatBox/TokenUtils.tsx @@ -21,9 +21,33 @@ import React, { useEffect, useState } from 'react'; * 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 (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; - if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; + 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)); } diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 9d8e8873b..08c106b8d 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -12,19 +12,11 @@ // 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 { useAuthStore } from '@/store/authStore'; import { VanillaChatStore } from '@/store/chatStore'; import { AgentStep, ChatTaskStatus } from '@/types/constants'; 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'; @@ -60,59 +52,24 @@ export const UserQueryGroup: React.FC = ({ index, }) => { const { t } = useTranslation(); - const { appearance } = useAuthStore(); - const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; 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 || ''; - } - ); - - // Subscribe to live token count for the current task - const liveTokens = useSyncExternalStore( - (callback) => chatStore.subscribe(callback), - () => { - const state = chatStore.getState(); - const taskId = state.activeTaskId; - if (!taskId || !state.tasks[taskId]) return 0; - return state.tasks[taskId].tokens || 0; - } - ); - - // Subscribe to task status for visibility control - const taskStatus = useSyncExternalStore( - (callback) => chatStore.subscribe(callback), - () => { - const state = chatStore.getState(); - const taskId = state.activeTaskId; - if (!taskId || !state.tasks[taskId]) return ''; - return state.tasks[taskId].status || ''; - } - ); + 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 ); @@ -129,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 @@ -324,7 +279,7 @@ export const UserQueryGroup: React.FC = ({ {/* Live token count – visible only while the task is running */} - {task && taskStatus === ChatTaskStatus.RUNNING && ( + {task && task.status === ChatTaskStatus.RUNNING && ( = ({ > {t('chat.current-task')} · - + {t('chat.tokens')} )} diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 0d05a81a9..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,13 +1038,7 @@ export default function ChatBox(): JSX.Element { {/* Header Box - Always visible */} {chatStore.activeTaskId && (