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/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/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/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/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/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/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/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/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/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/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/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, + }; +} 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/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 c74cc7f9c..41b203f05 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()} /> ); 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/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/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; } 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": "文件",