From 09b5e926bff6302bb502c32e92c5df821effb6ac Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Fri, 12 Jun 2026 18:10:29 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(conversation):=20add=20op=20st?= =?UTF-8?q?atus=20tray=20above=20chat=20input=20(#14737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(conversation): add op status tray above chat input Show elapsed time, total tokens, and total cost while an AI-runtime operation is running in the current conversation. Lives in the floating overlay above the chat input alongside QueueTray and TodoProgress, attaches flush to the input panel below. Co-Authored-By: Claude Opus 4.7 (1M context) * 🐛 fix(conversation): read top-level message.usage in op status tray Token totals stayed at 0 during regular agent runs because the standard agent path writes usage to `message.usage` (top-level) while the heterogeneous executor writes `metadata.usage`. Read both. Also drop the fragile createdAt window — assistant messages can be created before the AI_RUNTIME op's startTime, which excluded otherwise-valid rows — and aggregate across the whole conversation instead. UI: a little more padding, a pulsing dot to mark the running state, a tokens label, and a divider between tokens and cost. Co-Authored-By: Claude Opus 4.7 (1M context) * ✨ feat(conversation): streaming phase, ping dot, and richer metrics in op status tray - Left side now shows the current streaming phase (thinking / calling tools / searching / compressing / generating) derived from the most recent running sub-operation; server runtimes surface no sub-ops on the client and fall back to 'generating'. - Pulse dot upgraded to an expanding ping ring animation. - Zero-valued metrics are hidden entirely (no more '0 tokens / $0'). - Long-running tasks additionally surface turns and tool-call counts next to tokens and total cost. Co-Authored-By: Claude Fable 5 * 💄 style(conversation): polish op status tray display * 💄 style(conversation): unify op status tray glyph to a single hue The activity glyph mixed purple and cyan accents into the primary color; all layers now derive from colorPrimary alone (opacity-only variation). Co-Authored-By: Claude Fable 5 * 💄 style(conversation): strip glyph halo fill and drop-shadow The halo's tinted fill plus the drop-shadow rendered as a muddy disc behind the glyph (worst in light theme). Reduce to a breathing core dot plus a single rotating dashed orbit, primary hue only. Co-Authored-By: Claude Fable 5 * 💄 style(conversation): drop dollar prefix and code font in op status tray The dollar icon already conveys currency, and the code font made the numbers feel out of place next to the body text. Co-Authored-By: Claude Fable 5 * ✨ feat(conversation): show per-message cost next to the token chip Renders usage.cost beside the token count in the assistant message footer; hidden in credit mode (credits already express cost) and when the value is zero/absent. Co-Authored-By: Claude Fable 5 * 💄 style(conversation): hide per-message cost below $0.20 Cheap messages don't need a cost callout — the chip only surfaces once the cost is large enough to matter. Co-Authored-By: Claude Fable 5 * 🐛 fix(conversation): anchor reconnected op timer to real run start, surface steps - Page-refresh reconnect recreated the gateway operation with startTime=Date.now(), resetting the tray timer to 00:00 mid-run. Anchor it to the assistant message's createdAt instead. - Mirror the server's authoritative stepIndex onto op.metadata.stepCount at every step_start event, so the steps metric shows for real server-side runs (and survives reconnects). - Drop the tool-call count metric from the tray. Co-Authored-By: Claude Fable 5 * ✅ test(conversation): stub updateOperationMetadata in gateway event handler mock store Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- locales/en-US/chat.json | 7 + locales/zh-CN/chat.json | 7 + locales/zh-CN/plugin.json | 2 +- .../src/codex/FileChangeInspector.tsx | 19 +- packages/locales/src/default/chat.ts | 9 + .../Conversation/ChatInput/OpStatusTray.tsx | 334 ++++++++++++++++++ src/features/Conversation/ChatInput/index.tsx | 7 + .../components/Extras/Usage/index.tsx | 37 +- .../__tests__/gatewayEventHandler.test.ts | 1 + .../chat/slices/aiChat/actions/gateway.ts | 12 +- .../aiChat/actions/gatewayEventHandler.ts | 7 + 11 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 src/features/Conversation/ChatInput/OpStatusTray.tsx diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index e32d1a3477..1132778756 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -370,6 +370,13 @@ "noMatchingAgents": "No matching members found", "noMembersYet": "This group doesn't have any members yet. Click the + button to invite agents.", "noSelectedAgents": "No members selected yet", + "opStatusTray.status.compressing": "Compressing context", + "opStatusTray.status.generating": "Generating", + "opStatusTray.status.reasoning": "Thinking", + "opStatusTray.status.searching": "Searching", + "opStatusTray.status.toolCalling": "Calling tools", + "opStatusTray.steps": "steps", + "opStatusTray.tokens": "tokens", "openInNewWindow": "Open in New Window", "operation.contextCompression": "Context too long, compressing history...", "operation.execAgentRuntime": "Preparing response", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 16d9921c8c..ec736999fb 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -370,6 +370,13 @@ "noMatchingAgents": "未找到匹配的成员", "noMembersYet": "这个群组还没有成员。点击「+」邀请助理加入", "noSelectedAgents": "还未选择成员", + "opStatusTray.status.compressing": "压缩上下文中", + "opStatusTray.status.generating": "生成中", + "opStatusTray.status.reasoning": "思考中", + "opStatusTray.status.searching": "检索中", + "opStatusTray.status.toolCalling": "调用工具中", + "opStatusTray.steps": "步", + "opStatusTray.tokens": "tokens", "openInNewWindow": "在新窗口打开", "operation.contextCompression": "上下文过长,正在压缩历史记录……", "operation.execAgentRuntime": "准备响应中", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 626fdb7a9d..02d56831e7 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -21,7 +21,7 @@ "builtins.codex.commandExecution.readFile": "读取文件", "builtins.codex.fileChange.editedFiles_one": "已编辑 {{count}} 个文件", "builtins.codex.fileChange.editedFiles_other": "已编辑 {{count}} 个文件", - "builtins.codex.fileChange.editing": "正在编辑文件", + "builtins.codex.fileChange.editing": "编辑文件中", "builtins.codex.fileChange.noChanges": "没有文件改动", "builtins.codex.fileChange.unknownFile": "未知文件", "builtins.codex.mcpTool.error": "错误", diff --git a/packages/builtin-tools/src/codex/FileChangeInspector.tsx b/packages/builtin-tools/src/codex/FileChangeInspector.tsx index cb757597da..b610d0d024 100644 --- a/packages/builtin-tools/src/codex/FileChangeInspector.tsx +++ b/packages/builtin-tools/src/codex/FileChangeInspector.tsx @@ -35,29 +35,24 @@ const FileChangeInspector = memo 0 || stats.linesDeleted > 0; - const summary = - stats.total > 0 + const isEditing = isArgumentsStreaming || isLoading; + const summary = isEditing + ? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' }) + : stats.total > 0 ? t('builtins.codex.fileChange.editedFiles', { count: stats.total, defaultValue: stats.total === 1 ? 'Edited {{count}} file' : 'Edited {{count}} files', }) - : isArgumentsStreaming || isLoading - ? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' }) - : t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' }); + : t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' }); - if (isArgumentsStreaming && !stats.firstPath) { + if (isEditing && !stats.firstPath) { return (
{summary}
); } return ( -
+
{stats.firstPath ? ( <> {summary}: diff --git a/packages/locales/src/default/chat.ts b/packages/locales/src/default/chat.ts index c8933c6deb..452ed492b8 100644 --- a/packages/locales/src/default/chat.ts +++ b/packages/locales/src/default/chat.ts @@ -386,6 +386,15 @@ export default { 'newPlatformAgent': 'Connect Agent', 'newGroupChat': 'Create Group', + // Op status tray (floating panel above the chat input during a run) + 'opStatusTray.status.compressing': 'Compressing context', + 'opStatusTray.status.generating': 'Generating', + 'opStatusTray.status.reasoning': 'Thinking', + 'opStatusTray.status.searching': 'Searching', + 'opStatusTray.status.toolCalling': 'Calling tools', + 'opStatusTray.steps': 'steps', + 'opStatusTray.tokens': 'tokens', + // Connect agent: per-agent descriptions shown in step 0 of the connect modal 'platformAgent.create.desc.openclaw': 'Connect to OpenClaw running on one of your devices', 'platformAgent.create.desc.hermes': 'Connect to Hermes running on one of your devices', diff --git a/src/features/Conversation/ChatInput/OpStatusTray.tsx b/src/features/Conversation/ChatInput/OpStatusTray.tsx new file mode 100644 index 0000000000..6966f42f86 --- /dev/null +++ b/src/features/Conversation/ChatInput/OpStatusTray.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { Flexbox, Icon, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { CircleDollarSignIcon, CoinsIcon, FootprintsIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Fragment, memo, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useChatStore } from '@/store/chat'; +import { operationSelectors } from '@/store/chat/selectors'; +import { + AI_RUNTIME_OPERATION_TYPES, + type OperationType, +} from '@/store/chat/slices/operation/types'; +import { shinyTextStyles } from '@/styles'; + +import { contextSelectors, dataSelectors, useConversationStore } from '../store'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding-block: 8px; + padding-inline: 14px; + border: 1px solid ${cssVar.colorFillSecondary}; + border-block-end: none; + border-start-start-radius: 12px; + border-start-end-radius: 12px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + background: ${cssVar.colorBgElevated}; + `, + containerTopAttached: css` + border-start-start-radius: 0; + border-start-end-radius: 0; + `, + divider: css` + width: 1px; + height: 12px; + background: ${cssVar.colorBorderSecondary}; + `, + metric: css` + display: inline-flex; + gap: 4px; + align-items: center; + font-variant-numeric: tabular-nums; + `, + metricGroup: css` + display: inline-flex; + gap: 10px; + align-items: center; + `, + metricIcon: css` + color: ${cssVar.colorTextTertiary}; + `, + statusText: css` + font-weight: 500; + white-space: nowrap; + `, + timerValue: css` + color: ${cssVar.colorTextTertiary}; + `, + activityGlyph: css` + overflow: visible; + flex: none; + + width: 16px; + height: 16px; + + color: ${cssVar.colorPrimary}; + + @keyframes op-status-tray-glyph-spin { + to { + transform: rotate(360deg); + } + } + + @keyframes op-status-tray-glyph-core { + 0%, + 100% { + transform: scale(0.86); + opacity: 0.9; + } + + 50% { + transform: scale(1); + opacity: 1; + } + } + `, + glyphCore: css` + transform-origin: center; + transform-box: fill-box; + fill: ${cssVar.colorPrimary}; + animation: op-status-tray-glyph-core 1.5s ease-in-out infinite; + `, + glyphOrbit: css` + transform-origin: center; + transform-box: fill-box; + + fill: none; + stroke: color-mix(in srgb, ${cssVar.colorPrimary} 76%, transparent); + stroke-dasharray: 9 18; + stroke-linecap: round; + stroke-width: 1.5; + + animation: op-status-tray-glyph-spin 2s linear infinite; + `, +})); + +const ActivityGlyph = memo(() => ( + + + + +)); + +ActivityGlyph.displayName = 'ActivityGlyph'; + +const formatDuration = (ms: number) => { + if (ms < 0) ms = 0; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +}; + +const formatTokens = (n: number) => { + if (n < 1000) return String(n); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +}; + +const formatCost = (cost: number) => { + if (cost < 0.01) return cost.toFixed(4); + return cost.toFixed(2); +}; + +const normalizeStepCount = (stepCount: unknown) => { + if (typeof stepCount !== 'number' || !Number.isFinite(stepCount)) return 0; + return Math.max(0, Math.floor(stepCount)); +}; + +type ActivityKey = 'compressing' | 'generating' | 'reasoning' | 'searching' | 'toolCalling'; + +/** + * Map a running sub-operation type to the streaming phase shown on the left. + * Container ops (AI_RUNTIME) and bookkeeping ops return undefined. + */ +const resolveActivity = (type: OperationType): ActivityKey | undefined => { + if (type === 'reasoning') return 'reasoning'; + if ( + type === 'toolCalling' || + type === 'executeToolCall' || + type === 'createToolMessage' || + type === 'pluginApi' || + type.startsWith('builtinTool') + ) + return 'toolCalling'; + if (type === 'rag' || type === 'searchWorkflow') return 'searching'; + if (type === 'contextCompression' || type === 'generateSummary') return 'compressing'; + if ( + type === 'callLLM' || + type === 'groupAgentStream' || + type === 'createAssistantMessage' || + type === 'supervisorDecision' + ) + return 'generating'; + return undefined; +}; + +interface OpStatusTrayProps { + /** + * Square the top corners when another panel sits flush above this one. + */ + topAttached?: boolean; +} + +const OpStatusTray = memo(({ topAttached }) => { + const { t } = useTranslation('chat'); + const context = useConversationStore(contextSelectors.context); + const dbMessages = useConversationStore(dataSelectors.dbMessages); + + // Detect any running AI-runtime op (excludes sub-ops like callLLM/toolCalling) + // and capture the earliest start time as the op's anchor. + const startTime = useChatStore((s) => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let earliest: number | undefined; + for (const op of ops) { + if ( + op.status !== 'running' || + op.metadata.isAborting || + !AI_RUNTIME_OPERATION_TYPES.includes(op.type) + ) { + continue; + } + if (earliest === undefined || op.metadata.startTime < earliest) { + earliest = op.metadata.startTime; + } + } + return earliest; + }); + + // The most recently started running sub-op decides the streaming phase. + // Server-side runtimes surface no sub-ops on the client, so fall back to + // 'generating' — the dominant phase for plain server-streamed chat. + const activity = useChatStore((s): ActivityKey => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let current: ActivityKey | undefined; + let latest = -1; + for (const op of ops) { + if (op.status !== 'running' || op.metadata.isAborting) continue; + const mapped = resolveActivity(op.type); + if (!mapped) continue; + if (op.metadata.startTime > latest) { + latest = op.metadata.startTime; + current = mapped; + } + } + return current ?? 'generating'; + }); + + const steps = useChatStore((s) => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let stepCount = 0; + + for (const op of ops) { + if ( + op.status !== 'running' || + op.metadata.isAborting || + !AI_RUNTIME_OPERATION_TYPES.includes(op.type) + ) { + continue; + } + + stepCount = Math.max(stepCount, normalizeStepCount(op.metadata.stepCount)); + } + + return stepCount; + }); + + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!startTime) return; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [startTime]); + + // Aggregate tokens / cost across the current conversation. + // New code reads usage only from the top-level message field. + const { totalCost, totalTokens } = useMemo(() => { + let tokens = 0; + let cost = 0; + for (const m of dbMessages) { + if (m.role !== 'assistant') continue; + const usage = m.usage; + if (!usage) continue; + tokens += usage.totalTokens ?? 0; + cost += usage.cost ?? 0; + } + return { totalCost: cost, totalTokens: tokens }; + }, [dbMessages]); + + if (!startTime) return null; + + const elapsed = now - startTime; + const tokenLabel = t('opStatusTray.tokens', { defaultValue: 'tokens' }); + + // Zero-valued metrics render nothing; steps only matter for long-running + // multi-step tasks, so a single step stays hidden too. + const metrics: ReactNode[] = []; + if (steps > 1) + metrics.push( + + + + {steps} + + , + ); + if (totalTokens > 0) + metrics.push( + + + + {formatTokens(totalTokens)} + + , + ); + if (totalCost > 0) + metrics.push( + + + {formatCost(totalCost)} + , + ); + + return ( + + + + + {t(`opStatusTray.status.${activity}`)}... + + {formatDuration(elapsed)} + + + {metrics.length > 0 && ( + + {metrics.map((node, i) => ( + + {i > 0 && } + {node} + + ))} + + )} + + ); +}); + +OpStatusTray.displayName = 'OpStatusTray'; + +export default OpStatusTray; diff --git a/src/features/Conversation/ChatInput/index.tsx b/src/features/Conversation/ChatInput/index.tsx index 86cf0c76da..2a4b19a638 100644 --- a/src/features/Conversation/ChatInput/index.tsx +++ b/src/features/Conversation/ChatInput/index.tsx @@ -23,6 +23,7 @@ import { } from '@/features/ChatInput/store/initialState'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; +import { selectCurrentTurnTodosFromMessages } from '@/store/chat/slices/message/selectors/dbMessage'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { fileChatSelectors, useFileStore } from '@/store/file'; @@ -30,6 +31,7 @@ import WideScreenContainer from '../../WideScreenContainer'; import InterventionBar from '../InterventionBar'; import { dataSelectors, messageStateSelectors, useConversationStore } from '../store'; import TodoProgress from '../TodoProgress'; +import OpStatusTray from './OpStatusTray'; import QueueTray from './QueueTray'; import { getConversationChatInputUiState } from './utils'; @@ -276,6 +278,10 @@ const ChatInput = memo( (s) => operationSelectors.queuedMessageCount(context)(s) > 0, ); + // Detect whether TodoProgress will render (mirrors its own gating) so we + // can square the top corners of OpStatusTray when it sits flush below. + const hasTodos = (selectCurrentTurnTodosFromMessages(dbMessages)?.items.length ?? 0) > 0; + // Computed state const isInputEmpty = !inputMessage.trim() && fileList.length === 0 && contextList.length === 0; const { placeholderVariant, showSendMenu, showStopButton } = getConversationChatInputUiState({ @@ -375,6 +381,7 @@ const ChatInput = memo( > {!disableQueue && hasQueuedMessages && } + ({ `, })); +// Cheap messages don't need a cost callout — only surface it once it's +// expensive enough to matter. +const MIN_DISPLAY_COST = 0.2; + +const formatCost = (cost: number) => cost.toFixed(2); + interface UsageProps { model: string; performance?: ModelPerformance; @@ -33,6 +42,8 @@ interface UsageProps { const Usage = memo(({ model, usage, performance, provider }) => { const onboardingAgentId = useAgentStore(builtinAgentSelectors.webOnboardingAgentId); const conversationAgentId = useConversationStore(contextSelectors.agentId); + // Credit mode already expresses cost in credits — showing USD alongside would conflict. + const isShowCredit = useGlobalStore(systemStatusSelectors.isShowCredit); if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null; @@ -64,14 +75,22 @@ const Usage = memo(({ model, usage, performance, provider }) => { )} - {!!usage?.totalTokens && ( - - )} +
+ {!!usage?.totalTokens && ( + + )} + {!isShowCredit && !!usage?.cost && usage.cost >= MIN_DISPLAY_COST && ( +
+ + {formatCost(usage.cost)} +
+ )} +
); }, isEqual); diff --git a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts index 7be6a61607..8e030b1ddf 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts @@ -47,6 +47,7 @@ function createMockStore() { operationId: `op-reasoning-${reasoningCounter}`, }; }), + updateOperationMetadata: vi.fn(), }; } diff --git a/src/store/chat/slices/aiChat/actions/gateway.ts b/src/store/chat/slices/aiChat/actions/gateway.ts index 55dedaee1f..e6ecc40e4d 100644 --- a/src/store/chat/slices/aiChat/actions/gateway.ts +++ b/src/store/chat/slices/aiChat/actions/gateway.ts @@ -597,11 +597,21 @@ export class GatewayActionImpl { topicId, }; + // Anchor the operation to the run's real start: the assistant message was + // created when the run began. Defaulting to Date.now() here would reset + // elapsed-time displays (OpStatusTray) to zero on every page refresh. + const assistantMessage = Object.values(this.#get().messagesMap) + .flat() + .find((m) => m.id === assistantMessageId); + // Create a local operation for UI loading state, stashing the server op id // so intervention flows can find it after reconnect as well. const { operationId: gatewayOpId } = this.#get().startOperation({ context, - metadata: { serverOperationId: operationId }, + metadata: { + serverOperationId: operationId, + ...(assistantMessage?.createdAt ? { startTime: assistantMessage.createdAt } : {}), + }, type: 'execServerAgentRuntime', }); diff --git a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts index ea7de067e2..ef49cadb0d 100644 --- a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts +++ b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts @@ -440,6 +440,13 @@ export const createGatewayEventHandler = ( uiMessages?: UIChatMessage[]; }; + // The server's stepIndex is the authoritative step counter — mirror it + // onto the operation so step-based UI (OpStatusTray) stays correct + // even across page-refresh reconnects. + if (typeof event.stepIndex === 'number') { + get().updateOperationMetadata(operationId, { stepCount: event.stepIndex + 1 }); + } + // Server attaches the canonical UIChatMessage[] snapshot at every // step boundary (agent-runtime #15152). Use it as Source of Truth // instead of issuing a DB refetch — the refetch returns a stale