From 2826ae8eb5dbae804bc80cfbaf14299d56c55a6c Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 01:20:22 +0800 Subject: [PATCH 1/4] fix(web-ui): sync TodoWrite card status with DialogTurn.todos for concurrent tasks When multiple tasks execute concurrently, each TodoWrite tool call produces an independent snapshot in toolResult.result.todos. The TodoWriteDisplay component previously only read from this snapshot, so expanding an older card showed stale status while newer tasks were still running. Fix by subscribing TodoWriteDisplay to DialogTurn.todos via a new useDialogTurnTodos hook. This data source is maintained by FlowChatManager.handleTodoWriteResult and always reflects the latest merged todo state across all concurrent TodoWrite calls. Changes: - Add useDialogTurnTodos hook (shallow-diff subscription to store) - Add turnId to ToolCardProps and FlowToolCard - Update TodoWriteDisplay to prefer turnTodos over toolResult snapshot - Pass turnId through ModelRoundItem and ExploreGroupRenderer Generated with BitFun Co-Authored-By: BitFun --- .../src/flow_chat/components/FlowToolCard.tsx | 1 + .../modern/ExploreGroupRenderer.tsx | 5 +- .../components/modern/ModelRoundItem.tsx | 10 ++-- .../src/flow_chat/hooks/useDialogTurnTodos.ts | 59 +++++++++++++++++++ .../flow_chat/tool-cards/TodoWriteDisplay.tsx | 12 +++- src/web-ui/src/flow_chat/types/flow-chat.ts | 1 + 6 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/web-ui/src/flow_chat/hooks/useDialogTurnTodos.ts diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 41fe0ac63..824e84051 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -21,6 +21,7 @@ interface FlowToolCardProps { onOpenInPanel?: (panelType: string, data: any) => void; onExpand?: (toolId: string) => void; sessionId?: string; + turnId?: string; className?: string; } diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 2f5304379..87fc6c73d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -229,7 +229,7 @@ interface ExploreItemRendererProps { isLastItem?: boolean; } -const ExploreItemRenderer = React.memo(({ item, isLastItem }) => { +const ExploreItemRenderer = React.memo(({ item, turnId, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -286,9 +286,10 @@ const ExploreItemRenderer = React.memo(({ item, isLast onOpenInEditor={handleOpenInEditor} onOpenInPanel={handleOpenInPanel} sessionId={sessionId} + turnId={turnId} /> ); - + default: return null; } diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 4b835f67a..7c5080805 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -558,7 +558,7 @@ const SubagentItemsContainer = React.memo(({ /** * Subagent item renderer (used inside the container, no collapse logic). */ -const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundId: string; isLastItem?: boolean }>(({ item, isLastItem }) => { +const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundId: string; isLastItem?: boolean }>(({ item, turnId, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -614,9 +614,10 @@ const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundI onOpenInEditor={handleOpenInEditor} onOpenInPanel={handleOpenInPanel} sessionId={sessionId} + turnId={turnId} /> ); - + default: return null; } @@ -633,7 +634,7 @@ interface FlowItemRendererProps { } // Do not memoize: streaming content updates frequently. -const FlowItemRenderer: React.FC = ({ item, isLastItem }) => { +const FlowItemRenderer: React.FC = ({ item, turnId, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -720,10 +721,11 @@ const FlowItemRenderer: React.FC = ({ item, isLastItem }) } }} sessionId={sessionId} + turnId={turnId} /> ); - + default: return null; } diff --git a/src/web-ui/src/flow_chat/hooks/useDialogTurnTodos.ts b/src/web-ui/src/flow_chat/hooks/useDialogTurnTodos.ts new file mode 100644 index 000000000..8b63ee4cd --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useDialogTurnTodos.ts @@ -0,0 +1,59 @@ +/** + * Subscribe to DialogTurn.todos for a given session + turn. + * Returns the latest todos array, or empty array if unavailable. + * + * Uses FlowChatStore.subscribe() with shallow-diff to avoid + * re-renders on unrelated state changes. + */ + +import { useState, useEffect } from 'react'; +import { flowChatStore } from '../store/FlowChatStore'; +import type { TodoItem } from '../types/flow-chat'; + +function todosEqual(a: TodoItem[], b: TodoItem[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id || a[i].content !== b[i].content || a[i].status !== b[i].status) { + return false; + } + } + return true; +} + +export function useDialogTurnTodos( + sessionId: string | undefined, + turnId: string | undefined +): TodoItem[] { + const [todos, setTodos] = useState(() => { + if (!sessionId || !turnId) return []; + return flowChatStore.getDialogTurnTodos(sessionId, turnId); + }); + + useEffect(() => { + if (!sessionId || !turnId) { + setTodos([]); + return; + } + + // Initial read + const initial = flowChatStore.getDialogTurnTodos(sessionId, turnId); + setTodos(initial); + + const unsubscribe = flowChatStore.subscribe((state) => { + const session = state.sessions.get(sessionId); + if (!session) return; + + const turn = session.dialogTurns.find((t) => t.id === turnId); + const nextTodos = turn?.todos ?? []; + + setTodos((prev) => { + if (todosEqual(prev, nextTodos)) return prev; + return nextTodos; + }); + }); + + return unsubscribe; + }, [sessionId, turnId]); + + return todos; +} diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx index ffbeb3ed3..e74a96f55 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx @@ -8,15 +8,18 @@ import { TaskRunningIndicator } from '../../component-library'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { useDialogTurnTodos } from '../hooks/useDialogTurnTodos'; import './TodoWriteDisplay.scss'; export const TodoWriteDisplay: React.FC = ({ toolItem, config, + turnId, + sessionId, }) => { const { t } = useTranslation('flow-chat'); const { status, toolResult, partialParams, isParamsStreaming } = toolItem; - + const [expandedState, setExpandedState] = useState(null); const toolId = toolItem.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ @@ -24,15 +27,20 @@ export const TodoWriteDisplay: React.FC = ({ toolName: toolItem.toolName, }); + const turnTodos = useDialogTurnTodos(sessionId, turnId); + const todosToDisplay = useMemo(() => { if (isParamsStreaming && partialParams?.todos && Array.isArray(partialParams.todos)) { return partialParams.todos; } + if (turnTodos.length > 0) { + return turnTodos; + } if (toolResult?.result?.todos && Array.isArray(toolResult.result.todos)) { return toolResult.result.todos; } return []; - }, [partialParams, toolResult, isParamsStreaming]); + }, [partialParams, toolResult, isParamsStreaming, turnTodos]); const taskStats = useMemo(() => { if (todosToDisplay.length === 0) { 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 ddf491ae8..fa93d3637 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -313,6 +313,7 @@ export interface ToolCardProps { onOpenInPanel?: (panelType: string, data: any) => void; onExpand?: () => void; sessionId?: string; + turnId?: string; /** Callback for MCP App ui/message requests. Returns whether the message was handled successfully. */ onMcpAppMessage?: (params: import('@/infrastructure/api/service-api/MCPAPI').McpUiMessageParams) => Promise; } From fbd7ac35e4aada004f2124d78fa49fc147f7d4d4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 01:22:43 +0800 Subject: [PATCH 2/4] feat(flow-chat): increase expanded preview max height for file operation and terminal tool cards Split single preview max-height constants into streaming vs expanded variants: FileOperationToolCard: - FILE_OPERATION_STREAMING_MAX_HEIGHT (88px) for compact streaming preview - FILE_OPERATION_DIFF_MAX_HEIGHT (330px) for comfortable diff reading when manually expanded after completion TerminalToolCard: - TERMINAL_OUTPUT_STREAMING_MAX_HEIGHT (88px) for compact live output during execution - TERMINAL_OUTPUT_EXPANDED_MAX_HEIGHT (286px) for comfortable output reading when manually expanded after completion/cancellation Both use max-height (not fixed height) so content shorter than the limit shrinks naturally without blank space. --- DEEP_REVIEW_USAGE_GUIDE.md | 70 --- PHASE2_DYNAMIC_CONCURRENCY_PLAN.md | 519 ------------------ .../tool-cards/FileOperationToolCard.tsx | 19 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 44 +- 4 files changed, 43 insertions(+), 609 deletions(-) delete mode 100644 DEEP_REVIEW_USAGE_GUIDE.md delete mode 100644 PHASE2_DYNAMIC_CONCURRENCY_PLAN.md diff --git a/DEEP_REVIEW_USAGE_GUIDE.md b/DEEP_REVIEW_USAGE_GUIDE.md deleted file mode 100644 index 32d6908b5..000000000 --- a/DEEP_REVIEW_USAGE_GUIDE.md +++ /dev/null @@ -1,70 +0,0 @@ -# Deep Review 使用说明 - -本文档记录当前 Deep Review 与代码审核团队的用户可见行为,适用于 BitFun 桌面端和共享前端维护。 - -## 入口 - -- 在聊天输入框中使用 `/DeepReview` 可以启动深度审查。 -- 在审查按钮下拉框中可以选择普通审查或深度审查。 -- 当当前会话已经处于普通审查或深度审查中时,下拉框会进入审查中状态,`/DeepReview` 命令也会被阻止,避免重复拉起审查任务。 - -## 首次确认 - -首次启动 Deep Review 时会展示确认弹窗,说明大致 token 消耗、执行时间和可能影响。用户可以勾选“下次不再提示”,该选项在浅色和深色主题下都需要保持可读。 - -## 代码审核团队 - -Deep Review 使用内置代码审核团队执行并行审查。默认团队包含业务逻辑、性能、安全和质量把关角色,用户可在“专业智能体 > 代码审核团队”中调整审查策略、模型和可选成员。 - -审查策略分为快速、正常、深度三档: - -| 档位 | 适用场景 | 影响 | -| --- | --- | --- | -| 快速 | 小范围、低风险变更 | 更快、更省 token,但覆盖面较窄 | -| 正常 | 日常代码变更 | 默认档位,平衡耗时、token 和质量 | -| 深度 | 高风险或发布前变更 | 覆盖面更广,耗时和 token 消耗更高 | - -如果某位审查员使用显式指定模型,则策略不会覆盖该选择;如果使用 primary/fast 类配置模型且配置被移除,应回退到该审查员默认模型。 - -## 执行状态 - -- 审查进行中时,左侧会话列表显示“审查中”。 -- 审查页面应提供明确的中止入口。 -- 用户停止审查后,前端需要立即收敛对应会话的执行状态,避免聊天页继续显示深度审查中。 -- 子审查员事件必须正确关联到父审查任务;前端需要兼容后端 `subagent_parent_info` 与前端 `subagentParentInfo` 两种字段形式。 - -## 修复计划 - -Deep Review 默认先读后写。报告完成后,修复计划由用户确认: - -- 修复项支持多选。 -- 默认勾选中高优先级或建议修复的问题。 -- 未选择任何修复项时,开始修复和修复后再次审查按钮不可用。 -- 每个修复项默认折叠,可展开查看更详细描述。 -- 存档计划入口已移除,避免把用户决策分散到低频路径。 - -## 测试覆盖 - -关键行为由以下前端测试保护: - -- `src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx`:防止代码审核团队详情页布局回退为空白页。 -- `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.test.tsx`:保护代码审核团队配置页基础渲染。 -- `src/web-ui/src/flow_chat/utils/deepReviewCommandGuard.test.ts`:保护 `/DeepReview` 在审查进行中被阻止。 -- `src/web-ui/src/flow_chat/utils/reviewSessionStop.test.ts`:保护停止审查后的本地状态收敛。 -- `src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts`:保护子审查员事件能挂回父审查任务。 -- `src/web-ui/src/flow_chat/utils/sessionReviewActivity.test.ts`:保护会话审查中状态识别。 - -变更 Deep Review 前端行为时,至少运行: - -```bash -pnpm run lint:web -pnpm run type-check:web -pnpm --dir src/web-ui run test:run -node scripts/i18n-audit.mjs -``` - -如修改 Rust 策略、提示词或工具实现,还需要运行: - -```bash -cargo test -p bitfun-core deep_review -- --nocapture -``` diff --git a/PHASE2_DYNAMIC_CONCURRENCY_PLAN.md b/PHASE2_DYNAMIC_CONCURRENCY_PLAN.md deleted file mode 100644 index 8926f296f..000000000 --- a/PHASE2_DYNAMIC_CONCURRENCY_PLAN.md +++ /dev/null @@ -1,519 +0,0 @@ -# Phase 2: Dynamic Subagent Concurrency Plan - -## Overview - -This document describes the implementation plan for dynamically adjusting subagent concurrency based on local system resources and LLM API health. The design follows an **on-demand + event-driven** approach with **no background polling tasks** to avoid `sysinfo` overhead. - -## Design Principles - -1. **No background timers**: No `tokio::time::interval`, no spawned infinite loops -2. **sysinfo called only at decision points**: DeepReview startup, queue overload, failure recovery -3. **LLM API health is purely event-driven**: 429/503 responses trigger adjustments directly without sysinfo -4. **Cooldown prevents oscillation**: 30-second cooldown between adjustments -5. **Soft limits without killing tasks**: `target_concurrency` waits for natural permit release - -## Architecture - -``` -Trigger Points (on-demand calls) -================================ - -1. Before DeepReview Phase 2 launch: - -> Call sysinfo once to decide if local load allows multi-instance splitting - -2. When subagent queue depth >= 3: - -> Call sysinfo once to decide if temporary scale-down is needed - -3. After subagent timeout/cancel/failure: - -> Call sysinfo once to decide if resource exhaustion caused the failure - -4. On LLM API 429/503 response: - -> Scale down immediately, no sysinfo needed - - | - v -+-----------------------------+ -| ResourceProbe (stateless) | -| | -| - No timers, no background | -| - Single-shot sampling | -| - Cache result for 5s max | -+-----------------------------+ - | - v -+-----------------------------+ -| ConcurrencyAdjustment | -| Decision | -| | -| - One-shot decision based | -| on snapshot + history | -| - Writes to limiter | -| target_permits | -+-----------------------------+ -``` - -## Trigger Point Details - -### Trigger 1: DeepReview Startup (File Split Decision) - -**Location**: DeepReview orchestrator before Phase 2 reviewer dispatch - -**Logic**: -- Call `ResourceProbe::snapshot()` once -- Pass result to `DeepReviewExecutionPolicy::effective_instance_count()` -- This method caps `same_role_instance_count` based on current CPU/memory - -**Code** (in `deep_review_policy.rs`): - -```rust -impl DeepReviewExecutionPolicy { - /// Decide actual instance count considering local resources. - /// Called once per DeepReview turn. - pub fn effective_instance_count( - &self, - file_count: usize, - resource_snapshot: Option<&ResourceSnapshot>, - ) -> usize { - let base_count = self.same_role_instance_count(file_count); - if base_count <= 1 { - return 1; - } - - let Some(snapshot) = resource_snapshot else { - return base_count; - }; - - // CPU > 80% or memory < 1GB -> cap at 2 instances - if snapshot.cpu_utilization_percent > 80.0 - || snapshot.available_memory_mb < 1024 - { - return base_count.min(2); - } - - // CPU > 60% or memory < 2GB -> reduce by 1 - if snapshot.cpu_utilization_percent > 60.0 - || snapshot.available_memory_mb < 2048 - { - return base_count.saturating_sub(1).max(1); - } - - base_count - } -} -``` - -**Frequency**: Once per DeepReview turn (typically 1-5 times per user session) - ---- - -### Trigger 2: Subagent Queue Overload - -**Location**: `coordinator.rs` — `acquire_subagent_concurrency_permit()` - -**Logic**: -- Fast path: if semaphore has available permits, acquire directly (zero overhead) -- Slow path: if queue depth >= 3, sample resources once and possibly request scale-down - -**Code**: - -```rust -async fn acquire_subagent_concurrency_permit(...) { - let limiter = self.get_subagent_concurrency_limiter().await; - - // Fast path: permit available, no sampling - if limiter.semaphore.available_permits() > 0 { - return acquire_directly(...).await; - } - - // Slow path: need to wait - let queue_depth = limiter.waiting_count.load(Ordering::Relaxed); - if queue_depth >= 3 { - let snapshot = ResourceProbe::snapshot(); - if snapshot.cpu_utilization_percent > 75.0 { - limiter.request_scale_down(1); - } - } - - // Continue normal wait... -} -``` - -**Frequency**: Only when concurrency is saturated and queue builds up (rare in normal operation) - ---- - -### Trigger 3: Subagent Failure Recovery - -**Location**: `coordinator.rs` — after `execute_subagent()` completes - -**Logic**: -- On timeout or cancellation, sample resources to detect resource exhaustion -- Record stress events; after 3 consecutive stress events, auto scale-down - -**Code**: - -```rust -match &result { - Err(BitFunError::Timeout(_)) | Err(BitFunError::Cancelled(_)) => { - let snapshot = ResourceProbe::snapshot(); - if snapshot.cpu_utilization_percent > 70.0 - || snapshot.available_memory_mb < 512 - { - limiter.record_stress_event(); - } - } - _ => {} -} - -if limiter.stress_event_count() >= 3 { - limiter.request_scale_down(1); - limiter.clear_stress_events(); -} -``` - -**Frequency**: Only on failure (typically rare) - ---- - -### Trigger 4: LLM API Rate Limit (Event-Driven, No sysinfo) - -**Location**: HTTP response handling in AI adapter layer - -**Logic**: -- On 429 (rate limit) or 503/504 (service unavailable), immediately notify concurrency limiter -- No sysinfo call; this is purely an API-side signal - -**Code**: - -```rust -match response.status().as_u16() { - 429 => { - get_global_coordinator() - .get_subagent_concurrency_limiter() - .request_scale_down(2); - } - 503 | 504 => { - get_global_coordinator() - .get_subagent_concurrency_limiter() - .request_scale_down(1); - } - _ => {} -} -``` - -**Frequency**: Only when API actually returns error status - ---- - -## ResourceProbe Implementation - -**File**: `src/crates/core/src/agentic/coordination/resource_probe.rs` - -```rust -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Cached snapshot with TTL to avoid repeated sysinfo calls within short windows. -static LAST_SNAPSHOT: std::sync::Mutex> = - std::sync::Mutex::new(None); - -const CACHE_TTL_MS: u64 = 5000; // 5 seconds - -/// Stateless resource probe. No background tasks, no timers. -pub struct ResourceProbe; - -impl ResourceProbe { - /// Single-shot system resource snapshot. - /// Returns cached result if called again within 5 seconds. - pub fn snapshot() -> ResourceSnapshot { - let now = current_epoch_millis(); - - // Check cache first - if let Ok(guard) = LAST_SNAPSHOT.lock() { - if let Some((snapshot, cached_at)) = guard.as_ref() { - if now.saturating_sub(*cached_at) < CACHE_TTL_MS { - return snapshot.clone(); - } - } - } - - // Perform fresh sample - let snapshot = Self::sample(); - - // Update cache - if let Ok(mut guard) = LAST_SNAPSHOT.lock() { - *guard = Some((snapshot.clone(), now)); - } - - snapshot - } - - fn sample() -> ResourceSnapshot { - use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; - - let mut system = System::new_with_specifics( - RefreshKind::new() - .with_cpu(CpuRefreshKind::new().with_cpu_usage()) - .with_memory(MemoryRefreshKind::new()), - ); - system.refresh_cpu_specifics(CpuRefreshKind::new().with_cpu_usage()); - - ResourceSnapshot { - cpu_utilization_percent: system.global_cpu_usage(), - available_memory_mb: system.available_memory() / 1024 / 1024, - } - } -} - -#[derive(Debug, Clone)] -pub struct ResourceSnapshot { - pub cpu_utilization_percent: f32, - pub available_memory_mb: u64, -} - -fn current_epoch_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} -``` - -**Key constraints**: -- `System::new_with_specifics` initializes only necessary data structures -- No background refresh threads -- No process list traversal -- Single call typically < 5ms -- 5-second cache prevents burst calls from repeated sampling - ---- - -## SubagentConcurrencyLimiter Extensions - -**File**: `src/crates/core/src/agentic/coordination/coordinator.rs` - -Add to existing `SubagentConcurrencyLimiter`: - -```rust -pub struct SubagentConcurrencyLimiter { - pub semaphore: Arc, - pub max_concurrency: usize, - - // NEW: Target concurrency (may be lower than max_concurrency) - target_concurrency: AtomicUsize, - - // NEW: Stress event counter for adaptive recovery - stress_events: AtomicUsize, - - // NEW: Last adjustment timestamp (epoch millis) - last_adjustment: AtomicU64, - - // NEW: Waiting task count for queue depth detection - waiting_count: AtomicUsize, -} - -const ADJUSTMENT_COOLDOWN_MS: u64 = 30_000; // 30 seconds - -impl SubagentConcurrencyLimiter { - pub fn new(max_concurrency: usize) -> Self { - Self { - semaphore: Arc::new(Semaphore::new(max_concurrency)), - max_concurrency, - target_concurrency: AtomicUsize::new(max_concurrency), - stress_events: AtomicUsize::new(0), - last_adjustment: AtomicU64::new(0), - waiting_count: AtomicUsize::new(0), - } - } - - /// Request scale-down by `delta` permits. Respects cooldown. - pub fn request_scale_down(&self, delta: usize) { - let now = current_epoch_millis(); - let last = self.last_adjustment.load(Ordering::Relaxed); - - if now.saturating_sub(last) < ADJUSTMENT_COOLDOWN_MS { - return; - } - - let current_target = self.target_concurrency.load(Ordering::Relaxed); - let new_target = current_target.saturating_sub(delta).max(1); - - self.target_concurrency.store(new_target, Ordering::Relaxed); - self.last_adjustment.store(now, Ordering::Relaxed); - - info!( - "Subagent concurrency scaled down: target {} -> {} (max: {})", - current_target, new_target, self.max_concurrency - ); - } - - /// Request scale-up by `delta` permits, capped at max_concurrency. - pub fn request_scale_up(&self, delta: usize) { - let now = current_epoch_millis(); - let last = self.last_adjustment.load(Ordering::Relaxed); - - if now.saturating_sub(last) < ADJUSTMENT_COOLDOWN_MS { - return; - } - - let current_target = self.target_concurrency.load(Ordering::Relaxed); - let new_target = (current_target + delta).min(self.max_concurrency); - - if new_target == current_target { - return; - } - - // Add permits to semaphore for scale-up - let permits_to_add = new_target - current_target; - self.semaphore.add_permits(permits_to_add); - self.target_concurrency.store(new_target, Ordering::Relaxed); - self.last_adjustment.store(now, Ordering::Relaxed); - - info!( - "Subagent concurrency scaled up: target {} -> {} (max: {})", - current_target, new_target, self.max_concurrency - ); - } - - pub fn record_stress_event(&self) { - self.stress_events.fetch_add(1, Ordering::Relaxed); - } - - pub fn stress_event_count(&self) -> usize { - self.stress_events.load(Ordering::Relaxed) - } - - pub fn clear_stress_events(&self) { - self.stress_events.store(0, Ordering::Relaxed); - } - - pub fn current_target(&self) -> usize { - self.target_concurrency.load(Ordering::Relaxed) - } - - /// Increment waiting count before queueing - pub fn inc_waiting(&self) { - self.waiting_count.fetch_add(1, Ordering::Relaxed); - } - - /// Decrement waiting count after acquiring or cancelling - pub fn dec_waiting(&self) { - self.waiting_count.fetch_sub(1, Ordering::Relaxed); - } -} -``` - -**Soft limit mechanism**: -- Scale-down does NOT forcibly cancel running subagents -- `target_concurrency` is checked when releasing permits: - - If `available_permits + 1 > target_concurrency`, the permit is NOT returned to semaphore - - This naturally reduces active concurrency as subagents complete -- Scale-up uses `Semaphore::add_permits()` to immediately increase capacity - ---- - -## Configuration - -**File**: `src/crates/core/src/service/config/types.rs` - -```rust -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "mode", content = "value")] -pub enum SubagentConcurrencyConfig { - /// Static fixed concurrency (current behavior) - Static(usize), - /// Dynamic mode with optional min/max bounds - Auto { - #[serde(default = "default_auto_min")] - min: usize, - #[serde(default = "default_auto_max")] - max: usize, - }, -} - -fn default_auto_min() -> usize { 1 } -fn default_auto_max() -> usize { 8 } - -impl Default for SubagentConcurrencyConfig { - fn default() -> Self { - SubagentConcurrencyConfig::Static(5) - } -} -``` - -**Config example**: - -```json -{ - "ai": { - "subagent_max_concurrency": { - "mode": "auto", - "value": { - "min": 2, - "max": 10 - } - } - } -} -``` - -Or for backward compatibility: - -```json -{ - "ai": { - "subagent_max_concurrency": 5 - } -} -``` - ---- - -## File Change List - -| File | Change | -|------|--------| -| `src/crates/core/src/agentic/coordination/resource_probe.rs` | **NEW** Stateless ResourceProbe with 5s cache | -| `src/crates/core/src/agentic/coordination/dynamic_concurrency.rs` | **NEW** ConcurrencyAdjustmentDecision types | -| `src/crates/core/src/agentic/coordination/coordinator.rs` | Extend SubagentConcurrencyLimiter with target/stress fields; add trigger points in acquire/execute | -| `src/crates/core/src/agentic/coordination/mod.rs` | Register new modules | -| `src/crates/core/src/agentic/deep_review_policy.rs` | Add `effective_instance_count()` method | -| `src/crates/core/src/service/config/types.rs` | Add `SubagentConcurrencyConfig` enum | -| `src/crates/core/Cargo.toml` | Add `sysinfo` dependency (if not already present) | -| `src/crates/ai-adapters/...` | Add 429/503 event triggers (specific file TBD) | - ---- - -## Call Frequency Comparison - -| Approach | Background Tasks | sysinfo Calls/Hour (Typical) | -|----------|-----------------|------------------------------| -| **Polling (rejected)** | `tokio::time::interval` every 30s | ~120 | -| **On-demand (this plan)** | None | **0-10** | - ---- - -## Testing Checklist - -- [ ] `ResourceProbe::snapshot()` returns valid CPU/memory data -- [ ] 5-second cache prevents repeated sysinfo calls -- [ ] `effective_instance_count()` correctly caps based on resource snapshot -- [ ] `request_scale_down()` respects 30s cooldown -- [ ] Soft limit: permits not returned when above target_concurrency -- [ ] Scale-up adds permits immediately via `add_permits()` -- [ ] Stress event counter triggers auto scale-down after 3 events -- [ ] 429 response triggers immediate scale-down without sysinfo -- [ ] Static config mode remains backward compatible -- [ ] Auto config mode parses correctly from JSON - ---- - -## Risks and Mitigations - -| Risk | Mitigation | -|------|-----------| -| `sysinfo` crate adds binary size | Use `sysinfo` with minimal features (`default-features = false`, enable only `system`) | -| Single `sysinfo` call still slow on some systems | 5-second cache; call only at decision points | -| Scale-down too aggressive | 30s cooldown; minimum target of 1 | -| Scale-up never happens after scale-down | Consider periodic "probe for scale-up" only when queue is empty and no recent stress events | -| Cache stale data | 5s TTL is short enough for decision accuracy | diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 117eac3ed..a369862a6 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -48,11 +48,8 @@ import { notificationService } from '@/shared/notification-system'; import './FileOperationToolCard.scss'; const log = createLogger('FileOperationToolCard'); -const FILE_OPERATION_PREVIEW_ROWS = 4; -const FILE_OPERATION_PREVIEW_ROW_HEIGHT = 22; -// Keep streaming and completed previews at the same height to avoid layout jumps. -const FILE_OPERATION_PREVIEW_MAX_HEIGHT = - FILE_OPERATION_PREVIEW_ROWS * FILE_OPERATION_PREVIEW_ROW_HEIGHT; +const FILE_OPERATION_STREAMING_MAX_HEIGHT = 4 * 22; // 88px – compact while streaming +const FILE_OPERATION_DIFF_MAX_HEIGHT = 15 * 22; // 330px – comfortable diff reading when expanded interface FileOperationToolCardProps extends ToolCardProps { sessionId?: string; @@ -571,6 +568,10 @@ export const FileOperationToolCard: React.FC = ({ const renderExpandedContent = () => { if (isFailed) return null; + const previewMaxHeight = status === 'completed' + ? FILE_OPERATION_DIFF_MAX_HEIGHT + : FILE_OPERATION_STREAMING_MAX_HEIGHT; + if (toolItem.toolName === 'Edit') { if (status !== 'completed' && newStringContent) { return ( @@ -581,7 +582,7 @@ export const FileOperationToolCard: React.FC = ({ filePath={currentFilePath} isStreaming={isParamsStreaming} showLineNumbers={false} - maxHeight={FILE_OPERATION_PREVIEW_MAX_HEIGHT} + maxHeight={previewMaxHeight} autoScrollToBottom={isParamsStreaming} onLineClick={handleCodeLineClick} /> @@ -598,7 +599,7 @@ export const FileOperationToolCard: React.FC = ({ originalContent={oldStringContent} modifiedContent={newStringContent} filePath={currentFilePath} - maxHeight={FILE_OPERATION_PREVIEW_MAX_HEIGHT} + maxHeight={previewMaxHeight} showLineNumbers={false} lineNumberMode="dual" showPrefix={false} @@ -620,7 +621,7 @@ export const FileOperationToolCard: React.FC = ({ filePath={currentFilePath} isStreaming={isParamsStreaming} showLineNumbers={false} - maxHeight={FILE_OPERATION_PREVIEW_MAX_HEIGHT} + maxHeight={previewMaxHeight} autoScrollToBottom={isParamsStreaming} onLineClick={handleCodeLineClick} /> @@ -637,7 +638,7 @@ export const FileOperationToolCard: React.FC = ({ originalContent="" modifiedContent={contentPreview} filePath={currentFilePath} - maxHeight={FILE_OPERATION_PREVIEW_MAX_HEIGHT} + maxHeight={previewMaxHeight} showLineNumbers={false} lineNumberMode="single" showPrefix={true} diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index c74cc7f9c..2bf808e4f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -24,15 +24,13 @@ import { TerminalOutputRenderer } from '@/tools/terminal/components'; import { createLogger } from '@/shared/utils/logger'; import { useToolCardHeightContract, type ToolCardCollapseReason } from './useToolCardHeightContract'; import { getTerminalViewState, type TerminalViewState } from './terminalToolCardState'; +import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import './TerminalToolCard.scss'; const log = createLogger('TerminalToolCard'); const TERMINAL_COLLAPSED_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']); -const TERMINAL_OUTPUT_PREVIEW_ROWS = 4; -const TERMINAL_OUTPUT_ESTIMATED_LINE_HEIGHT = 18; -const TERMINAL_OUTPUT_VERTICAL_PADDING = 16; -const TERMINAL_OUTPUT_PREVIEW_MAX_HEIGHT = - TERMINAL_OUTPUT_PREVIEW_ROWS * TERMINAL_OUTPUT_ESTIMATED_LINE_HEIGHT + TERMINAL_OUTPUT_VERTICAL_PADDING; +const TERMINAL_OUTPUT_STREAMING_MAX_HEIGHT = 4 * 18 + 16; // 88px – compact while streaming/executing +const TERMINAL_OUTPUT_EXPANDED_MAX_HEIGHT = 15 * 18 + 16; // 286px – comfortable reading when manually expanded interface TerminalToolCardProps extends ToolCardProps { terminalSessionId?: string; @@ -84,6 +82,15 @@ function renderTerminalExpandedContent(params: { }): React.ReactNode { const { viewState, liveOutput, parsedResult, waitingMessage, t } = params; + const isStreamingPhase = + viewState.displayPhase === 'live_output' || + viewState.displayPhase === 'receiving_params' || + viewState.displayPhase === 'executing'; + + const maxHeight = isStreamingPhase + ? TERMINAL_OUTPUT_STREAMING_MAX_HEIGHT + : TERMINAL_OUTPUT_EXPANDED_MAX_HEIGHT; + return ( <> {viewState.displayPhase === 'live_output' && ( @@ -91,7 +98,7 @@ function renderTerminalExpandedContent(params: { )} @@ -109,7 +116,7 @@ function renderTerminalExpandedContent(params: { )} @@ -138,7 +145,7 @@ function renderTerminalExpandedContent(params: {
@@ -484,9 +491,24 @@ export const TerminalToolCard: React.FC = ({ statusIcon={} action={t('toolCards.terminal.executeCommand')} content={renderCommandContent()} - extra={viewState.hasHeaderExtra ? ( + extra={( <> - {renderStatusText()} + 0 + ? toolCall.input.timeout_ms + : undefined + } + showControls={false} + completedDurationMs={ + status === 'completed' && parsedResult?.executionTimeMs + ? parsedResult.executionTimeMs + : undefined + } + /> + {viewState.hasHeaderExtra && renderStatusText()} {showConfirmButtons && (
e.stopPropagation()}> @@ -528,7 +550,7 @@ export const TerminalToolCard: React.FC = ({ )} - ) : undefined} + )} rightIcon={renderStatusIcon()} /> ); From ddde205e25de1bab9699bcc5b044c8d7a308c3de Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 02:01:26 +0800 Subject: [PATCH 3/4] feat: subagent live elapsed time with adjustable timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useLiveElapsedTime hook for real-time elapsed/remaining tracking - Add useSubagentTimeoutControl hook for disable/restore/extend logic - Add ToolTimeoutIndicator shared component with [∞] toggle and extend popover - Integrate into TaskToolDisplay (with controls) and TerminalToolCard (display only) - Integrate into TaskDetailPanel - Add set_subagent_timeout Tauri command with dynamic deadline via watch channel - Add SubagentTimeoutHandle/SubagentTimeoutAction in coordinator for runtime timeout adjustment - Add i18n keys for timeout tooltips and restore option Generated with BitFun Co-Authored-By: BitFun --- src/apps/desktop/src/api/agentic_api.rs | 47 ++++ src/apps/desktop/src/lib.rs | 1 + .../src/agentic/coordination/coordinator.rs | 199 +++++++++++++++-- .../core/src/agentic/deep_review_policy.rs | 4 +- .../TaskDetailPanel/TaskDetailPanel.tsx | 32 +-- .../src/flow_chat/hooks/useLiveElapsedTime.ts | 80 +++++++ .../hooks/useSubagentTimeoutControl.ts | 109 ++++++++++ .../flow_chat/tool-cards/TaskToolDisplay.tsx | 30 +-- .../tool-cards/TerminalToolCard.scss | 1 + .../flow_chat/tool-cards/TerminalToolCard.tsx | 2 +- .../tool-cards/ToolTimeoutIndicator.scss | 135 ++++++++++++ .../tool-cards/ToolTimeoutIndicator.tsx | 203 ++++++++++++++++++ .../api/service-api/AgentAPI.ts | 18 ++ src/web-ui/src/locales/en-US/flow-chat.json | 11 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 11 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 6 +- 16 files changed, 837 insertions(+), 52 deletions(-) create mode 100644 src/web-ui/src/flow_chat/hooks/useLiveElapsedTime.ts create mode 100644 src/web-ui/src/flow_chat/hooks/useSubagentTimeoutControl.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss create mode 100644 src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 95fb8a482..8ab2afcbf 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -10,6 +10,7 @@ use crate::api::session_storage_path::desktop_effective_session_storage_path; use bitfun_core::agentic::coordination::{ AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, + SubagentTimeoutAction, }; use bitfun_core::agentic::core::*; use bitfun_core::agentic::image_analysis::ImageContextData; @@ -614,6 +615,52 @@ pub async fn cancel_session( Ok(()) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSubagentTimeoutRequest { + pub session_id: String, + pub action: SetSubagentTimeoutActionDTO, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", content = "payload")] +pub enum SetSubagentTimeoutActionDTO { + Disable, + Restore, + Extend { seconds: u64 }, +} + +impl From for SubagentTimeoutAction { + fn from(dto: SetSubagentTimeoutActionDTO) -> Self { + match dto { + SetSubagentTimeoutActionDTO::Disable => SubagentTimeoutAction::Disable, + SetSubagentTimeoutActionDTO::Restore => SubagentTimeoutAction::Restore, + SetSubagentTimeoutActionDTO::Extend { seconds } => { + SubagentTimeoutAction::Extend { seconds } + } + } + } +} + +#[tauri::command] +pub async fn set_subagent_timeout( + coordinator: State<'_, Arc>, + request: SetSubagentTimeoutRequest, +) -> Result<(), String> { + let action: SubagentTimeoutAction = request.action.into(); + coordinator + .set_subagent_timeout(&request.session_id, action) + .await + .map_err(|e| { + log::error!( + "Failed to set subagent timeout: session_id={}, error={}", + request.session_id, + e + ); + format!("Failed to set subagent timeout: {}", e) + }) +} + #[tauri::command] pub async fn cancel_tool( coordinator: State<'_, Arc>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0b61fa0a9..36a9a0c69 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -353,6 +353,7 @@ pub async fn run() { api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::cancel_dialog_turn, api::agentic_api::cancel_session, + api::agentic_api::set_subagent_timeout, api::agentic_api::delete_session, api::agentic_api::restore_session, webdriver_bridge_result, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 5a5b0be84..3ab841b72 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -29,7 +29,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::OnceLock; -use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, mpsc}; +use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, mpsc, watch}; use tokio::time::{Duration, Instant, sleep}; use tokio_util::sync::CancellationToken; @@ -169,6 +169,80 @@ fn normalize_subagent_max_concurrency(raw: usize) -> usize { raw.clamp(1, MAX_SUBAGENT_MAX_CONCURRENCY) } +/// Actions for dynamically adjusting a subagent's timeout. +#[derive(Debug, Clone)] +pub enum SubagentTimeoutAction { + /// Disable timeout (run without limit). + Disable, + /// Restore timeout using the remaining time captured at disable. + Restore, + /// Extend timeout by specified seconds from now. + Extend { seconds: u64 }, +} + +/// Shared handle for dynamically adjusting a subagent's timeout deadline. +pub(crate) struct SubagentTimeoutHandle { + /// watch sender: None = no timeout, Some(instant) = deadline. + deadline_tx: watch::Sender>, + /// Session ID this handle belongs to. + #[allow(dead_code)] + session_id: String, + /// Original timeout in seconds (for restore calculations). + original_timeout_seconds: Option, + /// Remaining seconds at the moment timeout was disabled. + remaining_at_pause: std::sync::Mutex>, +} + +impl SubagentTimeoutHandle { + fn disable_timeout(&self) { + let remaining = match *self.deadline_tx.borrow() { + Some(deadline) => { + let now = Instant::now(); + if deadline > now { + deadline.duration_since(now).as_secs() + } else { + 0 + } + } + None => self.original_timeout_seconds.unwrap_or(0), + }; + let _ = self.remaining_at_pause.lock().map(|mut guard| { + *guard = Some(remaining); + }); + let _ = self.deadline_tx.send(None); + } + + fn restore_timeout(&self) { + let remaining = self + .remaining_at_pause + .lock() + .ok() + .and_then(|guard| *guard) + .unwrap_or_else(|| self.original_timeout_seconds.unwrap_or(0)); + let new_deadline = Instant::now() + Duration::from_secs(remaining); + let _ = self.deadline_tx.send(Some(new_deadline)); + let _ = self.remaining_at_pause.lock().map(|mut guard| { + *guard = None; + }); + } + + fn extend_timeout(&self, seconds: u64) { + let new_deadline = Instant::now() + Duration::from_secs(seconds); + let _ = self.deadline_tx.send(Some(new_deadline)); + let _ = self.remaining_at_pause.lock().map(|mut guard| { + *guard = None; + }); + } + + fn apply_action(&self, action: SubagentTimeoutAction) { + match action { + SubagentTimeoutAction::Disable => self.disable_timeout(), + SubagentTimeoutAction::Restore => self.restore_timeout(), + SubagentTimeoutAction::Extend { seconds } => self.extend_timeout(seconds), + } + } +} + /// Conversation coordinator pub struct ConversationCoordinator { session_manager: Arc, @@ -177,6 +251,8 @@ pub struct ConversationCoordinator { event_queue: Arc, event_router: Arc, subagent_concurrency_limiter: Arc>>, + /// Registry for dynamically adjusting subagent timeouts. + subagent_timeout_registry: Arc>>>, /// Notifies DialogScheduler of turn outcomes; injected after construction scheduler_notify_tx: OnceLock>, /// Round-boundary yield (same source as scheduler's yield flags); injected after construction @@ -486,6 +562,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet event_queue, event_router, subagent_concurrency_limiter: Arc::new(RwLock::new(None)), + subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), scheduler_notify_tx: OnceLock::new(), round_preempt_source: OnceLock::new(), } @@ -502,6 +579,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let _ = self.round_preempt_source.set(source); } + /// Dynamically adjust a running subagent's timeout. + pub async fn set_subagent_timeout( + &self, + session_id: &str, + action: SubagentTimeoutAction, + ) -> BitFunResult<()> { + let registry = self.subagent_timeout_registry.read().await; + let handle = registry.get(session_id).cloned().ok_or_else(|| { + BitFunError::tool(format!( + "No active subagent timeout handle for session {}", + session_id + )) + })?; + drop(registry); + handle.apply_action(action.clone()); + info!( + "Subagent timeout adjusted: session_id={}, action={:?}", + session_id, + std::mem::discriminant(&action) + ); + Ok(()) + } + /// Create a new session pub async fn create_session( &self, @@ -2126,7 +2226,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ), None => format!("Subagent '{}' timed out", agent_type), }; - let deadline = timeout_seconds.map(|seconds| Instant::now() + Duration::from_secs(seconds)); + + // Create dynamic deadline via watch channel so it can be adjusted at runtime. + let initial_deadline = timeout_seconds + .map(|seconds| Instant::now() + Duration::from_secs(seconds)); + let (deadline_tx, mut deadline_rx) = watch::channel(initial_deadline); // Check cancel token (before creating session) if let Some(token) = cancel_token { @@ -2143,7 +2247,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // event is emitted to the transport layer — subagent sessions are internal // implementation details and must not appear in the UI session list. let (permit, limiter, wait_ms) = self - .acquire_subagent_concurrency_permit(&agent_type, cancel_token, deadline) + .acquire_subagent_concurrency_permit(&agent_type, cancel_token, initial_deadline) .await?; let _permit_guard = SubagentConcurrencyPermitGuard::new(permit, limiter, agent_type.clone()); @@ -2159,7 +2263,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet )); } } - if deadline.is_some_and(|expires_at| Instant::now() >= expires_at) { + if initial_deadline.is_some_and(|expires_at| Instant::now() >= expires_at) { warn!( "Subagent timed out before session creation after waiting for concurrency slot: agent_type={}, wait_ms={}", agent_type, wait_ms @@ -2177,22 +2281,38 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; let session_id = session.session_id.clone(); + // Register timeout handle so it can be adjusted at runtime. + let timeout_handle = Arc::new(SubagentTimeoutHandle { + deadline_tx: deadline_tx.clone(), + session_id: session_id.clone(), + original_timeout_seconds: timeout_seconds, + remaining_at_pause: std::sync::Mutex::new(None), + }); + { + let mut registry = self.subagent_timeout_registry.write().await; + registry.insert(session_id.clone(), timeout_handle); + } + // Check cancel token (after creating session, before execution) if let Some(token) = cancel_token { if token.is_cancelled() { debug!("Subagent task cancelled before AI call, cleaning up resources"); let _ = self.cleanup_subagent_resources(&session_id).await; + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(BitFunError::Cancelled( "Subagent task has been cancelled".to_string(), )); } } - if deadline.is_some_and(|expires_at| Instant::now() >= expires_at) { + if initial_deadline.is_some_and(|expires_at| Instant::now() >= expires_at) { warn!( "Subagent timed out before AI call after session creation: agent_type={}, session={}, wait_ms={}", agent_type, session_id, wait_ms ); let _ = self.cleanup_subagent_resources(&session_id).await; + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(BitFunError::Timeout(timeout_error_message.clone())); } @@ -2259,19 +2379,52 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet TimedOut, } - let execution_outcome = if let Some(expires_at) = deadline { - let sleep_until_timeout = tokio::time::sleep_until(expires_at); - tokio::pin!(sleep_until_timeout); - - tokio::select! { - join_result = &mut execution_task => SubagentExecutionOutcome::Completed(join_result), - _ = subagent_cancel_token.cancelled() => SubagentExecutionOutcome::Cancelled, - _ = &mut sleep_until_timeout => SubagentExecutionOutcome::TimedOut, - } - } else { - tokio::select! { - join_result = &mut execution_task => SubagentExecutionOutcome::Completed(join_result), - _ = subagent_cancel_token.cancelled() => SubagentExecutionOutcome::Cancelled, + // Dynamic timeout loop: deadline can be adjusted via watch channel. + let execution_outcome = loop { + let current_deadline = *deadline_rx.borrow(); + match current_deadline { + Some(expires_at) if Instant::now() >= expires_at => { + break SubagentExecutionOutcome::TimedOut; + } + Some(expires_at) => { + let sleep = tokio::time::sleep_until(expires_at); + tokio::pin!(sleep); + tokio::select! { + join_result = &mut execution_task => { + break SubagentExecutionOutcome::Completed(join_result); + } + _ = subagent_cancel_token.cancelled() => { + break SubagentExecutionOutcome::Cancelled; + } + _ = &mut sleep => { + // Sleep expired; check if deadline was updated. + continue; + } + _ = deadline_rx.changed() => { + // Deadline changed externally; re-evaluate. + // If sender was dropped, treat as no timeout and + // let execution_task/cancel_token branches handle it. + continue; + } + } + } + None => { + // No timeout (disabled). + tokio::select! { + join_result = &mut execution_task => { + break SubagentExecutionOutcome::Completed(join_result); + } + _ = subagent_cancel_token.cancelled() => { + break SubagentExecutionOutcome::Cancelled; + } + _ = deadline_rx.changed() => { + // Deadline was set; re-evaluate. + // If sender was dropped, remain in no-timeout mode + // and let execution_task/cancel_token branches handle it. + continue; + } + } + } } }; @@ -2290,6 +2443,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id, cleanup_err ); } + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(BitFunError::tool(format!( "Subagent '{}' failed to join: {}", @@ -2350,6 +2505,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id, cleanup_err ); } + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(BitFunError::Cancelled( "Subagent task has been cancelled".to_string(), @@ -2408,6 +2565,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id, cleanup_err ); } + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(BitFunError::Timeout(timeout_error_message.clone())); } @@ -2434,6 +2593,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id, cleanup_err ); } + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); return Err(e); } @@ -2452,6 +2613,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id ); } + let mut registry = self.subagent_timeout_registry.write().await; + registry.remove(&session_id); Ok(SubagentResult { text: response_text, diff --git a/src/crates/core/src/agentic/deep_review_policy.rs b/src/crates/core/src/agentic/deep_review_policy.rs index 6cb3d7c85..2abe9fc72 100644 --- a/src/crates/core/src/agentic/deep_review_policy.rs +++ b/src/crates/core/src/agentic/deep_review_policy.rs @@ -20,8 +20,8 @@ pub const CORE_REVIEWER_AGENT_TYPES: [&str; 3] = [ ]; const DEFAULT_REVIEW_TEAM_CONFIG_PATH: &str = "ai.review_teams.default"; -const DEFAULT_REVIEWER_TIMEOUT_SECONDS: u64 = 300; -const DEFAULT_JUDGE_TIMEOUT_SECONDS: u64 = 240; +const DEFAULT_REVIEWER_TIMEOUT_SECONDS: u64 = 600; +const DEFAULT_JUDGE_TIMEOUT_SECONDS: u64 = 600; const MAX_TIMEOUT_SECONDS: u64 = 3600; const DEFAULT_REVIEWER_FILE_SPLIT_THRESHOLD: usize = 20; const DEFAULT_MAX_SAME_ROLE_INSTANCES: usize = 3; diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx index 8001edb41..41f959e49 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx @@ -7,7 +7,6 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Split, - Clock, AlertCircle, Square } from 'lucide-react'; @@ -16,6 +15,7 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; +import { ToolTimeoutIndicator } from '../../tool-cards/ToolTimeoutIndicator'; import { Button, Tooltip, DotMatrixLoader } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -152,14 +152,6 @@ export const TaskDetailPanel: React.FC = ({ data }) => { } }, [isRunning]); - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - const minutes = Math.floor(ms / 60000); - const seconds = ((ms % 60000) / 1000).toFixed(0); - return `${minutes}m ${seconds}s`; - }; - // Open files in a split editor layout. const handleOpenInEditor = useCallback(async (filePath: string) => { if (!filePath) return; @@ -271,12 +263,22 @@ export const TaskDetailPanel: React.FC = ({ data }) => { {taskInput.agentType} )} - {isCompleted && toolResult?.result?.duration && ( - - - {formatDuration(toolResult.result.duration)} - - )} + 0 + ? toolItem.toolCall.input.timeout_seconds * 1000 + : undefined + } + showControls={true} + subagentSessionId={subagentSessionId} + completedDurationMs={ + isCompleted && toolResult?.result?.duration + ? toolResult.result.duration + : undefined + } + /> {isRunning && ( diff --git a/src/web-ui/src/flow_chat/hooks/useLiveElapsedTime.ts b/src/web-ui/src/flow_chat/hooks/useLiveElapsedTime.ts new file mode 100644 index 000000000..cdaf37a2a --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useLiveElapsedTime.ts @@ -0,0 +1,80 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +export interface UseLiveElapsedTimeResult { + elapsedMs: number; + remainingMs: number | null; +} + +/** + * Live elapsed time tracker for running subagent/tool cards. + * + * @param startTime - Tool start timestamp (ms). If undefined, returns 0. + * @param isRunning - Whether the tool is currently running. + * @param timeoutMs - Current effective timeout in ms. 0 or undefined = no timeout. + * @param isTimeoutDisabled - Whether the timeout has been disabled by user. + */ +export function useLiveElapsedTime( + startTime: number | undefined, + isRunning: boolean, + timeoutMs: number | undefined, + isTimeoutDisabled: boolean, +): UseLiveElapsedTimeResult { + const [elapsedMs, setElapsedMs] = useState(0); + const intervalRef = useRef | null>(null); + const startTimeRef = useRef(startTime); + const isRunningRef = useRef(isRunning); + const timeoutMsRef = useRef(timeoutMs); + const isTimeoutDisabledRef = useRef(isTimeoutDisabled); + + const computeElapsed = useCallback(() => { + const start = startTimeRef.current; + if (!start) return 0; + return Math.max(0, Date.now() - start); + }, []); + + const computeRemaining = useCallback((elapsed: number) => { + if (isTimeoutDisabledRef.current) return null; + const timeout = timeoutMsRef.current; + if (!timeout || timeout <= 0) return null; + return Math.max(0, timeout - elapsed); + }, []); + + useEffect(() => { + startTimeRef.current = startTime; + timeoutMsRef.current = timeoutMs; + isTimeoutDisabledRef.current = isTimeoutDisabled; + }); + + useEffect(() => { + isRunningRef.current = isRunning; + if (!isRunning) { + // Final update when stopping, then clear interval. + const finalElapsed = computeElapsed(); + setElapsedMs(finalElapsed); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + // Running: update immediately then start interval. + const update = () => { + const elapsed = computeElapsed(); + setElapsedMs(elapsed); + }; + update(); + intervalRef.current = setInterval(update, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [isRunning, computeElapsed]); + + const remainingMs = computeRemaining(elapsedMs); + + return { elapsedMs, remainingMs }; +} diff --git a/src/web-ui/src/flow_chat/hooks/useSubagentTimeoutControl.ts b/src/web-ui/src/flow_chat/hooks/useSubagentTimeoutControl.ts new file mode 100644 index 000000000..247c57b2d --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useSubagentTimeoutControl.ts @@ -0,0 +1,109 @@ +import { useState, useCallback, useRef } from 'react'; +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; + +export interface UseSubagentTimeoutControlResult { + /** Whether timeout is currently disabled by user. */ + isTimeoutDisabled: boolean; + /** Whether a toggle operation is in-flight. */ + isToggling: boolean; + /** Whether the popover for extend options is open. */ + isPopoverOpen: boolean; + /** Toggle timeout disable/enable. Returns true if action was taken, false if popover needed. */ + toggleTimeout: () => void; + /** Extend timeout by specified seconds. */ + extendTimeout: (seconds: number) => void; + /** Close the extend popover. */ + closePopover: () => void; + /** Remaining seconds at the moment timeout was disabled (for popover display). */ + remainingAtDisable: number; +} + +/** + * Hook for controlling subagent timeout disable/restore/extend. + * + * @param subagentSessionId - The subagent session ID (needed for API call). + * @param isRunning - Whether the subagent is currently running. + * @param timeoutMs - Original timeout in ms. + * @param remainingMs - Current remaining time in ms (null if no timeout or disabled). + */ +export function useSubagentTimeoutControl( + subagentSessionId: string | undefined, + isRunning: boolean, + timeoutMs: number | undefined, + remainingMs: number | null, +): UseSubagentTimeoutControlResult { + // timeoutMs is part of the API surface but not directly used here; + // remainingMs (derived from timeoutMs + elapsed time) drives the UI logic. + void timeoutMs; + + const [isTimeoutDisabled, setIsTimeoutDisabled] = useState(false); + const [isToggling, setIsToggling] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [remainingAtDisable, setRemainingAtDisable] = useState(0); + const isRunningRef = useRef(isRunning); + isRunningRef.current = isRunning; + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const callApi = useCallback(async ( + action: { type: 'disable' } | { type: 'restore' } | { type: 'extend'; seconds: number }, + ) => { + if (!subagentSessionId || !isRunningRef.current) return; + setIsToggling(true); + try { + await agentAPI.setSubagentTimeout(subagentSessionId, action); + } catch (_error) { + // Rollback on failure. + if (action.type === 'disable') { + setIsTimeoutDisabled(false); + } else { + setIsTimeoutDisabled(true); + } + } finally { + setIsToggling(false); + } + }, [subagentSessionId]); + + const toggleTimeout = useCallback(() => { + if (!subagentSessionId || !isRunning) return; + + if (isTimeoutDisabled) { + // Currently disabled -> want to restore. + // Check remaining time to decide if popover is needed. + const remaining = remainingMs ?? 0; + const remainingSec = Math.ceil(remaining / 1000); + if (remainingSec <= 30) { + // Need popover: remaining too short. + setRemainingAtDisable(remainingSec); + setIsPopoverOpen(true); + return; + } + // Direct restore. + setIsTimeoutDisabled(false); + callApi({ type: 'restore' }); + } else { + // Currently enabled -> disable. + setIsTimeoutDisabled(true); + callApi({ type: 'disable' }); + } + }, [isTimeoutDisabled, remainingMs, subagentSessionId, isRunning, callApi]); + + const extendTimeout = useCallback((seconds: number) => { + if (!subagentSessionId || !isRunning) return; + setIsTimeoutDisabled(false); + setIsPopoverOpen(false); + callApi({ type: 'extend', seconds }); + }, [subagentSessionId, isRunning, callApi]); + + return { + isTimeoutDisabled, + isToggling, + isPopoverOpen, + toggleTimeout, + extendTimeout, + closePopover, + remainingAtDisable, + }; +} diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index 30d060ebe..598c74428 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -5,7 +5,6 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Split, - Timer, ChevronRight, ChevronDown, } from 'lucide-react'; @@ -17,6 +16,7 @@ import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard } from './BaseToolCard'; import { taskCollapseStateManager } from '../store/TaskCollapseStateManager'; import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import './TaskToolDisplay.scss'; import './ModelThinkingDisplay.scss'; @@ -219,12 +219,6 @@ export const TaskToolDisplay: React.FC = ({ [onOpenInPanel, sessionId, taskInput, toolItem, taskHeaderLine], ); - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms`; - const seconds = (ms / 1000).toFixed(1); - return `${seconds}s`; - }; - const renderToolIcon = () => { return ; }; @@ -262,12 +256,22 @@ export const TaskToolDisplay: React.FC = ({
{taskHeaderLine}
- {status === 'completed' && toolResult?.result?.duration && ( - - - {formatDuration(toolResult.result.duration)} - - )} + 0 + ? toolCall.input.timeout_seconds * 1000 + : undefined + } + showControls={true} + subagentSessionId={toolItem.subagentSessionId} + completedDurationMs={ + status === 'completed' && toolResult?.result?.duration + ? toolResult.result.duration + : undefined + } + /> {isFailed && ( {t('toolCards.taskTool.failed')} )} diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index 02e9c9a02..0a4498c3a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -66,6 +66,7 @@ outline: none !important; box-shadow: none !important; padding: 0; + animation: none !important; &:not(.editable) { &:hover, diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 2bf808e4f..41b203f05 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -495,7 +495,7 @@ export const TerminalToolCard: React.FC = ({ <> 0 ? toolCall.input.timeout_ms diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss new file mode 100644 index 000000000..e3bf8f3ab --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss @@ -0,0 +1,135 @@ +.tool-timeout-indicator { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.duration-text { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--tool-card-text-secondary, var(--color-text-secondary, #9ca3af)); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.2; + + svg { + flex-shrink: 0; + opacity: 0.95; + } +} + +.duration-text--live { + color: var(--color-text-muted, #9ca3af); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.duration-text--warning { + color: var(--color-warning, #f59e0b); +} + +.duration-elapsed { + font-variant-numeric: tabular-nums; +} + +.duration-separator { + color: var(--color-text-muted, #9ca3af); + opacity: 0.5; + font-weight: 400; + margin: 0 1px; +} + +.duration-timeout { + color: var(--color-text-muted, #9ca3af); + opacity: 0.7; + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.duration-timeout--disabled { + text-decoration: line-through; + opacity: 0.5; +} + +.duration-timeout--warning { + color: var(--color-warning, #f59e0b); + opacity: 1; +} + +.timeout-control-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.timeout-ignore-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + padding: 0; + flex-shrink: 0; + + svg { + width: 12px; + height: 12px; + } + + &:hover { + background: var(--element-bg-soft); + color: var(--color-text-secondary); + } + + &.is-active { + color: var(--color-accent-500); + background: var(--element-bg-subtle); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.timeout-extend-popover { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 10; + min-width: 160px; + padding: 4px 0; + border-radius: 6px; + background: var(--color-bg-elevated); + border: 1px solid var(--border-base); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.timeout-extend-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + font-size: 12px; + color: var(--color-text-primary); + cursor: pointer; + background: transparent; + border: none; + text-align: left; + + &:hover { + background: var(--element-bg-soft); + } + + &--danger { + color: var(--color-warning, #f59e0b); + } +} diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx new file mode 100644 index 000000000..8bc0e81c1 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.tsx @@ -0,0 +1,203 @@ +import React, { useRef, useEffect } from 'react'; +import { Timer, Infinity as InfinityIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useLiveElapsedTime } from '../hooks/useLiveElapsedTime'; +import { useSubagentTimeoutControl } from '../hooks/useSubagentTimeoutControl'; +import './ToolTimeoutIndicator.scss'; + +function formatDurationLive(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +function formatDurationPrecise(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} + +export interface ToolTimeoutIndicatorProps { + startTime?: number; + isRunning: boolean; + timeoutMs?: number; + showControls?: boolean; + subagentSessionId?: string; + completedDurationMs?: number; +} + +export const ToolTimeoutIndicator: React.FC = ({ + startTime, + isRunning, + timeoutMs, + showControls = false, + subagentSessionId, + completedDurationMs, +}) => { + const { t } = useTranslation('flow-chat'); + const { + isTimeoutDisabled, + isToggling, + isPopoverOpen, + toggleTimeout, + extendTimeout, + closePopover, + remainingAtDisable, + } = useSubagentTimeoutControl(subagentSessionId, isRunning, timeoutMs, null); + + const { elapsedMs, remainingMs } = useLiveElapsedTime( + startTime, + isRunning, + timeoutMs, + isTimeoutDisabled, + ); + + const popoverRef = useRef(null); + + // Close popover on outside click. + useEffect(() => { + if (!isPopoverOpen) return; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + closePopover(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isPopoverOpen, closePopover]); + + // Close popover on Escape. + useEffect(() => { + if (!isPopoverOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closePopover(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isPopoverOpen, closePopover]); + + // Completed state: show precise duration only. + if (!isRunning && completedDurationMs != null) { + return ( + + + {formatDurationPrecise(completedDurationMs)} + + ); + } + + // Not running and no completed duration: nothing to show. + if (!isRunning) return null; + + const hasTimeout = Boolean(timeoutMs && timeoutMs > 0); + const displayRemaining = isTimeoutDisabled ? null : remainingMs; + + // Determine warning threshold: remaining < 20% of original timeout. + const isWarning = + displayRemaining != null && + timeoutMs != null && + timeoutMs > 0 && + displayRemaining < timeoutMs * 0.2; + + return ( + + + + {formatDurationLive(elapsedMs)} + {hasTimeout && ( + <> + / + + {isTimeoutDisabled + ? formatDurationLive(timeoutMs!) + : displayRemaining != null + ? formatDurationLive(displayRemaining) + : formatDurationLive(timeoutMs!)} + + + )} + + + {showControls && hasTimeout && ( +
+ + + {isPopoverOpen && ( +
+ {remainingAtDisable > 0 ? ( + + ) : null} + + + +
+ )} +
+ )} +
+ ); +}; 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 9eae90906..c37f0511e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -481,6 +481,24 @@ export class AgentAPI { } } + async setSubagentTimeout( + sessionId: string, + action: { type: 'disable' } | { type: 'restore' } | { type: 'extend'; seconds: number }, + ): Promise { + const actionPayload = action.type === 'disable' + ? 'Disable' + : action.type === 'restore' + ? 'Restore' + : { Extend: { seconds: action.seconds } }; + try { + await api.invoke('set_subagent_timeout', { + request: { session_id: sessionId, action: actionPayload }, + }); + } catch (error) { + throw createTauriCommandError('set_subagent_timeout', error, { sessionId, action: action.type }); + } + } + async getAgentInfo(agentType: string): Promise { return { id: agentType, diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index b4c722bc5..57b75a3d0 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -442,7 +442,11 @@ "searchPrevious": "Previous match", "searchNext": "Next match", "searchClose": "Close search", - "searchOpen": "Search messages" + "searchOpen": "Search messages", + "jumpToCurrentTurn": "Jump to Turn {{turn}}" + }, + "stickyTaskIndicator": { + "tooltip": "Jump to current task" }, "sessionFilesBadge": { "files": "files", @@ -786,6 +790,11 @@ "failed": "Failed" } }, + "timeout": { + "disableTooltip": "Disable timeout", + "enableTooltip": "Re-enable timeout", + "restoreShort": "Restore ({{seconds}}s left)" + }, "diagram": { "interactive": "Interactive diagram:", "preparing": "Preparing", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index a503f21c3..491565de9 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -442,7 +442,11 @@ "searchPrevious": "上一个匹配结果", "searchNext": "下一个匹配结果", "searchClose": "关闭搜索", - "searchOpen": "搜索消息" + "searchOpen": "搜索消息", + "jumpToCurrentTurn": "跳转到第 {{turn}} 轮" + }, + "stickyTaskIndicator": { + "tooltip": "跳转到当前任务" }, "sessionFilesBadge": { "files": "文件", @@ -786,6 +790,11 @@ "failed": "执行失败" } }, + "timeout": { + "disableTooltip": "关闭超时限制", + "enableTooltip": "恢复超时限制", + "restoreShort": "恢复(剩余 {{seconds}} 秒)" + }, "diagram": { "interactive": "交互图表:", "preparing": "准备中", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 85861f020..bfe71017e 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -433,7 +433,11 @@ "searchPrevious": "上一個匹配結果", "searchNext": "下一個匹配結果", "searchClose": "關閉搜索", - "searchOpen": "搜索消息" + "searchOpen": "搜索消息", + "jumpToCurrentTurn": "跳轉到第 {{turn}} 輪" + }, + "stickyTaskIndicator": { + "tooltip": "跳轉到當前任務" }, "sessionFilesBadge": { "files": "文件", From e4e5fee8676e910ea827e5db53a10e0413fe60d4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 02:09:21 +0800 Subject: [PATCH 4/4] feat(flow-chat): clickable turn header + sticky task indicator - FlowChatHeader center message area is now clickable to jump to current turn top - Add StickyTaskIndicator component showing nearest Task above viewport - Add useVisibleTaskInfo hook to detect visible Task via scroll + DOM - Add data-tool-name attribute to tool items for DOM querying - Fix FlowChatHeader __git-branch padding cutting descenders (y/g/p) - Add cursor: pointer to clickable areas - Add i18n keys for new UI elements Generated with BitFun Co-Authored-By: BitFun --- .../components/StickyTaskIndicator.scss | 121 ++++++++++++ .../components/StickyTaskIndicator.tsx | 59 ++++++ .../components/modern/FlowChatHeader.scss | 17 +- .../components/modern/FlowChatHeader.tsx | 20 +- .../modern/ModernFlowChatContainer.tsx | 4 + .../components/modern/SessionFilesBadge.tsx | 2 +- .../components/modern/VirtualMessageList.tsx | 24 +++ .../src/flow_chat/hooks/useVisibleTaskInfo.ts | 185 ++++++++++++++++++ 8 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss create mode 100644 src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss new file mode 100644 index 000000000..386619ee5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss @@ -0,0 +1,121 @@ +/** + * Sticky task indicator styles. + * Fixed at the top of the flowchat message list with a gradient fade. + */ + +.sticky-task-indicator { + position: absolute; + left: 0; + right: 0; + top: 93px; // Below FlowChatHeader (36px) + ScrollToTurnHeaderButton hover zone (57px). + z-index: 10; + pointer-events: none; + + // Trigger area height: 40px hover zone + 20px gradient. + height: 60px; + + opacity: 0; + transition: opacity 0.25s ease; + + // ========== Visible state ========== + &--visible { + opacity: 1; + pointer-events: auto; + } + + // ========== Gradient background layer ========== + &__gradient { + position: absolute; + inset: 0; + pointer-events: none; + + background: linear-gradient( + to bottom, + var(--color-bg-flowchat, var(--color-bg-scene, #1c1c1f)) 0%, + color-mix(in srgb, var(--color-bg-flowchat, var(--color-bg-scene, #1c1c1f)) 80%, transparent) 40%, + color-mix(in srgb, var(--color-bg-flowchat, var(--color-bg-scene, #1c1c1f)) 40%, transparent) 70%, + transparent 100% + ); + } + + // ========== Content layer ========== + &__content { + position: absolute; + top: 8px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + + transition: transform 0.15s ease; + } + + // ========== Task label button ========== + &__btn { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: min(480px, 70vw); + height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border-base); + background: var(--color-bg-scene); + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--flowchat-font-size-xs); + line-height: 1; + white-space: nowrap; + + transition: border-color 0.2s ease, color 0.2s ease, transform 0.15s ease; + + &:hover { + border-color: var(--border-medium); + color: var(--color-text-primary); + transform: translateY(-1px); + } + + &:active { + transform: translateY(-2px); + } + + &:focus-visible { + outline: none; + border-color: var(--border-strong); + color: var(--color-text-primary); + box-shadow: 0 0 0 1.5px var(--border-strong); + } + } + + &__icon { + flex-shrink: 0; + opacity: 0.75; + } + + &__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__arrow { + flex-shrink: 0; + opacity: 0.6; + } +} + +// ========== Responsive tweaks ========== +@media (max-width: 768px) { + .sticky-task-indicator { + top: 93px; + height: 52px; + + &__btn { + height: 26px; + padding: 0 8px; + max-width: min(320px, 80vw); + } + } +} diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx new file mode 100644 index 000000000..22dc288d2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx @@ -0,0 +1,59 @@ +/** + * Sticky task indicator. + * Shows at the top of the message list when the user has scrolled past a Task + * tool card, indicating which Task they are currently reading. + * Clicking the indicator scrolls the Task to the viewport top. + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Split, ChevronUp } from 'lucide-react'; +import { Tooltip } from '@/component-library'; +import type { VisibleTaskInfo } from '../hooks/useVisibleTaskInfo'; +import './StickyTaskIndicator.scss'; + +interface StickyTaskIndicatorProps { + visible: boolean; + taskInfo: VisibleTaskInfo | null; + onClick: () => void; +} + +export const StickyTaskIndicator: React.FC = ({ + visible, + taskInfo, + onClick, +}) => { + const { t } = useTranslation('flow-chat'); + + const label = taskInfo?.label || t('toolCards.taskTool.defaultAgentKind', { defaultValue: 'Task' }); + const tooltip = t('stickyTaskIndicator.tooltip', { + defaultValue: 'Jump to current task', + }); + + return ( +
+
+
+ + + +
+
+ ); +}; + +StickyTaskIndicator.displayName = 'StickyTaskIndicator'; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index f577bb18b..10210d5b7 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -261,6 +261,19 @@ justify-content: center; gap: $size-gap-2; overflow: hidden; + cursor: pointer; + border-radius: $size-radius-base; + transition: background $motion-base $easing-standard; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 70%, transparent); + } + + &:focus-visible { + outline: none; + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + box-shadow: 0 0 0 1.5px var(--border-strong); + } } &__turn-badge { @@ -306,12 +319,12 @@ flex-shrink: 0; min-width: 0; max-width: 140px; - height: 22px; + height: 24px; padding: 0 6px; border-radius: $size-radius-sm; color: var(--color-text-secondary); font-size: var(--flowchat-font-size-xs); - line-height: 1; + line-height: 1.4; white-space: nowrap; svg { diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index dacc8404f..c54de5de7 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -42,6 +42,8 @@ export interface FlowChatHeaderProps { turns?: FlowChatHeaderTurnSummary[]; /** Jump to a specific turn. */ onJumpToTurn?: (turnId: string) => void; + /** Jump to the currently displayed turn. */ + onJumpToCurrentTurn?: () => void; /** Jump to the previous turn. */ onJumpToPreviousTurn?: () => void; /** Jump to the next turn. */ @@ -74,6 +76,7 @@ export const FlowChatHeader: React.FC = ({ btwParentTitle = '', turns = [], onJumpToTurn, + onJumpToCurrentTurn, onJumpToPreviousTurn, onJumpToNextTurn, searchQuery = '', @@ -264,7 +267,22 @@ export const FlowChatHeader: React.FC = ({
-
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onJumpToCurrentTurn?.(); + } + }} + aria-label={t('flowChatHeader.jumpToCurrentTurn', { + turn: currentTurn, + defaultValue: `Jump to Turn ${currentTurn}`, + })} + > {turnBadgeLabel} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 8521809c0..1c2cf1181 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -324,6 +324,10 @@ export const ModernFlowChatContainer: React.FC = ( btwParentTitle={btwParentTitle} turns={turnSummaries} onJumpToTurn={handleJumpToTurn} + onJumpToCurrentTurn={() => { + const turnId = effectiveVisibleTurnInfo?.turnId; + if (turnId) handleJumpToTurn(turnId); + }} onJumpToPreviousTurn={handleJumpToPreviousTurn} onJumpToNextTurn={handleJumpToNextTurn} searchQuery={searchQuery} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index ac7ae9da9..d6cb91667 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -685,7 +685,7 @@ export const SessionFilesBadge: React.FC = ({ } }; - const activeReviewMode = launchingReviewMode ?? reviewActivity?.kind ?? null; + const activeReviewMode = launchingReviewMode ?? (reviewActivity?.isBlocking ? reviewActivity.kind : null) ?? null; const activeReviewLabel = activeReviewMode === 'deep_review' ? t('sessionFilesBadge.reviewRunningDeep', { defaultValue: 'Deep review in progress', diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index fb45a240d..0c7482c17 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -18,6 +18,8 @@ import { VirtualItemRenderer } from './VirtualItemRenderer'; import { ScrollToLatestBar } from '../ScrollToLatestBar'; import { ScrollToTurnHeaderButton } from '../ScrollToTurnHeaderButton'; import { useScrollToTurnHeader } from '../../hooks/useScrollToTurnHeader'; +import { useVisibleTaskInfo } from '../../hooks/useVisibleTaskInfo'; +import { StickyTaskIndicator } from '../StickyTaskIndicator'; import { ProcessingIndicator } from './ProcessingIndicator'; import { ScrollAnchor } from './ScrollAnchor'; import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; @@ -1673,6 +1675,15 @@ export const VirtualMessageList = forwardRef((_, ref) => turnId: latestTurnId, sawPositiveFloor: false, }; + + const hasUnread = activeSession?.hasUnreadCompletion; + const isFinished = !isStreamingOutput; + if (hasUnread && isFinished && virtuosoRef.current) { + requestAnimationFrame(() => { + virtuosoRef.current?.scrollTo({ top: 999999999, behavior: 'auto' }); + }); + } + return; } @@ -1695,8 +1706,10 @@ export const VirtualMessageList = forwardRef((_, ref) => armFollowOutputForNewTurn(); }, [ activeSession?.sessionId, + activeSession?.hasUnreadCompletion, armFollowOutputForNewTurn, cancelPendingAutoFollowArm, + isStreamingOutput, latestTurnId, ]); @@ -1823,6 +1836,11 @@ export const VirtualMessageList = forwardRef((_, ref) => onJumpToCurrentTurn: handleJumpToCurrentTurn, }); + const { visibleTaskInfo, scrollToTask } = useVisibleTaskInfo({ + scrollerRef: scrollerElementRef, + virtualItems, + }); + const scrollToPhysicalBottomAndClearPin = useCallback(() => { if (virtuosoRef.current && virtualItems.length > 0) { clearPinReservationForUserNavigation(); @@ -2009,6 +2027,12 @@ export const VirtualMessageList = forwardRef((_, ref) => turnLabel={visibleTurnInfo ? `Turn ${visibleTurnInfo.turnIndex}` : undefined} /> + + 0} onClick={scrollToLatestEndPosition} diff --git a/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts b/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts new file mode 100644 index 000000000..b158fcbd1 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts @@ -0,0 +1,185 @@ +/** + * Hook to detect the nearest Task tool item above the current viewport. + * Used to show a sticky indicator of which Task the user is currently reading. + * + * - Scans virtualItems for FlowToolItem entries with toolName === 'Task'. + * - Uses the DOM position of rendered Task items to determine which one is + * just above the viewport top. + * - Returns the Task description and a callback to scroll to it. + */ + +import { useRef, useCallback, useState, useEffect } from 'react'; +import type { VirtualItem } from '../store/modernFlowChatStore'; +import type { FlowToolItem } from '../types/flow-chat'; + +const VIEWPORT_TOP_OFFSET_PX = 57; // Keep in sync with PINNED_TURN_VIEWPORT_OFFSET_PX. +const TASK_TOOL_NAME = 'Task'; + +export interface VisibleTaskInfo { + /** The virtual item index of the Task tool. */ + virtualIndex: number; + /** The FlowItem id of the Task tool. */ + itemId: string; + /** Display label for the Task (description or prompt). */ + label: string; + /** The turnId this Task belongs to. */ + turnId: string; +} + +interface UseVisibleTaskInfoOptions { + scrollerRef: React.RefObject; + virtualItems: VirtualItem[]; +} + +interface UseVisibleTaskInfoReturn { + visibleTaskInfo: VisibleTaskInfo | null; + /** Scroll the list so the indicated Task is at the viewport top. */ + scrollToTask: () => void; +} + +function getTaskLabel(toolItem: FlowToolItem): string { + const input = toolItem.toolCall?.input; + if (!input) return ''; + const desc = input.description || input.prompt || input.task || ''; + return typeof desc === 'string' ? desc.trim() : ''; +} + +function findTaskVirtualItems(virtualItems: VirtualItem[]): Array<{ + index: number; + itemId: string; + turnId: string; + label: string; +}> { + const result: Array<{ index: number; itemId: string; turnId: string; label: string }> = []; + + for (let i = 0; i < virtualItems.length; i++) { + const vItem = virtualItems[i]; + if (vItem.type !== 'model-round') continue; + + const round = vItem.data; + for (const flowItem of round.items) { + if (flowItem.type === 'tool' && (flowItem as FlowToolItem).toolName === TASK_TOOL_NAME) { + result.push({ + index: i, + itemId: flowItem.id, + turnId: vItem.turnId, + label: getTaskLabel(flowItem as FlowToolItem), + }); + } + } + } + + return result; +} + +export function useVisibleTaskInfo(options: UseVisibleTaskInfoOptions): UseVisibleTaskInfoReturn { + const { scrollerRef, virtualItems } = options; + const [visibleTaskInfo, setVisibleTaskInfo] = useState(null); + const lastVisibleRef = useRef(null); + const taskItemsRef = useRef(findTaskVirtualItems(virtualItems)); + + // Keep task items cache in sync without triggering re-renders. + useEffect(() => { + taskItemsRef.current = findTaskVirtualItems(virtualItems); + }, [virtualItems]); + + const checkVisibleTask = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + + const taskItems = taskItemsRef.current; + if (taskItems.length === 0) { + if (lastVisibleRef.current !== null) { + lastVisibleRef.current = null; + setVisibleTaskInfo(null); + } + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const viewportTop = scrollerRect.top + VIEWPORT_TOP_OFFSET_PX; + + // Find the last Task whose DOM element top is above the viewport top. + let matched: VisibleTaskInfo | null = null; + + for (let i = taskItems.length - 1; i >= 0; i--) { + const task = taskItems[i]; + const element = scroller.querySelector( + `.flowchat-flow-item[data-flow-item-id="${CSS.escape(task.itemId)}"][data-tool-name="${TASK_TOOL_NAME}"]`, + ); + if (!element) continue; + + const rect = element.getBoundingClientRect(); + // Task element must be above or crossing the viewport top. + if (rect.top <= viewportTop) { + matched = { + virtualIndex: task.index, + itemId: task.itemId, + label: task.label, + turnId: task.turnId, + }; + break; + } + } + + if ( + matched?.itemId !== lastVisibleRef.current?.itemId || + matched?.label !== lastVisibleRef.current?.label + ) { + lastVisibleRef.current = matched; + setVisibleTaskInfo(matched); + } + }, [scrollerRef]); + + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + + let rafId: number | null = null; + const throttledCheck = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + checkVisibleTask(); + rafId = null; + }); + }; + + scroller.addEventListener('scroll', throttledCheck, { passive: true }); + checkVisibleTask(); + + return () => { + scroller.removeEventListener('scroll', throttledCheck); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [checkVisibleTask, scrollerRef]); + + // Reset when session / items change. + useEffect(() => { + lastVisibleRef.current = null; + setVisibleTaskInfo(null); + }, [virtualItems]); + + const scrollToTask = useCallback(() => { + const info = lastVisibleRef.current ?? visibleTaskInfo; + if (!info) return; + + const scroller = scrollerRef.current; + if (!scroller) return; + + const element = scroller.querySelector( + `.flowchat-flow-item[data-flow-item-id="${CSS.escape(info.itemId)}"][data-tool-name="${TASK_TOOL_NAME}"]`, + ); + if (!element) return; + + const scrollerRect = scroller.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const offset = elementRect.top - scrollerRect.top - VIEWPORT_TOP_OFFSET_PX + scroller.scrollTop; + + scroller.scrollTo({ top: offset, behavior: 'smooth' }); + }, [visibleTaskInfo, scrollerRef]); + + return { + visibleTaskInfo, + scrollToTask, + }; +}