From 937795a8fa8e2fa485e307f1a8c41f14a791ce02 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 11 May 2026 23:41:02 +0800 Subject: [PATCH] feat(agent): generate Write tool body via plaintext follow-up RoundExecutor synthesizes Write tool contents in a secondary completion using `` tags when the model omits `content` from JSON. Update Write tool schema to require only file_path (no inlined content). Polish flow chat UI: collapsible transitions, virtual list/follow-output, tool cards, and event handling for streamed tool telemetry. --- .../src/agentic/execution/round_executor.rs | 269 +++++++++++++++++- .../tools/implementations/file_write_tool.rs | 58 +--- .../src/flow_chat/components/CodePreview.tsx | 28 +- .../modern/ExploreGroupRenderer.tsx | 30 +- .../components/modern/ExploreRegion.scss | 29 +- .../components/modern/ModelRoundItem.tsx | 42 +-- .../modern/SmoothHeightCollapse.scss | 40 +++ .../modern/SmoothHeightCollapse.tsx | 98 +++++++ .../components/modern/SubagentItems.scss | 29 +- .../components/modern/VirtualItemRenderer.tsx | 1 + .../components/modern/VirtualMessageList.tsx | 5 + .../modern/useFlowChatFollowOutput.ts | 29 +- .../EventHandlerModule.test.ts | 7 + .../flow-chat-manager/EventHandlerModule.ts | 25 +- .../flow-chat-manager/ToolEventModule.ts | 6 +- .../flow_chat/store/modernFlowChatStore.ts | 16 +- .../flow_chat/tool-cards/BaseToolCard.scss | 9 +- .../src/flow_chat/tool-cards/BaseToolCard.tsx | 9 +- .../flow_chat/tool-cards/CompactToolCard.scss | 3 +- .../flow_chat/tool-cards/CompactToolCard.tsx | 5 +- .../tool-cards/FileOperationToolCard.tsx | 17 +- 21 files changed, 609 insertions(+), 146 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss create mode 100644 src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 5946699d0..5e9b5a1a9 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -17,7 +17,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use dashmap::DashMap; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -481,10 +481,28 @@ impl RoundExecutor { return Err(BitFunError::Cancelled("Execution cancelled".to_string())); } + // ---- Write tool content generation ---- + // For Write tool calls without a "content" field, spawn a separate AI + // request with the full session history to generate the file content as + // plain text wrapped in tags. This avoids having the + // model emit large file contents inside JSON tool-call arguments, which + // is a major source of JSON parse failures. + let tool_calls = stream_result.tool_calls.clone(); + let tool_calls = self + .generate_write_tool_contents( + ai_client.clone(), + &context, + &ai_messages, + tool_calls, + &cancel_token, + event_subagent_parent_info.clone(), + ) + .await?; + // Execute tool calls debug!( "Preparing to execute tool calls: count={}", - stream_result.tool_calls.len() + tool_calls.len() ); let tool_phase_started_at = Instant::now(); @@ -566,18 +584,17 @@ impl RoundExecutor { // Execute tools — convert pipeline-level Err into per-tool error results // so the model always receives a tool_result for every tool_call. let execution_results = match tool_pipeline - .execute_tools(stream_result.tool_calls.clone(), tool_context, tool_options) + .execute_tools(tool_calls.clone(), tool_context, tool_options) .await { Ok(results) => results, Err(e) => { error!( "Tool pipeline execution failed, generating error results for all {} tool calls: {}", - stream_result.tool_calls.len(), + tool_calls.len(), e ); - stream_result - .tool_calls + tool_calls .iter() .map(|tc| crate::agentic::tools::pipeline::ToolExecutionResult { tool_id: tc.tool_id.clone(), @@ -620,7 +637,7 @@ impl RoundExecutor { let assistant_message = Message::assistant_with_reasoning( reasoning, stream_result.full_text.clone(), - stream_result.tool_calls.clone(), + tool_calls.clone(), ) .with_turn_id(context.dialog_turn_id.clone()) .with_round_id(round_id.clone()) @@ -676,7 +693,7 @@ impl RoundExecutor { Ok(RoundResult { assistant_message, - tool_calls: stream_result.tool_calls.clone(), + tool_calls: tool_calls.clone(), tool_result_messages, has_more_rounds, finish_reason: if has_more_rounds { @@ -736,6 +753,193 @@ impl RoundExecutor { } } + /// Generate file content for Write tool calls that lack a `content` field. + /// + /// When a Write tool call arrives without `content`, this method spawns a + /// separate AI request with the full session history and a directive to + /// output the file content as plain text inside `` tags. + /// The extracted content is then injected into the tool call arguments so + /// the downstream Write tool execution proceeds as normal. + async fn generate_write_tool_contents( + &self, + ai_client: Arc, + context: &RoundContext, + ai_messages: &[AIMessage], + mut tool_calls: Vec, + cancel_token: &CancellationToken, + subagent_parent_info: Option, + ) -> BitFunResult> { + // Find indices of Write tool calls that need content generation + let write_indices: Vec = tool_calls + .iter() + .enumerate() + .filter(|(_, tc)| { + tc.tool_name == "Write" + && tc.arguments.get("content").is_none() + && tc.arguments.get("file_path").and_then(|v| v.as_str()).is_some() + }) + .map(|(i, _)| i) + .collect(); + + if write_indices.is_empty() { + return Ok(tool_calls); + } + + info!( + "Generating content for {} Write tool call(s) via separate AI request", + write_indices.len() + ); + + for idx in &write_indices { + if cancel_token.is_cancelled() { + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + + let tc = &tool_calls[*idx]; + let file_path = tc + .arguments + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_id = tc.tool_id.clone(); + + // Emit Started event so the UI can show the tool card + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + tool_event: ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: "Write".to_string(), + params: tc.arguments.clone(), + timeout_seconds: None, + }, + subagent_parent_info: subagent_parent_info.clone(), + }, + EventPriority::High, + ) + .await; + + // Build a content-generation prompt + let content_prompt = format!( + "Now output the complete file content for the file `{}`. \ + Output ONLY the raw file content wrapped in tags. \ + Do NOT include any other text, explanation, or commentary outside the tags.\n\ + \n", + file_path + ); + + let mut content_messages = ai_messages.to_vec(); + content_messages.push(AIMessage::user(content_prompt)); + + // Send the content-generation request (no tools, pure text output) + let full_text = match ai_client + .send_message_stream(content_messages, None) + .await + { + Ok(response) => { + let mut text = String::new(); + let mut stream = response.stream; + use futures::StreamExt; + while let Some(chunk) = stream.next().await { + if cancel_token.is_cancelled() { + return Err(BitFunError::Cancelled("Execution cancelled".to_string())); + } + match chunk { + Ok(resp) => { + let chunk_text = resp.text.unwrap_or_default(); + if !chunk_text.is_empty() { + text.push_str(&chunk_text); + + // Emit streaming ParamsPartial so the UI + // shows a live content preview + let params = serde_json::json!({ + "file_path": &file_path, + "content": &text, + }); + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + tool_event: ToolEventData::ParamsPartial { + tool_id: tool_id.clone(), + tool_name: "Write".to_string(), + params: params.to_string(), + }, + subagent_parent_info: subagent_parent_info.clone(), + }, + EventPriority::Normal, + ) + .await; + } + } + Err(e) => { + error!("Error in Write content generation stream: {}", e); + break; + } + } + } + text + } + Err(e) => { + error!("Write content generation request failed: {}", e); + return Err(BitFunError::AIClient(format!( + "Write content generation failed for {}: {}", + file_path, e + ))); + } + }; + + let content = extract_bitfun_contents(&full_text); + if content.is_empty() { + warn!( + "Write content generation returned empty content for file_path={}", + file_path + ); + } + + let final_params = serde_json::json!({ + "file_path": &file_path, + "content": &content, + }); + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + tool_event: ToolEventData::ParamsPartial { + tool_id: tool_id.clone(), + tool_name: "Write".to_string(), + params: final_params.to_string(), + }, + subagent_parent_info: subagent_parent_info.clone(), + }, + EventPriority::Normal, + ) + .await; + + // Inject content into the tool call arguments + tool_calls[*idx] + .arguments + .as_object_mut() + .expect("Write tool arguments must be a JSON object") + .insert("content".to_string(), serde_json::Value::String(content)); + + debug!( + "Write content generated: file_path={}, content_len={}", + file_path, + tool_calls[*idx] + .arguments + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len()) + .unwrap_or(0) + ); + } + + Ok(tool_calls) + } + /// Emit event async fn emit_event(&self, event: AgenticEvent, priority: EventPriority) { let _ = self.event_queue.enqueue(event, Some(priority)).await; @@ -889,9 +1093,32 @@ fn token_details_from_usage( (!details.is_empty()).then_some(serde_json::Value::Object(details)) } +/// Extract content from `...` tags. +/// +/// If the tags are present, returns the text between them (trimmed). +/// If the tags are not present, returns the full text trimmed (fallback for +/// models that ignore the tag instruction). +fn extract_bitfun_contents(text: &str) -> String { + const OPEN_TAG: &str = ""; + const CLOSE_TAG: &str = ""; + + if let Some(start) = text.find(OPEN_TAG) { + let content_start = start + OPEN_TAG.len(); + if let Some(end) = text[content_start..].find(CLOSE_TAG) { + return text[content_start..content_start + end].trim().to_string(); + } + // Opening tag found but no closing tag — take everything after the + // opening tag (the model may still be streaming or forgot to close). + return text[content_start..].trim().to_string(); + } + + // No tags at all — return the full text as a fallback + text.trim().to_string() +} + #[cfg(test)] mod tests { - use super::{RoundExecutor, StreamProcessor}; + use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; use crate::agentic::events::{EventQueue, EventQueueConfig}; use dashmap::DashMap; use std::sync::Arc; @@ -1020,4 +1247,28 @@ mod tests { "I can help with that." )); } + + #[test] + fn extract_bitfun_contents_with_tags() { + let text = "Some preamble\n\nfn main() {}\n\nSome trailing"; + assert_eq!(extract_bitfun_contents(text), "fn main() {}"); + } + + #[test] + fn extract_bitfun_contents_without_tags_fallback() { + let text = "fn main() {}"; + assert_eq!(extract_bitfun_contents(text), "fn main() {}"); + } + + #[test] + fn extract_bitfun_contents_open_tag_only() { + let text = "\nfn main() {}"; + assert_eq!(extract_bitfun_contents(text), "fn main() {}"); + } + + #[test] + fn extract_bitfun_contents_empty() { + let text = ""; + assert_eq!(extract_bitfun_contents(text), ""); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 8e34c4a3b..0bf319b44 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -10,9 +10,6 @@ use tokio::fs; pub struct FileWriteTool; -const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; -const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; - impl Default for FileWriteTool { fn default() -> Self { Self::new() @@ -39,10 +36,10 @@ Usage: - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. - The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- Keep writes focused. The 200-line / 20KB guideline is a soft reliability threshold, not a hard cap. If a task genuinely needs more content, preserve correctness and use a staged plan instead of truncating. -- For existing files, prefer Read + targeted Edit calls. For large new files or rewrites, write the stable scaffold first, then fill or revise sections with focused Edit calls. Do not replace an entire existing file just to change a few sections. +- For existing files, prefer Read + targeted Edit calls. For new files or rewrites, preserve correctness and provide the complete intended file content when this tool is appropriate. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. -- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked."#.to_string()) +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +- Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."#.to_string()) } fn input_schema(&self) -> Value { @@ -52,13 +49,9 @@ Usage: "file_path": { "type": "string", "description": "The file to write. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." - }, - "content": { - "type": "string", - "description": "The content to write to the file. 200 lines / 20KB is a soft reliability threshold, not a hard cap. For existing files prefer Read + focused Edit calls. For large new files, write a stable scaffold first, then add sections in follow-up focused edits unless a complete initial body is required." } }, - "required": ["file_path", "content"], + "required": ["file_path"], "additionalProperties": false }) } @@ -92,31 +85,6 @@ Usage: } }; - if input.get("content").is_none() { - return ValidationResult { - result: false, - message: Some("content is required".to_string()), - error_code: Some(400), - meta: None, - }; - } - - let large_write_warning = - input - .get("content") - .and_then(|v| v.as_str()) - .and_then(|content| { - let line_count = content.lines().count(); - let byte_count = content.len(); - if line_count > LARGE_WRITE_SOFT_LINE_LIMIT - || byte_count > LARGE_WRITE_SOFT_BYTE_LIMIT - { - Some((line_count, byte_count)) - } else { - None - } - }); - if let Some(ctx) = context { let resolved = match ctx.resolve_tool_path(file_path) { Ok(resolved) => resolved, @@ -140,24 +108,6 @@ Usage: } } - if let Some((line_count, byte_count)) = large_write_warning { - return ValidationResult { - result: true, - message: Some(format!( - "Large Write payload: {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: for existing files use Read + focused Edit calls; for large new files write a stable scaffold first, then add sections in follow-up edits unless a complete initial body is required.", - line_count, byte_count - )), - error_code: None, - meta: Some(json!({ - "large_write": true, - "line_count": line_count, - "byte_count": byte_count, - "soft_line_limit": LARGE_WRITE_SOFT_LINE_LIMIT, - "soft_byte_limit": LARGE_WRITE_SOFT_BYTE_LIMIT - })), - }; - } - ValidationResult::default() } diff --git a/src/web-ui/src/flow_chat/components/CodePreview.tsx b/src/web-ui/src/flow_chat/components/CodePreview.tsx index 27b206cb6..e836d5338 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.tsx +++ b/src/web-ui/src/flow_chat/components/CodePreview.tsx @@ -79,13 +79,25 @@ export const CodePreview: React.FC = memo(({ // (maxHeight ≈ 88 px), we only need to tokenize the tail of the buffer. // After streaming ends, the full content is restored for the completed view. const STREAMING_TAIL_LINES = 60; // generous tail – more than enough for any maxHeight - const displayContent = useMemo(() => { - if (!isStreaming) return deferredContent; + const displayContentInfo = useMemo(() => { + if (!isStreaming) { + return { content: deferredContent, startingLineNumber: 1 }; + } + const lines = deferredContent.split('\n'); - if (lines.length <= STREAMING_TAIL_LINES) return deferredContent; - return lines.slice(-STREAMING_TAIL_LINES).join('\n'); + if (lines.length <= STREAMING_TAIL_LINES) { + return { content: deferredContent, startingLineNumber: 1 }; + } + + const startingLineNumber = lines.length - STREAMING_TAIL_LINES + 1; + return { + content: lines.slice(-STREAMING_TAIL_LINES).join('\n'), + startingLineNumber, + }; }, [isStreaming, deferredContent]); + const displayContent = displayContentInfo.content; + const [highlightedLine, setHighlightedLine] = useState(null); const detectedLanguage = useMemo(() => { @@ -115,7 +127,8 @@ export const CodePreview: React.FC = memo(({ }, [onLineClick, filePath]); const lineProps = useCallback((lineNumber: number): React.HTMLProps => { - const isHighlighted = highlightedLine === lineNumber; + const actualLineNumber = displayContentInfo.startingLineNumber + lineNumber - 1; + const isHighlighted = highlightedLine === actualLineNumber; return { style: { display: 'block', @@ -125,10 +138,10 @@ export const CodePreview: React.FC = memo(({ paddingLeft: '3px', transition: 'background-color 0.15s ease, border-color 0.15s ease', }, - onClick: () => handleLineClick(lineNumber), + onClick: () => handleLineClick(actualLineNumber), className: isHighlighted ? 'code-line--highlighted' : '', }; - }, [highlightedLine, handleLineClick]); + }, [highlightedLine, handleLineClick, displayContentInfo.startingLineNumber]); if (!content) { return ( @@ -153,6 +166,7 @@ export const CodePreview: React.FC = memo(({ language={detectedLanguage} style={prismStyle} showLineNumbers={showLineNumbers} + startingLineNumber={displayContentInfo.startingLineNumber} wrapLines={true} wrapLongLines={true} lineProps={lineProps} 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 5ed97360c..24584160a 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -14,6 +14,7 @@ import { FlowToolCard } from '../FlowToolCard'; import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; import { useToolCardHeightContract } from '../../tool-cards/useToolCardHeightContract'; import { useFlowChatContext } from './FlowChatContext'; +import { SmoothHeightCollapse } from './SmoothHeightCollapse'; import './ExploreRegion.scss'; export interface ExploreGroupRendererProps { @@ -205,20 +206,23 @@ export const ExploreGroupRenderer: React.FC = React.m {displaySummary} )} -
-
-
- {allItems.map((item, idx) => ( - - ))} -
+ +
+ {allItems.map((item, idx) => ( + + ))}
-
+
); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index 6b101383e..86cbc0ecb 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -95,16 +95,14 @@ opacity: 0.8; } - // Animated content wrapper using the CSS Grid row technique. - // grid-template-rows: 0fr → 1fr smoothly animates height without needing - // to know the actual pixel height. .explore-region__content-wrapper { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: + height 0.32s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.18s ease, + transform 0.32s cubic-bezier(0.4, 0, 0.2, 1); } - // Inner div must have min-height: 0 for the 0fr trick to work. + // Inner div must have min-height: 0 for height animation clipping to work. .explore-region__content-inner { overflow: hidden; min-height: 0; @@ -144,7 +142,7 @@ } .explore-region__content-wrapper { - grid-template-rows: 1fr; + height: auto; } } @@ -242,5 +240,18 @@ // Hidden collapsed region (not the last one). .explore-region--hidden { - display: none; + max-height: 0; + margin: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: translateY(-2px); + transition: + max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.18s ease, + transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), + margin 0.28s cubic-bezier(0.4, 0, 0.2, 1), + padding 0.28s cubic-bezier(0.4, 0, 0.2, 1); } 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 adac47cd3..352d4dc85 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -23,6 +23,7 @@ import { ForkSessionButton } from './ForkSessionButton'; import { buildModelRoundItemGroups } from './modelRoundItemGrouping'; import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; +import { SmoothHeightCollapse } from './SmoothHeightCollapse'; import './ModelRoundItem.scss'; import './SubagentItems.scss'; @@ -32,6 +33,7 @@ interface ModelRoundItemProps { round: ModelRound; turnId: string; isLastRound?: boolean; + isTurnComplete?: boolean; } function useTaskCollapsed(toolId: string): boolean { @@ -99,7 +101,7 @@ const TaskWithSubagentWrapper: React.FC = React.me }); export const ModelRoundItem = React.memo( - ({ round, turnId, isLastRound = false }) => { + ({ round, turnId, isLastRound = false, isTurnComplete = false }) => { const { t } = useTranslation('flow-chat'); const { sessionId } = useFlowChatContext(); const [copied, setCopied] = useState(false); @@ -301,7 +303,7 @@ export const ModelRoundItem = React.memo( } })} - {isLastRound && hasContent && !round.isStreaming && ( + {isTurnComplete && isLastRound && hasContent && !round.isStreaming && (
@@ -330,7 +332,9 @@ export const ModelRoundItem = React.memo( // In complete state, compare items array reference to detect tool state changes. return ( prev.round.id === next.round.id && - prev.round.items === next.round.items + prev.round.items === next.round.items && + prev.isLastRound === next.isLastRound && + prev.isTurnComplete === next.isTurnComplete ); } ); @@ -438,21 +442,23 @@ const SubagentItemsContainer = React.memo(({ return (
-
- {items.map((item, idx) => ( - - ))} -
+ +
+ {items.map((item, idx) => ( + + ))} +
+
); }); diff --git a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss new file mode 100644 index 000000000..d0d521f67 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss @@ -0,0 +1,40 @@ +.smooth-height-collapse { + height: 0; + overflow: hidden; + opacity: 0; + transform: translateY(-2px); + transition: + height var(--smooth-height-collapse-duration, 260ms) cubic-bezier(0.4, 0, 0.2, 1), + opacity 180ms ease, + transform var(--smooth-height-collapse-duration, 260ms) cubic-bezier(0.4, 0, 0.2, 1); + will-change: height, opacity, transform; +} + +.smooth-height-collapse--opening, +.smooth-height-collapse--open { + opacity: 1; + transform: translateY(0); +} + +.smooth-height-collapse--open { + height: auto; + overflow: visible; + will-change: auto; +} + +.smooth-height-collapse--closing { + opacity: 0; + transform: translateY(-2px); +} + +.smooth-height-collapse__inner { + min-height: 0; +} + +@media (prefers-reduced-motion: reduce) { + .smooth-height-collapse { + transition: none; + transform: none; + will-change: auto; + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx new file mode 100644 index 000000000..c5f282466 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx @@ -0,0 +1,98 @@ +import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react'; + +interface SmoothHeightCollapseProps { + isOpen: boolean; + children?: ReactNode; + className?: string; + innerClassName?: string; + durationMs?: number; +} + +type CollapsePhase = 'open' | 'opening' | 'closed' | 'closing'; + +export const SmoothHeightCollapse: React.FC = ({ + isOpen, + children, + className = '', + innerClassName = '', + durationMs = 260, +}) => { + const innerRef = useRef(null); + const [phase, setPhase] = useState(() => (isOpen ? 'open' : 'closed')); + const [height, setHeight] = useState(() => (isOpen ? 'auto' : '0px')); + const shouldRender = isOpen || phase !== 'closed'; + + useLayoutEffect(() => { + const inner = innerRef.current; + if (!inner) { + return; + } + + let frameId = 0; + let timeoutId = 0; + + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + if (reduceMotion) { + setPhase(isOpen ? 'open' : 'closed'); + setHeight(isOpen ? 'auto' : '0px'); + return; + } + + if (isOpen) { + setPhase('opening'); + setHeight('0px'); + frameId = window.requestAnimationFrame(() => { + setHeight(`${inner.scrollHeight}px`); + }); + timeoutId = window.setTimeout(() => { + setPhase('open'); + setHeight('auto'); + }, durationMs); + } else { + const startHeight = inner.getBoundingClientRect().height; + setPhase('closing'); + setHeight(`${startHeight}px`); + frameId = window.requestAnimationFrame(() => { + setHeight('0px'); + }); + timeoutId = window.setTimeout(() => { + setPhase('closed'); + }, durationMs); + } + + return () => { + window.cancelAnimationFrame(frameId); + window.clearTimeout(timeoutId); + }; + }, [durationMs, isOpen]); + + useLayoutEffect(() => { + const inner = innerRef.current; + if (!inner || phase !== 'open') { + return; + } + + const observer = new ResizeObserver(() => { + setHeight('auto'); + }); + observer.observe(inner); + return () => observer.disconnect(); + }, [phase, children]); + + return ( +
+ {shouldRender && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index c6493f3cd..6151261d1 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -10,13 +10,21 @@ } .subagent-items-wrapper--collapsed { - display: none; + pointer-events: none; } .subagent-items-wrapper--expanded { display: block; } +.subagent-items-collapse { + overflow: hidden; +} + +.subagent-items-collapse.smooth-height-collapse--open { + overflow: visible; +} + // Subagent container for items under the same parent task. .subagent-items-container { // Match header right padding (10px). Left padding aligns subagent content @@ -94,10 +102,25 @@ display: block; &.subagent-item--collapsed { - display: none; + max-height: 0; + opacity: 0; + overflow: hidden; + transform: translateY(-2px); + transition: + max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.18s ease, + transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; } &.subagent-item--expanded { - display: block; + max-height: 520px; + opacity: 1; + overflow: visible; + transform: translateY(0); + transition: + max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.18s ease, + transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); } } diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index 4439b2bce..a8e977820 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -48,6 +48,7 @@ export const VirtualItemRenderer = React.memo( round={item.data} turnId={item.turnId} isLastRound={item.isLastRound} + isTurnComplete={item.isTurnComplete} /> ); 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 403d9149e..bd85c452c 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -1194,6 +1194,7 @@ export const VirtualMessageList = forwardRef((_, ref) => const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { followOutputControllerRef.current.handleUserScrollIntent(); + releaseAnchorLock('wheel-up'); } }; @@ -1211,6 +1212,7 @@ export const VirtualMessageList = forwardRef((_, ref) => if (currentY - startY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { touchScrollIntentStartYRef.current = currentY; followOutputControllerRef.current.handleUserScrollIntent(); + releaseAnchorLock('touch-scroll-up'); } }; @@ -1224,6 +1226,7 @@ export const VirtualMessageList = forwardRef((_, ref) => } followOutputControllerRef.current.handleUserScrollIntent(); + releaseAnchorLock('keyboard-scroll-up'); }; const handlePointerDown = (event: PointerEvent) => { @@ -1237,6 +1240,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scrollbarPointerInteractionActiveRef.current = true; followOutputControllerRef.current.handleUserScrollIntent(); + releaseAnchorLock('scrollbar-pointer-down'); }; const handlePointerMove = (event: PointerEvent) => { @@ -1250,6 +1254,7 @@ export const VirtualMessageList = forwardRef((_, ref) => } followOutputControllerRef.current.handleUserScrollIntent(); + releaseAnchorLock('scrollbar-pointer-move'); }; const endScrollbarPointerInteraction = () => { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 20d946dc6..044af7f35 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -11,6 +11,7 @@ const PROGRAMMATIC_SCROLL_GUARD_MS = 160; const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 24; const USER_SCROLL_DIRECTION_EPSILON_PX = 0.5; const USER_SCROLL_INTENT_WINDOW_MS = 450; +const USER_SCROLL_INTENT_PROGRAMMATIC_GRACE_MS = 80; export type FollowOutputEnterReason = 'jump-to-latest' | 'auto-follow'; export type FollowOutputExitReason = @@ -275,10 +276,22 @@ export function useFlowChatFollowOutput({ const now = performance.now(); if (now <= programmaticScrollUntilMsRef.current) { - return; + const scroller = scrollerRef.current; + const alreadyAwayFromBottom = scroller + ? getDistanceFromBottom(scroller) > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX + : false; + + if (!alreadyAwayFromBottom) { + return; + } + + programmaticScrollUntilMsRef.current = Math.min( + programmaticScrollUntilMsRef.current, + now + USER_SCROLL_INTENT_PROGRAMMATIC_GRACE_MS, + ); } explicitUserScrollIntentUntilMsRef.current = now + USER_SCROLL_INTENT_WINDOW_MS; - }, []); + }, [scrollerRef]); const scheduleFollowToLatest = useCallback((_reason: string) => { if ( @@ -338,10 +351,6 @@ export function useFlowChatFollowOutput({ return; } - if (shouldSuspendAutoFollow?.() === true) { - return; - } - const upwardDelta = previousScrollTop - currentScrollTop; if (upwardDelta > USER_SCROLL_DIRECTION_EPSILON_PX) { const now = performance.now(); @@ -357,6 +366,14 @@ export function useFlowChatFollowOutput({ return; } + if (shouldSuspendAutoFollow?.() === true) { + if (isFollowingOutputRef.current && hasRecentExplicitUserIntent) { + exitFollowOutput('user-scroll-up'); + } + explicitUserScrollIntentUntilMsRef.current = 0; + return; + } + explicitUserScrollIntentUntilMsRef.current = 0; if (!isFollowingOutputRef.current) { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 0a4886daa..7aa0d07bd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -5,6 +5,7 @@ import { handleDialogTurnComplete, handleSessionStateChanged, insertSteeringItemIfAbsent, + isAppWindowFocused, shouldProcessEvent, } from './EventHandlerModule'; import { stateMachineManager } from '../../state-machine'; @@ -33,6 +34,12 @@ vi.mock('../../../shared/notification-system/services/NotificationService', () = }, })); +describe('isAppWindowFocused', () => { + it('returns true when no document is available', () => { + expect(isAppWindowFocused()).toBe(true); + }); +}); + describe('normalizeSubagentParentInfo', () => { it('normalizes snake_case subagent parent metadata from backend events', () => { expect( diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 51d9cd2ed..83e6e458b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -96,6 +96,19 @@ function isStreamingExecutionState(state: SessionExecutionState): boolean { return state === SessionExecutionState.PROCESSING || state === SessionExecutionState.FINISHING; } +export function isAppWindowFocused(): boolean { + if (typeof document === 'undefined') { + return true; + } + + return document.visibilityState === 'visible' && document.hasFocus(); +} + +function shouldMarkUnreadCompletion(sessionId: string): boolean { + const activeSessionId = FlowChatStore.getInstance().getState().activeSessionId; + return sessionId !== activeSessionId || !isAppWindowFocused(); +} + function logDroppedDataEvent( eventName: string, sessionId: string, @@ -718,9 +731,7 @@ function finalizeTurnCompletionState( log.warn('Failed to save dialog turn (non-critical)', { sessionId, turnId, error }); }); - // Mark unread completion for non-active sessions - const activeSessionId = store.getState().activeSessionId; - if (sessionId !== activeSessionId) { + if (shouldMarkUnreadCompletion(sessionId)) { const pending = context.pendingTurnCompletions.get(sessionId); const isPartialRecovery = !!pending?.partialRecoveryReason; // Partial recovery after retry failure is treated as an error state (red dot) @@ -2143,9 +2154,7 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { notificationService.error(formatted.message, options); } - // Mark unread error completion for non-active sessions - const activeSessionIdForError = store.getState().activeSessionId; - if (sessionId !== activeSessionIdForError) { + if (shouldMarkUnreadCompletion(sessionId)) { context.flowChatStore.markSessionUnreadCompletion(sessionId, 'error'); } } @@ -2251,9 +2260,7 @@ function handleDialogTurnCancelled( }); } - // Mark unread completion for non-active sessions (skip if user explicitly cancelled) - const activeSessionIdForCancelled = store.getState().activeSessionId; - if (sessionId !== activeSessionIdForCancelled && !context.userCancelledSessionIds.has(sessionId)) { + if (shouldMarkUnreadCompletion(sessionId) && !context.userCancelledSessionIds.has(sessionId)) { context.flowChatStore.markSessionUnreadCompletion(sessionId, 'completed'); } context.userCancelledSessionIds.delete(sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 9bd185736..fa80df051 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -175,7 +175,10 @@ function applyParamsPartial( } const prevBuffer = existingToolItem._paramsBuffer || ''; - const newBuffer = prevBuffer + (toolEvent.params || ''); + const isWriteTool = isWriteLikeToolName(toolEvent.tool_name); + const incomingParams = toolEvent.params || ''; + const isWriteFullParamsSnapshot = isWriteTool && incomingParams.trimStart().startsWith('{'); + const newBuffer = isWriteFullParamsSnapshot ? incomingParams : prevBuffer + incomingParams; let parsedParams: Record = {}; try { @@ -183,7 +186,6 @@ function applyParamsPartial( } catch { } - const isWriteTool = isWriteLikeToolName(toolEvent.tool_name); const isEditTool = ['edit', 'search_replace', 'Edit'].includes(toolEvent.tool_name); const hasContentField = parsedParams && ('content' in parsedParams || 'contents' in parsedParams); const hasNewString = parsedParams && 'new_string' in parsedParams; diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 00ca09590..bd4fe7ef3 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -46,7 +46,7 @@ export type VirtualItem = steeringId: string; steeringStatus: FlowUserSteeringItem['status']; } - | { type: 'model-round'; data: ModelRound; turnId: string; isLastRound: boolean } + | { type: 'model-round'; data: ModelRound; turnId: string; isLastRound: boolean; isTurnComplete: boolean } | { type: 'explore-group'; data: ExploreGroupData; turnId: string } | { type: 'image-analyzing'; turnId: string }; @@ -222,7 +222,12 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); }); - const flushRoundEntries = (rounds: ModelRound[]) => { + const isTurnComplete = turn.status === 'completed' || turn.status === 'cancelled' || turn.status === 'error'; + + const flushRoundEntries = ( + rounds: ModelRound[], + options: { collapseTrailingExploreGroup: boolean }, + ) => { if (rounds.length === 0) return; interface TempExploreGroup { @@ -268,7 +273,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { } }); - if (currentGroup) { + if (currentGroup && options.collapseTrailingExploreGroup) { tempGroups.push(currentGroup); } @@ -309,6 +314,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { data: round, turnId: turn.id, isLastRound, + isTurnComplete, }); roundIndex++; } @@ -323,7 +329,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { return; } - flushRoundEntries(pendingRounds); + flushRoundEntries(pendingRounds, { collapseTrailingExploreGroup: true }); pendingRounds = []; items.push({ @@ -335,7 +341,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); }); - flushRoundEntries(pendingRounds); + flushRoundEntries(pendingRounds, { collapseTrailingExploreGroup: isTurnComplete }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss index f249be826..466bac487 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss @@ -1,3 +1,5 @@ +@use '../components/modern/SmoothHeightCollapse.scss'; + /** * Common tool card styles * Provides unified card appearance and interaction effects @@ -32,8 +34,8 @@ } /* Divider when body below header: success expanded panel or failed error panel */ - &:has(> .base-tool-card-expanded), - &:has(> .base-tool-card-error) { + &:has(> .base-tool-card-expanded-collapse:not(.smooth-height-collapse--closed)), + &:has(> .base-tool-card-error-collapse:not(.smooth-height-collapse--closed)) { .base-tool-card-header::after { content: ''; position: absolute; @@ -192,7 +194,8 @@ padding: var(--tool-card-expanded-pad-y) var(--tool-card-expanded-pad-x); background: transparent; position: relative; - animation: expandDown 0.2s ease-out; + opacity: 1; + transform: none; box-sizing: border-box; } diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx index 5d3d0068a..12d176721 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx @@ -3,6 +3,7 @@ * Provides unified card styles and interaction logic */ import React, { ReactNode } from 'react'; +import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; import { ToolCardHeaderLayoutContext, useToolCardHeaderLayout, @@ -105,17 +106,17 @@ export const BaseToolCard: React.FC = ({
- {hasExpandedContent && ( +
{expandedContent}
- )} +
- {isFailed && errorContent && ( +
{errorContent}
- )} +
); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss index 146686a10..5cab95498 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss @@ -303,7 +303,8 @@ background: var(--color-bg-primary); border: 1px solid var(--border-base); border-radius: 6px; - animation: expandDown 0.2s ease-out; + opacity: 1; + transform: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.02); diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx index 7f9566c66..8426dfc04 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx @@ -10,6 +10,7 @@ import React, { ReactNode } from 'react'; import { BaseToolCard, type BaseToolCardProps } from './BaseToolCard'; +import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; import { ToolCardIconSlot } from './ToolCardIconSlot'; import { ToolCardStatusIcon } from './ToolCardStatusIcon'; import type { ToolCardHeaderAffordanceKind } from './ToolCardHeaderLayoutContext'; @@ -80,11 +81,11 @@ export const CompactToolCard: React.FC = ({ {header} - {isExpanded && expandedContent && ( +
{expandedContent}
- )} +
); }; 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 fc5df56bc..9ee81bc48 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -166,6 +166,16 @@ export const FileOperationToolCard: React.FC = ({ const oldStringContent = getOldString(); const newStringContent = getNewString(); const contentPreview = getContent(); + const writeContentCharCount = toolItem.toolName === 'Write' ? contentPreview.length : 0; + const writeContentStatusText = useMemo(() => { + if (toolItem.toolName !== 'Write' || writeContentCharCount <= 0) return null; + + const formattedCount = writeContentCharCount.toLocaleString(); + if (status === 'completed') { + return `${formattedCount} chars written`; + } + return `${formattedCount} chars received`; + }, [status, toolItem.toolName, writeContentCharCount]); const isFailed = status === 'error' || (toolResult && 'success' in toolResult && !toolResult.success); const showConfirmationActions = Boolean( @@ -852,7 +862,12 @@ export const FileOperationToolCard: React.FC = ({ } extra={ - {isParamsStreaming && (status === 'preparing' || status === 'streaming') && ( + {writeContentStatusText && ( + + {writeContentStatusText} + + )} + {isParamsStreaming && (status === 'preparing' || status === 'streaming') && !writeContentStatusText && ( {currentFilePath ? t('toolCards.file.receivingParams') : t('toolCards.file.analyzing')}