From 68e4962c2e5f45125a87a03d5ae6b703e763f0fd Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 21 May 2026 20:27:06 +0800 Subject: [PATCH 1/2] fix: prevent select dropdown clipping --- .../components/Select/Select.test.tsx | 169 ++++++++++++++++++ .../components/Select/Select.tsx | 54 +++++- 2 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 src/web-ui/src/component-library/components/Select/Select.test.tsx diff --git a/src/web-ui/src/component-library/components/Select/Select.test.tsx b/src/web-ui/src/component-library/components/Select/Select.test.tsx new file mode 100644 index 000000000..efc0566df --- /dev/null +++ b/src/web-ui/src/component-library/components/Select/Select.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { Select } from './Select'; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string, options?: Record & { defaultValue?: string }) => ( + options?.defaultValue ?? key + ), + }), +})); + +describe('Select', () => { + let container: HTMLDivElement; + let root: Root; + let getBoundingClientRectSpy: ReturnType; + let offsetHeightSpy: ReturnType; + let innerHeight = 800; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: innerHeight, + }); + + getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this instanceof HTMLElement && this.classList.contains('select')) { + return { + top: 700, + bottom: 740, + left: 0, + right: 240, + width: 240, + height: 40, + x: 0, + y: 700, + toJSON() { + return this; + }, + } as DOMRect; + } + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON() { + return this; + }, + } as DOMRect; + }); + + offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + if (this instanceof HTMLElement && this.classList.contains('select__dropdown')) { + return 220; + } + return 0; + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + getBoundingClientRectSpy.mockRestore(); + offsetHeightSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it('flips the dropdown upward when there is not enough room below', async () => { + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector('.select__trigger') as HTMLElement; + + await act(async () => { + trigger.click(); + await Promise.resolve(); + }); + + const selectRoot = container.querySelector('.select'); + const dropdown = container.querySelector('.select__dropdown'); + + expect(selectRoot?.className).toContain('select--placement-bottom'); + expect(dropdown?.className).toContain('select__dropdown--bottom'); + }); +}); diff --git a/src/web-ui/src/component-library/components/Select/Select.tsx b/src/web-ui/src/component-library/components/Select/Select.tsx index 39c4af2d1..03a77cc0d 100644 --- a/src/web-ui/src/component-library/components/Select/Select.tsx +++ b/src/web-ui/src/component-library/components/Select/Select.tsx @@ -2,7 +2,14 @@ * Select dropdown component */ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import React, { + useState, + useRef, + useEffect, + useLayoutEffect, + useMemo, + useCallback, +} from 'react'; import { useI18n } from '@/infrastructure/i18n'; import './Select.scss'; @@ -80,6 +87,7 @@ export const Select: React.FC = ({ const resolvedEmptyText = emptyText ?? t('select.emptyText'); const resolvedCustomValueHint = customValueHint ?? t('select.customValueHint'); const [isOpen, setIsOpen] = useState(false); + const [resolvedPlacement, setResolvedPlacement] = useState<'bottom' | 'top'>(placement); const [selectedValue, setSelectedValue] = useState( value !== undefined ? value : defaultValue !== undefined ? defaultValue : multiple ? [] : '' ); @@ -107,6 +115,44 @@ export const Select: React.FC = ({ ); }, [options, searchQuery, searchable]); + useLayoutEffect(() => { + if (!isOpen) { + setResolvedPlacement(placement); + return; + } + + const selectElement = selectRef.current; + const dropdownElement = dropdownRef.current; + if (!selectElement || !dropdownElement || typeof window === 'undefined') { + setResolvedPlacement(placement); + return; + } + + const triggerRect = selectElement.getBoundingClientRect(); + const dropdownHeight = dropdownElement.offsetHeight || dropdownElement.scrollHeight || 240; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const spaceAbove = triggerRect.top; + + let nextPlacement: 'bottom' | 'top' = placement; + if (placement === 'bottom' && spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + nextPlacement = 'top'; + } else if (placement === 'top' && spaceAbove < dropdownHeight && spaceBelow > spaceAbove) { + nextPlacement = 'bottom'; + } + + setResolvedPlacement(nextPlacement); + }, [ + isOpen, + placement, + options.length, + searchable, + multiple, + showSelectAll, + allowCustomValue, + searchQuery, + filteredOptions.length, + ]); + const groupedOptions = useMemo(() => { const groups: { [key: string]: SelectOption[] } = {}; const ungrouped: SelectOption[] = []; @@ -322,7 +368,7 @@ export const Select: React.FC = ({ const classNames = [ 'select', `select--${size}`, - `select--placement-${placement}`, + `select--placement-${resolvedPlacement}`, isOpen && 'select--open', disabled && 'select--disabled', error && 'select--error', @@ -463,7 +509,7 @@ export const Select: React.FC = ({ {isOpen && ( -
+
{searchable && (
= ({ )}
); -}; \ No newline at end of file +}; From 863393d2b7e3b65a649c2966bfd6b214ba647111 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 21 May 2026 21:31:23 +0800 Subject: [PATCH 2/2] feat(acp): display session context usage --- src/apps/desktop/src/api/acp_client_api.rs | 18 +++++ src/crates/acp/src/client/manager.rs | 74 ++++++++++++++---- src/crates/acp/src/client/mod.rs | 2 +- src/crates/acp/src/client/session_options.rs | 77 +++++++++++++++---- src/crates/acp/src/client/stream.rs | 38 +++++++++ .../src/flow_chat/components/ChatInput.tsx | 30 +++++--- .../flow_chat/components/ModelSelector.tsx | 25 +++++- .../modern/UserMessageItem.test.tsx | 13 +++- .../services/AgenticEventListener.ts | 10 +++ .../flow-chat-manager/EventHandlerModule.ts | 26 +++++++ .../src/flow_chat/store/FlowChatStore.test.ts | 30 ++++++++ .../src/flow_chat/store/FlowChatStore.ts | 44 +++++++++++ src/web-ui/src/flow_chat/types/flow-chat.ts | 11 +++ .../api/service-api/ACPClientAPI.ts | 10 +++ .../api/service-api/AgentAPI.ts | 19 +++++ 15 files changed, 386 insertions(+), 41 deletions(-) diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index d16b2cf2b..4681848bd 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -359,6 +359,24 @@ pub async fn start_acp_dialog_turn( bitfun_core::util::errors::BitFunError::service(e.to_string()) })?; } + AcpClientStreamEvent::ContextUsageUpdated(usage) => { + app_handle + .emit( + "agentic://acp-context-usage-updated", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "clientId": request.client_id, + "used": usage.used, + "size": usage.size, + "cost": usage.cost, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } AcpClientStreamEvent::Completed => { app_handle .emit( diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index f93d74e7e..d22b5d154 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -46,7 +46,9 @@ use super::requirements::{ install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; -use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::session_options::{ + model_config_id, session_options_from_state, AcpSessionContextUsage, AcpSessionOptions, +}; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; use super::stream::{ @@ -127,6 +129,7 @@ struct AcpRemoteSession { active: Option>, models: Option, config_options: Vec, + context_usage: Option, discard_pending_updates_before_next_prompt: bool, } @@ -154,6 +157,7 @@ impl AcpRemoteSession { active: None, models: None, config_options: Vec::new(), + context_usage: None, discard_pending_updates_before_next_prompt: false, } } @@ -859,6 +863,7 @@ impl AcpClientService { Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )) } @@ -920,6 +925,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } Err(error) => { @@ -947,6 +953,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } @@ -1045,23 +1052,34 @@ impl AcpClientService { .await?; discard_pending_session_updates_if_needed(&mut session).await; - let active = session - .active - .as_mut() - .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; - active.send_prompt(prompt).map_err(protocol_error)?; + { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + } let mut round_tracker = AcpStreamRoundTracker::new(); let mut tool_call_tracker = AcpToolCallTracker::new(); loop { - match active.read_update().await.map_err(protocol_error)? { + let message = { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.read_update().await.map_err(protocol_error)? + }; + + match message { SessionMessage::SessionMessage(dispatch) => { - for event in acp_dispatch_to_stream_events_with_tracker( + let events = acp_dispatch_to_stream_events_with_tracker( dispatch, &mut tool_call_tracker, ) - .await? - { + .await?; + update_session_context_usage(&mut session, &events); + for event in events { for event in round_tracker.apply(event) { on_event(event)?; } @@ -1991,14 +2009,29 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } session.discard_pending_updates_before_next_prompt = false; - let Some(active) = session.active.as_mut() else { - return; - }; - let started_at = Instant::now(); let mut discarded_count = 0usize; while started_at.elapsed() < LOAD_REPLAY_DRAIN_MAX_DURATION { - match tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await { + let update = { + let Some(active) = session.active.as_mut() else { + return; + }; + tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await + }; + + match update { + Ok(Ok(SessionMessage::SessionMessage(dispatch))) => { + let mut tracker = AcpToolCallTracker::new(); + if let Ok(events) = + acp_dispatch_to_stream_events_with_tracker(dispatch, &mut tracker).await + { + update_session_context_usage(session, &events); + } + discarded_count += 1; + } + Ok(Ok(SessionMessage::StopReason(_))) => { + discarded_count += 1; + } Ok(Ok(_)) => { discarded_count += 1; } @@ -2021,6 +2054,17 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } } +fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { + let Some(usage) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::ContextUsageUpdated(usage) => Some(usage.clone()), + _ => None, + }) else { + return; + }; + + session.context_usage = Some(usage); +} + fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 58959c3e9..3b26d9a12 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -20,5 +20,5 @@ pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; -pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use session_options::{AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions}; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs index 3f6ea88b5..c6fc11638 100644 --- a/src/crates/acp/src/client/session_options.rs +++ b/src/crates/acp/src/client/session_options.rs @@ -1,9 +1,28 @@ use agent_client_protocol::schema::{ - ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + Cost, ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionModelState, }; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionContextUsage { + pub used: u64, + pub size: u64, + #[serde(default)] + pub cost: Option, +} + +impl From for AcpSessionContextUsage { + fn from(update: agent_client_protocol::schema::UsageUpdate) -> Self { + Self { + used: update.used, + size: update.size, + cost: update.cost, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AcpSessionOptions { @@ -13,6 +32,8 @@ pub struct AcpSessionOptions { pub available_models: Vec, #[serde(default)] pub model_config_id: Option, + #[serde(default)] + pub context_usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,7 +48,9 @@ pub struct AcpSessionModelOption { pub(super) fn session_options_from_state( models: Option<&SessionModelState>, config_options: &[SessionConfigOption], + context_usage: Option<&AcpSessionContextUsage>, ) -> AcpSessionOptions { + let context_usage = context_usage.cloned(); if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { return AcpSessionOptions { current_model_id: Some(models.current_model_id.to_string()), @@ -37,19 +60,24 @@ pub(super) fn session_options_from_state( .map(model_option_from_model_info) .collect(), model_config_id: None, + context_usage, }; } - model_config_option(config_options) - .map(|option| { - let (current_model_id, available_models) = select_model_values(option); - AcpSessionOptions { - current_model_id, - available_models, - model_config_id: Some(option.id.to_string()), - } - }) - .unwrap_or_default() + if let Some(option) = model_config_option(config_options) { + let (current_model_id, available_models) = select_model_values(option); + return AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + context_usage, + }; + } + + AcpSessionOptions { + context_usage, + ..Default::default() + } } pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { @@ -119,7 +147,7 @@ mod tests { fn converts_native_model_state() { let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); - let options = session_options_from_state(Some(&state), &[]); + let options = session_options_from_state(Some(&state), &[], None); assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); assert_eq!(options.available_models.len(), 1); @@ -140,11 +168,34 @@ mod tests { ) .category(SessionConfigOptionCategory::Model); - let options = session_options_from_state(None, &[config]); + let options = session_options_from_state(None, &[config], None); assert_eq!(options.current_model_id.as_deref(), Some("fast")); assert_eq!(options.model_config_id.as_deref(), Some("model")); assert_eq!(options.available_models.len(), 2); assert_eq!(options.available_models[1].id, "smart"); } + + #[test] + fn includes_context_usage() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + let usage = AcpSessionContextUsage { + used: 42_000, + size: 128_000, + cost: Some(agent_client_protocol::schema::Cost::new(0.12, "USD")), + }; + + let options = session_options_from_state(Some(&state), &[], Some(&usage)); + + let context_usage = options.context_usage.expect("context usage"); + assert_eq!(context_usage.used, 42_000); + assert_eq!(context_usage.size, 128_000); + assert_eq!( + context_usage + .cost + .as_ref() + .map(|cost| cost.currency.as_str()), + Some("USD") + ); + } } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index 024fcfb20..8a8b2c8ea 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -8,6 +8,7 @@ use agent_client_protocol::util::MatchDispatch; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use bitfun_events::ToolEventData; +use super::session_options::AcpSessionContextUsage; use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; #[derive(Debug, Clone)] @@ -20,6 +21,7 @@ pub enum AcpClientStreamEvent { AgentText(String), AgentThought(String), ToolEvent(ToolEventData), + ContextUsageUpdated(AcpSessionContextUsage), Completed, Cancelled, } @@ -77,6 +79,7 @@ impl AcpStreamRoundTracker { events } AcpClientStreamEvent::ModelRoundStarted { .. } + | AcpClientStreamEvent::ContextUsageUpdated(_) | AcpClientStreamEvent::Completed | AcpClientStreamEvent::Cancelled => vec![event], } @@ -121,6 +124,11 @@ pub(super) async fn acp_dispatch_to_stream_events_with_tracker( SessionUpdate::ToolCallUpdate(tool_call_update) => { events.extend(acp_tool_call_update_events(tool_call_update, tracker)); } + SessionUpdate::UsageUpdate(usage_update) => { + events.push(AcpClientStreamEvent::ContextUsageUpdated( + AcpSessionContextUsage::from(usage_update), + )); + } _ => {} } Ok(()) @@ -423,12 +431,42 @@ mod tests { AcpClientStreamEvent::AgentText(_) => "text", AcpClientStreamEvent::AgentThought(_) => "thought", AcpClientStreamEvent::ToolEvent(_) => "tool", + AcpClientStreamEvent::ContextUsageUpdated(_) => "usage", AcpClientStreamEvent::Completed => "completed", AcpClientStreamEvent::Cancelled => "cancelled", }) .collect() } + #[test] + fn exposes_context_usage_updates() { + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::UsageUpdate(agent_client_protocol::schema::UsageUpdate::new( + 1_000, 4_000, + )), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + assert!(matches!( + events.as_slice(), + [AcpClientStreamEvent::ContextUsageUpdated(usage)] if usage.used == 1_000 && usage.size == 4_000 + )); + } + #[test] fn starts_new_round_for_text_after_tool() { let mut tracker = AcpStreamRoundTracker::new(); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a03082bb6..e6039dfd7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -20,7 +20,7 @@ import { import { SessionExecutionEvent } from '../state-machine/types'; import { ModelSelector } from './ModelSelector'; import { FlowChatStore } from '../store/FlowChatStore'; -import type { FlowChatState } from '../types/flow-chat'; +import type { FlowChatState, Session } from '../types/flow-chat'; import type { FileContext, DirectoryContext, ImageContext } from '@/types/context.ts'; import { SmartRecommendations } from './smart-recommendations'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -200,6 +200,24 @@ function renderMcpPromptMessages(messages: MCPPromptMessage[]): string { .join('\n\n'); } +function getSessionContextUsageDisplay(session?: Session): { current: number; max: number } { + if (!session) { + return { current: 0, max: 128128 }; + } + + if (session.currentAcpContextUsage) { + return { + current: session.currentAcpContextUsage.used, + max: session.currentAcpContextUsage.size, + }; + } + + return { + current: session.currentTokenUsage?.totalTokens || 0, + max: session.maxContextTokens || 128128, + }; +} + export const ChatInput: React.FC = ({ className = '', onSendMessage @@ -593,10 +611,7 @@ export const ChatInput: React.FC = ({ if (effectiveTargetSessionId) { const session = state.sessions.get(effectiveTargetSessionId); if (session) { - setTokenUsage({ - current: session.currentTokenUsage?.totalTokens || 0, - max: session.maxContextTokens || 128128 - }); + setTokenUsage(getSessionContextUsageDisplay(session)); } } }); @@ -605,10 +620,7 @@ export const ChatInput: React.FC = ({ const state = store.getState(); const session = state.sessions.get(effectiveTargetSessionId); if (session) { - setTokenUsage({ - current: session.currentTokenUsage?.totalTokens || 0, - max: session.maxContextTokens || 128128 - }); + setTokenUsage(getSessionContextUsageDisplay(session)); } } diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index b4411f07c..238461bcf 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -117,6 +117,17 @@ const buildAutoModelInfo = ( provider: 'auto', }); +const syncAcpContextUsageToStore = ( + sessionId: string | undefined, + options: AcpSessionOptions, +): void => { + if (!sessionId || !options.contextUsage) { + return; + } + + FlowChatStore.getInstance().updateAcpContextUsage(sessionId, options.contextUsage); +}; + export const ModelSelector: React.FC = ({ currentMode, className = '', @@ -208,6 +219,7 @@ export const ModelSelector: React.FC = ({ remoteSshHost: activeSession?.remoteSshHost, }); setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); } catch (error) { log.warn('Failed to load ACP session model options', { sessionId, acpClientId, error }); setAcpOptions(null); @@ -368,6 +380,7 @@ export const ModelSelector: React.FC = ({ modelId, }); setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); FlowChatStore.getInstance().updateSessionModelName(sessionId, modelId); log.info('ACP session model updated', { sessionId, acpClientId, modelId }); setDropdownOpen(false); @@ -440,7 +453,12 @@ export const ModelSelector: React.FC = ({ } const currentAcpModelId = acpOptions?.currentModelId || acpAvailableModels[0]?.id || ''; - const acpTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + const acpBaseTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + const acpUsageTooltip = + currentTokens > 0 && maxTokens > 0 + ? `${formatTokenCount(currentTokens)}/${formatTokenCount(maxTokens)} (${tokenPercentage}%)` + : ''; + const acpTooltip = acpUsageTooltip ? `${acpBaseTooltip} · ${acpUsageTooltip}` : acpBaseTooltip; return (
= ({ {getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} + {tokenPercentage > 0 && ( + + · {tokenPercentage}% + + )} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 04ccd73e0..59aa4568e 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -34,10 +34,19 @@ vi.mock('../../store/modernFlowChatStore', () => ({ useActiveSession: () => activeSessionRef.current, })); +const flowChatStoreMock = vi.hoisted(() => ({ + getState: vi.fn(() => ({ + sessions: new Map(), + activeSessionId: null, + })), + truncateDialogTurnsFrom: vi.fn(), +})); + vi.mock('../../store/FlowChatStore', () => ({ - flowChatStore: { - truncateDialogTurnsFrom: vi.fn(), + FlowChatStore: { + getInstance: () => flowChatStoreMock, }, + flowChatStore: flowChatStoreMock, })); vi.mock('@/infrastructure/api', () => ({ diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 50f2cf0a1..531caad3e 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -19,6 +19,7 @@ import type { ModelRoundCompletedEvent, UserSteeringInjectedEvent, DeepReviewQueueStateChangedEvent, + AcpContextUsageUpdatedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; @@ -43,6 +44,7 @@ export interface AgenticEventCallbacks { onDialogTurnFailed?: (event: AgenticEvent) => void; onDialogTurnCancelled?: (event: AgenticEvent) => void; onTokenUsageUpdated?: (event: AgenticEvent) => void; + onAcpContextUsageUpdated?: (event: AcpContextUsageUpdatedEvent) => void; onContextCompressionStarted?: (event: AgenticEvent) => void; onContextCompressionCompleted?: (event: AgenticEvent) => void; onContextCompressionFailed?: (event: AgenticEvent) => void; @@ -190,6 +192,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onAcpContextUsageUpdated) { + const unlisten = agentAPI.onAcpContextUsageUpdated((event) => { + logger.debug('ACP context usage updated:', event); + callbacks.onAcpContextUsageUpdated?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onContextCompressionStarted) { const unlisten = agentAPI.onContextCompressionStarted((event) => { logger.debug('Context compression started:', event); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index f8c358cbc..23cc092a9 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -25,6 +25,7 @@ import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, ModelRoundCompletedEvent, + AcpContextUsageUpdatedEvent, SessionModelAutoMigratedEvent, SubagentSessionLinkedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; @@ -625,6 +626,9 @@ export async function initializeEventListeners( onTokenUsageUpdated: (event) => { handleTokenUsageUpdate(event); }, + onAcpContextUsageUpdated: (event) => { + handleAcpContextUsageUpdate(event); + }, onContextCompressionStarted: (event) => { handleCompressionStarted(context, event); }, @@ -1765,6 +1769,28 @@ function handleTokenUsageUpdate(event: any): void { } } +function handleAcpContextUsageUpdate(event: AcpContextUsageUpdatedEvent): void { + const { sessionId, used, size, cost } = event; + + if (!sessionId || typeof used !== 'number' || typeof size !== 'number') { + log.debug('Dropped invalid ACP context usage update', { event }); + return; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + + if (!session) { + log.debug('Session not found (ACP context usage update)', { sessionId }); + return; + } + + store.updateAcpContextUsage( + sessionId, + cost ? { used, size, cost } : { used, size }, + ); +} + /** * Handle context compression started event */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 305fcf3d8..eef31fb48 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -238,6 +238,36 @@ describe('FlowChatStore local usage reports', () => { }); }); +describe('FlowChatStore ACP context usage', () => { + afterEach(() => { + resetStore(); + }); + + it('stores ACP context usage separately from token usage reports', () => { + const session = createSession({ + config: { agentType: 'acp:codex' }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateAcpContextUsage(session.sessionId, { + used: 42_000, + size: 128_000, + cost: { amount: 0.12, currency: 'USD' }, + }); + + const stored = flowChatStore.getState().sessions.get(session.sessionId); + expect(stored?.currentAcpContextUsage).toMatchObject({ + used: 42_000, + size: 128_000, + cost: { amount: 0.12, currency: 'USD' }, + }); + expect(stored?.currentTokenUsage).toBeUndefined(); + }); +}); + describe('FlowChatStore historical session hydration state', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index cf1a3440b..c07bb43ef 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -13,6 +13,7 @@ import { FlowImageAnalysisItem, ImageAnalysisResult, AnyFlowItem, + AcpContextUsage, SessionConfig, SessionContextRestoreState, SessionHistoryState, @@ -1394,6 +1395,49 @@ export class FlowChatStore { }); } + public updateAcpContextUsage( + sessionId: string, + contextUsage: { used: number; size: number; cost?: { amount: number; currency: string } } + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session) return prev; + + const nextUsage: AcpContextUsage = { + used: contextUsage.used, + size: contextUsage.size, + timestamp: Date.now(), + }; + if (contextUsage.cost) { + nextUsage.cost = contextUsage.cost; + } + + const currentUsage = session.currentAcpContextUsage; + if ( + currentUsage && + currentUsage.used === nextUsage.used && + currentUsage.size === nextUsage.size && + currentUsage.cost?.amount === nextUsage.cost?.amount && + currentUsage.cost?.currency === nextUsage.cost?.currency + ) { + return prev; + } + + const updatedSession = { + ...session, + currentAcpContextUsage: nextUsage, + }; + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, updatedSession); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + public rollbackTokenUsage(): void { } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 0ea56a428..f680c1516 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -178,6 +178,16 @@ export interface TokenUsage { timestamp: number; } +export interface AcpContextUsage { + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; + timestamp: number; +} + // Dialog turn: user input + full AI response across model rounds. export interface DialogTurn { id: string; @@ -297,6 +307,7 @@ export interface Session { todos?: TodoItem[]; currentTokenUsage?: TokenUsage; + currentAcpContextUsage?: AcpContextUsage; maxContextTokens?: number; // Session mode is synced to the input when switching sessions. diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 98a30dbed..502aa0ab3 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -97,10 +97,20 @@ export interface AcpSessionModelOption { description?: string; } +export interface AcpContextUsage { + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; +} + export interface AcpSessionOptions { currentModelId?: string; availableModels: AcpSessionModelOption[]; modelConfigId?: string; + contextUsage?: AcpContextUsage; } export interface SubmitAcpPermissionResponseRequest { diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 2728a6f4d..a1c24cae2 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -256,6 +256,16 @@ export interface ModelRoundCompletedEvent extends AgenticEvent { tokenDetails?: unknown; } +export interface AcpContextUsageUpdatedEvent extends AgenticEvent { + clientId?: string; + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; +} + export interface CompressionEvent extends AgenticEvent { compressionId: string; @@ -617,6 +627,15 @@ export class AgentAPI { return api.listen('agentic://token-usage-updated', callback); } + onAcpContextUsageUpdated( + callback: (event: AcpContextUsageUpdatedEvent) => void + ): () => void { + return api.listen( + 'agentic://acp-context-usage-updated', + callback + ); + } + onContextCompressionStarted(callback: (event: CompressionEvent) => void): () => void { return api.listen('agentic://context-compression-started', callback);