diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index 8547dcfb..47312af8 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -45,9 +45,28 @@ const BANNED_COMMANDS: &[&str] = &[ "safari", ]; -fn truncate_string_by_chars(s: &str, max_chars: usize) -> String { +fn truncate_output_preserving_tail(s: &str, max_chars: usize) -> String { let chars: Vec = s.chars().collect(); - chars[..max_chars].into_iter().collect() + if chars.len() <= max_chars { + return s.to_string(); + } + + let tail_bias = max_chars.saturating_mul(4) / 5; + let separator = "\n... [truncated, middle omitted, tail preserved] ...\n"; + let separator_len = separator.chars().count(); + + if separator_len >= max_chars { + return chars[chars.len() - max_chars..].iter().collect(); + } + + let content_budget = max_chars - separator_len; + let tail_len = tail_bias.min(content_budget); + let head_len = content_budget.saturating_sub(tail_len); + + let head: String = chars[..head_len].iter().collect(); + let tail: String = chars[chars.len() - tail_len..].iter().collect(); + + format!("{head}{separator}{tail}") } /// Result of shell resolution for bash tool @@ -142,7 +161,7 @@ impl BashTool { let cleaned_output = strip_ansi(output_text); let output_len = cleaned_output.chars().count(); if output_len > MAX_OUTPUT_LENGTH { - let truncated = truncate_string_by_chars(&cleaned_output, MAX_OUTPUT_LENGTH); + let truncated = truncate_output_preserving_tail(&cleaned_output, MAX_OUTPUT_LENGTH); result_string.push_str(&format!( "{}", truncated @@ -226,7 +245,7 @@ Usage notes: - DO NOT use multiline commands or HEREDOC syntax (e.g., <` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. - The output may include the command echo and/or the shell prompt (e.g., `PS C:\path>`). Do not treat these as part of the command's actual result. @@ -916,3 +935,34 @@ impl BashTool { }]) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_output_preserving_tail_keeps_end_of_output() { + let input = "BEGIN-".to_string() + &"x".repeat(120) + "-IMPORTANT-END"; + + let truncated = truncate_output_preserving_tail(&input, 80); + + assert!(truncated.contains("tail preserved")); + assert!(truncated.ends_with("IMPORTANT-END")); + assert!(!truncated.contains("BEGIN-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); + assert!(truncated.chars().count() <= 80); + } + + #[test] + fn render_result_marks_truncated_output_and_keeps_tail() { + let tool = BashTool::new(); + let long_output = + "prefix\n".to_string() + &"y".repeat(MAX_OUTPUT_LENGTH + 100) + "\nfinal-error"; + + let rendered = tool.render_result("session-1", &long_output, false, false, 1); + + assert!(rendered.contains("")); + assert!(rendered.contains("tail preserved")); + assert!(rendered.contains("final-error")); + assert!(rendered.contains("1")); + } +} 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 a6096359..87edc89b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -431,8 +431,12 @@ export const VirtualMessageList = forwardRef((_, ref) => const collapseIntent = pendingCollapseIntentRef.current; const now = performance.now(); const hasValidCollapseIntent = collapseIntent.active && collapseIntent.expiresAtMs >= now; - const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - currentTotalCompensation); - const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - effectiveDistanceFromBottom); + // For unsignaled shrinks, the visible gap to the bottom is what matters. + // Existing synthetic footer compensation may be stale from an earlier + // protected collapse, and subtracting it here makes the list think the + // viewport is still pinned near the bottom when the user has already moved + // away. That misclassification re-arms anchor restore and causes jitter. + const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - distanceFromBottom); const cumulativeShrinkPx = hasValidCollapseIntent ? collapseIntent.cumulativeShrinkPx + shrinkAmount : 0; @@ -452,6 +456,16 @@ export const VirtualMessageList = forwardRef((_, ref) => cumulativeShrinkPx, }; } + + if (!hasValidCollapseIntent && fallbackAdditionalCompensation <= COMPENSATION_EPSILON_PX) { + // If the user is already far enough from the bottom, this shrink does not + // need protection. Reusing stale bottom compensation here makes the + // scroll listener restore an older anchor during upward scroll and causes + // the visible "wall hit" jitter. + previousScrollTopRef.current = currentScrollTop; + return; + } + const nextReservationState: BottomReservationState = { ...bottomReservationStateRef.current, collapse: { @@ -1041,8 +1055,20 @@ export const VirtualMessageList = forwardRef((_, ref) => mutationObserverRef.current?.disconnect(); let mutationPending = false; - mutationObserverRef.current = new MutationObserver(() => { + mutationObserverRef.current = new MutationObserver((mutations) => { if (mutationPending) return; + if (!isProcessing) { + return; + } + const characterDataMutations = mutations.filter(mutation => mutation.type === 'characterData'); + const attributesMutations = mutations.filter(mutation => mutation.type === 'attributes'); + const hasSemanticMutation = ( + characterDataMutations.length > 0 || + attributesMutations.length > 0 + ); + if (!hasSemanticMutation) { + return; + } mutationPending = true; requestAnimationFrame(() => { mutationPending = false; @@ -1331,6 +1357,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleVisibleTurnMeasure, scrollerElement, shouldSuspendAutoFollow, + isProcessing, updateBottomReservationState, ]); 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 76dd4a5d..7a873359 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -206,6 +206,8 @@ export const FileOperationToolCard: React.FC = ({ (async () => { try { + // TODO: Persist diff stats with the tool result so historical cards can + // read a static value instead of recomputing on every remount. const { snapshotAPI } = await import('../../infrastructure/api'); const summary = await snapshotAPI.getOperationSummary(sessionId, toolCall.id); if (cancelled) return;