diff --git a/src/apps/desktop/capabilities/browser-webview.json b/src/apps/desktop/capabilities/browser-webview.json index a1589b67..5fcbaf82 100644 --- a/src/apps/desktop/capabilities/browser-webview.json +++ b/src/apps/desktop/capabilities/browser-webview.json @@ -5,7 +5,7 @@ "webviews": ["embedded-browser-*"], "local": true, "remote": { - "urls": ["https://*", "http://*"] + "urls": ["https://*", "https://*:*", "http://*", "http://*:*"] }, "permissions": [ "core:event:allow-emit" diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index 73613e47..93884c01 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -1,4 +1,9 @@ //! Browser API — commands for the embedded browser feature. +//! +//! Browser webviews are created on the Rust side so that we can attach an +//! `on_page_load` handler that safely catches panics from the upstream wry +//! `url_from_webview` bug (WKWebView.URL() returning nil). +//! See: use serde::Deserialize; use tauri::Manager; @@ -31,6 +36,10 @@ pub struct WebviewLabelRequest { } /// Return the current URL of a browser webview. +/// +/// Uses `catch_unwind` to guard against a known wry bug where +/// `WKWebView::URL()` returns nil (e.g. after navigating to an invalid +/// address), causing an `unwrap()` panic inside `url_from_webview`. #[tauri::command] pub async fn browser_get_url( app: tauri::AppHandle, @@ -40,6 +49,12 @@ pub async fn browser_get_url( .get_webview(&request.label) .ok_or_else(|| format!("Webview not found: {}", request.label))?; - let url = webview.url().map_err(|e| format!("url failed: {e}"))?; - Ok(url.to_string()) + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webview.url())); + + match result { + Ok(Ok(url)) => Ok(url.to_string()), + Ok(Err(e)) => Err(format!("url failed: {e}")), + Err(_) => Err("url unavailable (webview URL is nil)".to_string()), + } } + diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 5bc5588e..e9bfc043 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -805,6 +805,18 @@ fn setup_panic_hook() { log::error!("Application panic at {}: {}", location, message); + // Known wry bug: WKWebView.URL() returns nil after navigating to an + // invalid address, causing url_from_webview to panic on unwrap(). + // This is non-fatal — the webview is still alive — so we log and + // continue instead of killing the process. + // See: https://github.com/tauri-apps/wry/pull/1554 + if location.contains("wry") && location.contains("wkwebview") { + log::warn!( + "Suppressed non-fatal wry/wkwebview panic, application continues" + ); + return; + } + if message.contains("WSAStartup") || message.contains("10093") || message.contains("hyper") { log::error!("Network-related crash detected, possible solutions:"); 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 fb31a74d..7b37fa3a 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -63,6 +63,22 @@ impl BashTool { Self } + /// Build environment variables that suppress interactive behaviors + /// (pagers, editors, prompts) so agent-driven commands never block. + pub fn noninteractive_env() -> std::collections::HashMap { + let mut env = std::collections::HashMap::new(); + env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); + // Disable git pager globally (prevents `less`/`more` from blocking) + env.insert("GIT_PAGER".to_string(), "cat".to_string()); + // Disable generic pager for other tools (man, etc.) + env.insert("PAGER".to_string(), "cat".to_string()); + // Prevent git from prompting for credentials or SSH passphrases + env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()); + // Ensure git never opens an interactive editor (e.g. for commit messages) + env.insert("GIT_EDITOR".to_string(), "true".to_string()); + env + } + /// Resolve shell configuration for bash tool. /// If configured shell doesn't support integration, falls back to system default. async fn resolve_shell() -> ResolvedShell { @@ -452,11 +468,7 @@ Usage notes: &chat_session_id[..8.min(chat_session_id.len())] )), shell_type: shell_type.clone(), - env: Some({ - let mut env = std::collections::HashMap::new(); - env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); - env - }), + env: Some(Self::noninteractive_env()), ..Default::default() }, ) @@ -670,11 +682,7 @@ impl BashTool { session_id: None, session_name: None, shell_type, - env: Some({ - let mut env = std::collections::HashMap::new(); - env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); - env - }), + env: Some(Self::noninteractive_env()), ..Default::default() }, ) diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 9294a003..d5eafe28 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -1687,11 +1687,9 @@ impl RemoteExecutionDispatcher { working_directory: workspace, session_id: Some(sid.clone()), session_name: Some(name), - env: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); - m - }), + env: Some( + crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), + ), ..Default::default() }, ) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 9785dd99..de09603a 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -518,11 +518,36 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── -const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ thinking, streaming }) => { +const ThinkingBlock: React.FC<{ + thinking: string; + streaming?: boolean; + isLastItem?: boolean; +}> = ({ thinking, streaming, isLastItem = false }) => { const { t } = useI18n(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(!!streaming); + const userToggledRef = useRef(false); const wrapperRef = useRef(null); const [scrollState, setScrollState] = useState({ atTop: true, atBottom: true }); + const displayedThinking = useTypewriter(thinking, !!streaming); + + useEffect(() => { + if (userToggledRef.current) return; + if (streaming) { + setOpen(true); + } else if (!isLastItem) { + setOpen(false); + } + }, [streaming, isLastItem]); + + useEffect(() => { + if (!streaming || !open) return; + const el = wrapperRef.current; + if (!el) return; + const gap = el.scrollHeight - el.scrollTop - el.clientHeight; + if (gap < 80) { + el.scrollTop = el.scrollHeight; + } + }, [displayedThinking, streaming, open]); const handleScroll = useCallback(() => { const el = wrapperRef.current; @@ -533,6 +558,11 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th }); }, []); + const handleToggle = useCallback(() => { + userToggledRef.current = true; + setOpen(o => !o); + }, []); + if (!thinking && !streaming) return null; const charCount = thinking.length; @@ -542,7 +572,7 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th return (
-
)} @@ -1411,11 +1441,13 @@ function renderStandardGroups( animate?: boolean, onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise, onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>, + isActiveTurn?: boolean, ) { return groups.map((g, gi) => { if (g.type === 'thinking') { const text = g.entries.map(e => e.content || '').join('\n\n'); - return ; + const isLast = isActiveTurn && gi === groups.length - 1; + return ; } if (g.type === 'tool') { const rendered: React.ReactNode[] = []; @@ -1533,7 +1565,7 @@ function renderActiveTurnItems( }; if (askEntries.length === 0) { - return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true, onFileDownload, onGetFileInfo); + return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true, onFileDownload, onGetFileInfo, true); } const beforeAskItems: ChatMessageItem[] = []; @@ -1551,9 +1583,9 @@ function renderActiveTurnItems( return ( <> - {renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true, onFileDownload, onGetFileInfo)} + {renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true, onFileDownload, onGetFileInfo, true)} {renderQuestionEntries(askEntries, 'active', onAnswer)} - {renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true, onFileDownload, onGetFileInfo)} + {renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true, onFileDownload, onGetFileInfo, true)} ); } @@ -2532,7 +2564,8 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, {!hasRunningSubagent && (turn.thinking || turnIsActive) && ( )} {taskTools.map(t => ( diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index eb82cc2f..da400a98 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -95,9 +95,8 @@ function SessionTypeIcon({ agentType }: { agentType: string }) { /* Mode Selection Icons */ const ProModeIcon = () => ( - - - + + ); @@ -562,7 +561,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS )} {/* Session Creation Options */} -
+
{t('sessions.launch')}
@@ -632,7 +631,7 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS
{/* Session History */} -
+
{t('sessions.recent')}
diff --git a/src/mobile-web/src/styles/components/sessions.scss b/src/mobile-web/src/styles/components/sessions.scss index 07061977..339b7e15 100644 --- a/src/mobile-web/src/styles/components/sessions.scss +++ b/src/mobile-web/src/styles/components/sessions.scss @@ -264,6 +264,10 @@ letter-spacing: 0.14em; text-transform: uppercase; color: var(--color-accent-500); + + .session-list__panel--assistant & { + color: var(--color-pink-500); + } } .session-list__section-title { diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx index 9dd32159..03f9c7b8 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx @@ -14,6 +14,7 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import { useContextStore } from '@/shared/context-system'; import type { WebElementContext } from '@/shared/types/context'; import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT, BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; +import { validateUrl, checkConnectivity } from './browserUrlCheck'; import './BrowserPanel.scss'; const log = createLogger('BrowserPanel'); @@ -242,6 +243,9 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => } try { + validateUrl(nextUrl); + await checkConnectivity(nextUrl); + if (urlPollTimerRef.current) { clearInterval(urlPollTimerRef.current); urlPollTimerRef.current = null; @@ -265,6 +269,7 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl }) => currentUrlRef.current = url; setInputValue(url); setCurrentUrl(url); + setError(null); evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {}); } }) diff --git a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx index ae271f65..41d094dc 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx @@ -4,6 +4,7 @@ import { IconButton } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { useSceneStore } from '@/app/stores/sceneStore'; import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; +import { validateUrl, checkConnectivity } from './browserUrlCheck'; import './BrowserScene.scss'; const log = createLogger('BrowserScene'); @@ -259,6 +260,9 @@ const BrowserScene: React.FC = () => { } try { + validateUrl(nextUrl); + await checkConnectivity(nextUrl); + if (urlPollTimerRef.current) { clearInterval(urlPollTimerRef.current); urlPollTimerRef.current = null; @@ -282,6 +286,7 @@ const BrowserScene: React.FC = () => { currentUrlRef.current = url; setInputValue(url); setCurrentUrl(url); + setError(null); evalWebview(label, BLANK_TARGET_INTERCEPT_SCRIPT).catch(() => {}); } }) diff --git a/src/web-ui/src/app/scenes/browser/browserUrlCheck.ts b/src/web-ui/src/app/scenes/browser/browserUrlCheck.ts new file mode 100644 index 00000000..f67e7390 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/browserUrlCheck.ts @@ -0,0 +1,29 @@ +export function validateUrl(url: string): void { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported protocol: ${parsed.protocol}`); + } + if (!parsed.hostname) { + throw new Error('Missing hostname'); + } + } catch (e) { + throw new Error(`Invalid URL: ${url}${e instanceof Error ? ` (${e.message})` : ''}`); + } +} + +export async function checkConnectivity(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + await fetch(url, { + method: 'HEAD', + mode: 'no-cors', + signal: controller.signal, + }); + } catch { + throw new Error(`Connection failed: ${new URL(url).hostname} is not reachable`); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 918f10c1..10eee0a3 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -1,6 +1,7 @@ /** * Streaming text block component. - * Renders content directly without a typewriter delay. + * Applies a typewriter effect during streaming to smooth out + * the batched content updates from EventBatcher (~100ms). * Supports a streaming cursor indicator. */ @@ -8,6 +9,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { MarkdownRenderer } from '@/component-library'; import type { FlowTextItem } from '../types/flow-chat'; import { useFlowChatContext } from './modern/FlowChatContext'; +import { useTypewriter } from '../hooks/useTypewriter'; import './FlowTextBlock.scss'; // Idle timeout (ms) after content stops growing. @@ -32,6 +34,10 @@ export const FlowTextBlock = React.memo(({ const content = typeof textItem.content === 'string' ? textItem.content : String(textItem.content || ''); + + const isStreaming = textItem.isStreaming && + (textItem.status === 'streaming' || textItem.status === 'running'); + const displayContent = useTypewriter(content, isStreaming); // Heuristic: if content does not change for a while, streaming is done. const [isContentGrowing, setIsContentGrowing] = useState(true); @@ -39,7 +45,6 @@ export const FlowTextBlock = React.memo(({ const timeoutRef = useRef(null); useEffect(() => { - // Reset idle timer on content changes. if (content !== lastContentRef.current) { lastContentRef.current = content; setIsContentGrowing(true); @@ -60,14 +65,12 @@ export const FlowTextBlock = React.memo(({ }; }, [content]); - // Stop immediately when the item completes. useEffect(() => { if (textItem.status === 'completed' || !textItem.isStreaming) { setIsContentGrowing(false); } }, [textItem.status, textItem.isStreaming]); - // Show shimmer only while content is actively growing. const isActivelyStreaming = textItem.isStreaming && (textItem.status === 'streaming' || textItem.status === 'running') && isContentGrowing; @@ -77,7 +80,7 @@ export const FlowTextBlock = React.memo(({
{textItem.isMarkdown ? ( (({ /> ) : (
- {content} + {displayContent}
)}
); }, (prevProps, nextProps) => { - // Custom comparator: compare only key fields. const prev = prevProps.textItem; const next = nextProps.textItem; return ( diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 11f466f0..9be6936c 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -52,6 +52,7 @@ export const RichTextInput = React.forwardRef>(new Set()); const mentionStateRef = useRef({ isActive: false, query: '', startOffset: 0 }); + const isLocalChangeRef = useRef(false); // Display name without the # prefix const getContextDisplayName = (context: ContextItem): string => { @@ -216,7 +217,7 @@ export const RichTextInput = React.forwardRef { + // Strip zero-width and control characters that WebKit/WebView may inject + // (e.g. from dead-key sequences, function keys, arrow keys, etc.) + // Preserve normal whitespace: space (0x20), tab (0x09), newline (0x0A), carriage return (0x0D). + return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\u200B-\u200F\u2028\u2029\uFEFF\u2060\u00AD]/g, ''); + }; + + /** Compute the cursor's character offset within the editor. */ + const getCursorOffset = useCallback((editor: HTMLElement): number => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return -1; + const range = sel.getRangeAt(0); + if (!range.collapsed) return -1; + const preRange = document.createRange(); + preRange.selectNodeContents(editor); + preRange.setEnd(range.startContainer, range.startOffset); + return preRange.toString().length; + }, []); + + /** Restore the cursor to a character offset within the editor. */ + const setCursorOffset = useCallback((editor: HTMLElement, offset: number) => { + let remaining = offset; + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + const len = (node.textContent || '').length; + if (remaining <= len) { + const sel = window.getSelection(); + if (sel) { + sel.collapse(node, remaining); + } + return; + } + remaining -= len; + } + // Offset past all text – place cursor at end + const sel = window.getSelection(); + if (sel) { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + }, []); + const handleInput = useCallback(() => { if (isComposingRef.current) return; - + + const editor = internalRef.current; + + // Scrub any invisible characters the browser may have inserted. + // Save and restore the cursor (as a character offset) so cleaning + // never disturbs the caret position. + if (editor) { + const cursorOffset = getCursorOffset(editor); + let didClean = false; + let removedBeforeCursor = 0; + + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); + let charsSoFar = 0; + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + const original = node.textContent || ''; + const cleaned = sanitizeText(original); + if (cleaned !== original) { + // Count how many invisible chars were removed before the cursor + if (cursorOffset >= 0) { + if (cursorOffset > charsSoFar) { + const relevantSlice = original.slice(0, Math.min(cursorOffset - charsSoFar, original.length)); + removedBeforeCursor += relevantSlice.length - sanitizeText(relevantSlice).length; + } + } + node.textContent = cleaned; + didClean = true; + } + charsSoFar += original.length; + } + + if (didClean && cursorOffset >= 0) { + setCursorOffset(editor, Math.max(cursorOffset - removedBeforeCursor, 0)); + } + } + const textContent = extractTextContent(); const visibleContextIds = new Set( Array.from(internalRef.current?.querySelectorAll('[data-context-id]') ?? []) @@ -313,6 +395,7 @@ export const RichTextInput = React.forwardRef visibleContextIds.has(context.id)); + isLocalChangeRef.current = true; onChange(textContent, visibleContexts); // Ensure detection runs after DOM updates @@ -321,6 +404,21 @@ export const RichTextInput = React.forwardRef) => { + const inputEvent = e.nativeEvent as InputEvent; + const inputType = inputEvent.inputType; + + // Only act on insertText – block attempts to insert purely-invisible content. + // We intentionally avoid a blanket whitelist so that we never accidentally + // block browser-internal input types (cursor movement, spellcheck, etc.). + if (inputType === 'insertText' && inputEvent.data != null) { + const cleaned = sanitizeText(inputEvent.data); + if (cleaned.length === 0) { + e.preventDefault(); + } + } + }, []); + const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); @@ -473,8 +571,15 @@ export const RichTextInput = React.forwardRef { + if (isLocalChangeRef.current) { + isLocalChangeRef.current = false; + return; + } + const editor = internalRef.current; if (!editor) return; @@ -572,6 +677,7 @@ export const RichTextInput = React.forwardRef = ({ allItems, stats, isGroupStreaming, - isFollowedByCritical + isFollowedByCritical, + isLastGroupInTurn } = data; // Track auto-collapse once to prevent flicker. @@ -74,9 +75,12 @@ export const ExploreGroupRenderer: React.FC = ({ // Build summary text with i18n. const displaySummary = useMemo(() => { - const { readCount, searchCount } = stats; + const { readCount, searchCount, thinkingCount } = stats; const parts: string[] = []; + if (thinkingCount > 0) { + parts.push(t('exploreRegion.thinkingCount', { count: thinkingCount })); + } if (readCount > 0) { parts.push(t('exploreRegion.readFiles', { count: readCount })); } @@ -117,11 +121,12 @@ export const ExploreGroupRenderer: React.FC = ({ return (
- {allItems.map(item => ( + {allItems.map((item, idx) => ( ))}
@@ -139,11 +144,12 @@ export const ExploreGroupRenderer: React.FC = ({
- {allItems.map(item => ( + {allItems.map((item, idx) => ( ))}
@@ -160,9 +166,10 @@ export const ExploreGroupRenderer: React.FC = ({ interface ExploreItemRendererProps { item: FlowItem; turnId: string; + isLastItem?: boolean; } -const ExploreItemRenderer = React.memo(({ item }) => { +const ExploreItemRenderer = React.memo(({ item, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -203,10 +210,17 @@ const ExploreItemRenderer = React.memo(({ item }) => { /> ); - case 'thinking': + case 'thinking': { + const thinkingItem = item as FlowThinkingItem; + // Hide completed thinking inside explore groups — it adds no value + // when collapsed (the explore group summary already shows thinking count). + if (thinkingItem.status === 'completed' && !isLastItem) { + return null; + } return ( - + ); + } case 'tool': return ( 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 bffff62f..b918121b 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -254,14 +254,17 @@ export const ModelRoundItem = React.memo( className={`model-round-item model-round-item--${round.isStreaming ? 'streaming' : 'complete'}`} > {groupedItems.map((group, groupIndex) => { + const isLastGroup = groupIndex === groupedItems.length - 1; + const isLast = isLastRound && isLastGroup; switch (group.type) { case 'explore': - return group.items.map(item => ( + return group.items.map((item, itemIdx) => ( )); @@ -272,6 +275,7 @@ export const ModelRoundItem = React.memo( item={group.item} turnId={turnId} roundId={round.id} + isLastItem={isLast} /> ); @@ -471,12 +475,13 @@ const SubagentItemsContainer = React.memo(({ className="subagent-items-container" data-parent-tool-id={parentTaskToolId} > - {items.map((item) => ( + {items.map((item, idx) => ( ))}
@@ -487,7 +492,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 }>(({ item }) => { +const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundId: string; isLastItem?: boolean }>(({ item, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -530,7 +535,7 @@ const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundI case 'thinking': return ( - + ); case 'tool': @@ -557,10 +562,11 @@ interface FlowItemRendererProps { item: FlowItem; turnId: string; roundId: string; + isLastItem?: boolean; } // Do not memoize: streaming content updates frequently. -const FlowItemRenderer: React.FC = ({ item }) => { +const FlowItemRenderer: React.FC = ({ item, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -616,7 +622,7 @@ const FlowItemRenderer: React.FC = ({ item }) => { case 'thinking': return wrapContent( - + ); case 'tool': 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 7df7363f..5250674e 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -1,13 +1,11 @@ /** * Virtualized message list. * Renders a flattened DialogTurn stream (user messages + model rounds). - * Keeps the conceptual model: Session → DialogTurn → ModelRound → FlowItem. * - * Stick-to-bottom behavior: - * 1) Default stick-to-bottom; any content change (including collapse) scrolls to bottom. - * 2) Scrolling up past a threshold exits stick-to-bottom. - * 3) Returning to bottom or clicking the button re-enables stick-to-bottom. - * 4) MutationObserver watches height changes to handle collapse/expand. + * Scroll policy (simplified): + * - While the agent is processing → always auto-scroll to bottom (smooth). + * - While idle → user scrolls freely; no auto-scroll interference. + * - "Scroll to latest" bar appears when not at bottom AND not processing. */ import React, { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; @@ -21,18 +19,12 @@ import { useVirtualItems, useActiveSession, useModernFlowChatStore } from '../.. import { useChatInputState } from '../../store/chatInputStateStore'; import './VirtualMessageList.scss'; -// Scroll-up threshold for entering history view. -const SCROLL_UP_THRESHOLD = 50; - /** * Methods exposed by VirtualMessageList. */ export interface VirtualMessageListRef { - /** Scroll to a specific turn (1-based). */ scrollToTurn: (turnIndex: number) => void; - /** Scroll to a specific virtual item index (0-based). */ scrollToIndex: (index: number) => void; - /** Scroll to bottom. */ scrollToBottom: () => void; } @@ -40,237 +32,50 @@ export const VirtualMessageList = forwardRef((_, ref) => const virtuosoRef = useRef(null); const virtualItems = useVirtualItems(); const activeSession = useActiveSession(); - // Stick-to-bottom state: true auto-follows new content and height changes. - // false means the user is viewing history and auto-scroll is disabled. - const [stickToBottom, setStickToBottom] = useState(true); - const stickToBottomRef = useRef(true); - // When we programmatically navigate (e.g. jump to a marker), Virtuoso can briefly - // report "at bottom" due to thresholds, which would auto-reenable stick-to-bottom. - // Suppress that behavior for a short window after navigation. - const suppressAutoEnableStickToBottomRef = useRef(false); - const suppressAutoEnableTimerRef = useRef | null>(null); - - // Sync ref with state. - useEffect(() => { - stickToBottomRef.current = stickToBottom; - }, [stickToBottom]); - useEffect(() => { - return () => { - if (suppressAutoEnableTimerRef.current) { - clearTimeout(suppressAutoEnableTimerRef.current); - } - }; - }, []); - - const beginNavigation = useCallback(() => { - suppressAutoEnableStickToBottomRef.current = true; - if (suppressAutoEnableTimerRef.current) { - clearTimeout(suppressAutoEnableTimerRef.current); - } - // Long enough to cover the scroll + DOM focus + atBottom recalculation. - suppressAutoEnableTimerRef.current = setTimeout(() => { - suppressAutoEnableStickToBottomRef.current = false; - }, 900); - }, []); - - // Track whether we're at bottom (for button visibility). const [isAtBottom, setIsAtBottom] = useState(true); - - // Last scroll position to detect direction. - const lastScrollTopRef = useRef(0); - - // Initialization flag to avoid false positives on first render. - const isInitializedRef = useRef(false); - - // Scroller ref for observing height changes. + const scrollerElementRef = useRef(null); - - // Track user scrolling to prevent auto-scroll during interaction. - const isUserScrollingRef = useRef(false); - const userScrollingTimeoutRef = useRef(null); - - // Previous virtualItems length to detect new tail items and post-layout scrolling. - const prevLengthRef = useRef(0); - // Set true during programmatic scroll-to-bottom to avoid mis-detecting user scroll. - const programmaticScrollToBottomRef = useRef(false); - // Visible item range (updated by rangeChanged) for layout bookkeeping. - const lastRangeRef = useRef({ startIndex: 0, endIndex: 0 }); - // When list length increases, delay scroll-to-bottom and suppress followOutput to avoid jitter. - const suppressFollowOutputRef = useRef(false); - - // Delay initialization until layout stabilizes. - useEffect(() => { - const timer = setTimeout(() => { - isInitializedRef.current = true; - }, 100); - return () => clearTimeout(timer); - }, []); - - useEffect(() => { - return () => { - if (userScrollingTimeoutRef.current) { - clearTimeout(userScrollingTimeoutRef.current); - } - }; - }, []); - - // Treat tool card toggle as user interaction to prevent auto-scroll. - useEffect(() => { - const handleToolCardToggle = () => { - isUserScrollingRef.current = true; - - if (userScrollingTimeoutRef.current) { - clearTimeout(userScrollingTimeoutRef.current); - } - - // Clear after 300ms (slightly longer than scroll) to cover animation. - userScrollingTimeoutRef.current = setTimeout(() => { - isUserScrollingRef.current = false; - }, 300); - }; - - window.addEventListener('tool-card-toggle', handleToolCardToggle); - return () => { - window.removeEventListener('tool-card-toggle', handleToolCardToggle); - }; - }, []); - - // ChatInput expansion state. + const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); - const handleScrollerRef = useCallback((ref: HTMLElement | Window | null) => { - if (ref && ref instanceof HTMLElement) { - scrollerElementRef.current = ref; + const activeSessionState = useActiveSessionState(); + const isProcessing = activeSessionState.isProcessing; + const processingPhase = activeSessionState.processingPhase; + + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { + if (el && el instanceof HTMLElement) { + scrollerElementRef.current = el; } }, []); - - // Observe scroller height changes; auto-scroll in stick-to-bottom mode. - // Performance: throttle MutationObserver callbacks. - useEffect(() => { - const scroller = scrollerElementRef.current; - if (!scroller) return; - - let lastScrollHeight = scroller.scrollHeight; - let rafId: number | null = null; - let throttleTimer: NodeJS.Timeout | null = null; - let pendingCheck = false; - - // Throttle interval: at most once every 50ms during streaming. - const THROTTLE_MS = 50; - - const checkAndScroll = () => { - pendingCheck = false; - - // Skip auto-scroll while the user is scrolling. - if (isUserScrollingRef.current) { - // Update lastScrollHeight without scrolling. - lastScrollHeight = scroller.scrollHeight; - return; - } - - // When delaying scroll-to-bottom on new items, skip followOutput here to avoid jitter. - if (suppressFollowOutputRef.current) { - lastScrollHeight = scroller.scrollHeight; - return; - } - - const newScrollHeight = scroller.scrollHeight; - - // Height changed. - if (newScrollHeight !== lastScrollHeight) { - const heightDecreased = newScrollHeight < lastScrollHeight; - lastScrollHeight = newScrollHeight; - - // In stick-to-bottom mode, scroll to bottom. - // When height decreases (collapse), scroll immediately. - if (stickToBottomRef.current && virtuosoRef.current) { - // Use requestAnimationFrame to avoid animation conflicts. - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { - if (stickToBottomRef.current && virtuosoRef.current && !isUserScrollingRef.current) { - virtuosoRef.current.scrollTo({ - top: 999999999, - behavior: heightDecreased ? 'auto' : 'auto' - }); - } - }); - } - } - }; - - // Throttled mutation handler to reduce high-frequency callbacks. - const throttledCheckAndScroll = () => { - if (pendingCheck) return; // Pending check already scheduled. - - pendingCheck = true; - - if (throttleTimer) { - return; - } - - checkAndScroll(); - - throttleTimer = setTimeout(() => { - throttleTimer = null; - if (pendingCheck) { - checkAndScroll(); - } - }, THROTTLE_MS); - }; - - // Observe DOM changes via MutationObserver. - // Optimization: listen to childList only; streaming updates trigger childList changes. - const observer = new MutationObserver(throttledCheckAndScroll); - - observer.observe(scroller, { - childList: true, - subtree: true, - // Remove attributes and characterData to reduce callbacks. - // attributes: true, - // characterData: true - }); - - checkAndScroll(); - - return () => { - observer.disconnect(); - if (rafId) cancelAnimationFrame(rafId); - if (throttleTimer) clearTimeout(throttleTimer); - }; - }, []); // Empty deps: set up once on mount. - // Compute all user message items and their turn info. + // ── User-message index map (for turn navigation & range reporting) ─── const userMessageItems = React.useMemo(() => { return virtualItems .map((item, index) => ({ item, index })) .filter(({ item }) => item.type === 'user-message'); }, [virtualItems]); - // Update visible turn info when the range changes. + // ── Visible turn info (range-changed callback) ─────────────────────── const handleRangeChanged = useCallback((range: ListRange) => { - lastRangeRef.current = range; const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; - + if (userMessageItems.length === 0) { setVisibleTurnInfo(null); return; } - // Find the first visible user message. - const visibleUserMessage = userMessageItems.find(({ index }) => + const visibleUserMessage = userMessageItems.find(({ index }) => index >= range.startIndex && index <= range.endIndex ); - - // If none are visible, find the closest one before the range. - const targetMessage = visibleUserMessage || + const targetMessage = visibleUserMessage || [...userMessageItems].reverse().find(({ index }) => index < range.startIndex); if (targetMessage) { const turnIndex = userMessageItems.indexOf(targetMessage) + 1; - const userMessage = targetMessage.item.type === 'user-message' - ? targetMessage.item.data + const userMessage = targetMessage.item.type === 'user-message' + ? targetMessage.item.data : null; setVisibleTurnInfo({ @@ -282,14 +87,13 @@ export const VirtualMessageList = forwardRef((_, ref) => } }, [userMessageItems]); - // Initialize visible turn info. useEffect(() => { const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; - + if (userMessageItems.length > 0) { const firstMessage = userMessageItems[0]; - const userMessage = firstMessage.item.type === 'user-message' - ? firstMessage.item.data + const userMessage = firstMessage.item.type === 'user-message' + ? firstMessage.item.data : null; setVisibleTurnInfo({ @@ -303,30 +107,22 @@ export const VirtualMessageList = forwardRef((_, ref) => } }, [userMessageItems.length]); - // Scroll to a specific turn. + // ── Navigation helpers ──────────────────────────────────────────────── const scrollToTurn = useCallback((turnIndex: number) => { - if (virtuosoRef.current && turnIndex >= 1 && turnIndex <= userMessageItems.length) { - const targetItem = userMessageItems[turnIndex - 1]; - if (targetItem) { - beginNavigation(); - // Immediately exit stick-to-bottom to avoid a race where followOutput / delayed - // scroll-to-bottom pulls us back down before state updates propagate. - stickToBottomRef.current = false; - - if (targetItem.index === 0) { - virtuosoRef.current.scrollTo({ - top: 0, - behavior: 'smooth', - }); - } else { - virtuosoRef.current.scrollToIndex({ - index: targetItem.index, - align: 'center', - behavior: 'smooth', - }); - } - setStickToBottom(false); // Exit stick-to-bottom on manual navigation. - } + if (!virtuosoRef.current) return; + if (turnIndex < 1 || turnIndex > userMessageItems.length) return; + + const targetItem = userMessageItems[turnIndex - 1]; + if (!targetItem) return; + + if (targetItem.index === 0) { + virtuosoRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + virtuosoRef.current.scrollToIndex({ + index: targetItem.index, + behavior: 'smooth', + align: 'center', + }); } }, [userMessageItems]); @@ -334,255 +130,134 @@ export const VirtualMessageList = forwardRef((_, ref) => if (!virtuosoRef.current) return; if (index < 0 || index >= virtualItems.length) return; - beginNavigation(); - stickToBottomRef.current = false; - if (index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); } else { - virtuosoRef.current.scrollToIndex({ - index, - align: 'center', - behavior: 'auto', - }); + virtuosoRef.current.scrollToIndex({ index, align: 'center', behavior: 'auto' }); } - - setStickToBottom(false); }, [virtualItems.length]); - // Scroll to bottom. const scrollToBottom = useCallback(() => { if (virtuosoRef.current && virtualItems.length > 0) { - suppressAutoEnableStickToBottomRef.current = false; - if (suppressAutoEnableTimerRef.current) { - clearTimeout(suppressAutoEnableTimerRef.current); - suppressAutoEnableTimerRef.current = null; - } - stickToBottomRef.current = true; - virtuosoRef.current.scrollTo({ - top: 999999999, - behavior: 'smooth', - }); - setStickToBottom(true); // Restore stick-to-bottom mode. + virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); } }, [virtualItems.length]); - // When list length increases in stick-to-bottom mode, delay scroll-to-bottom - // to avoid followOutput scrolling before layout is ready. - useEffect(() => { - const currentLength = virtualItems.length; - const prevLength = prevLengthRef.current; - prevLengthRef.current = currentLength; - - if (!stickToBottom || currentLength === 0 || currentLength <= prevLength) return; - if (!virtuosoRef.current) return; - - // Suppress followOutput and do a single delayed scroll-to-bottom. - suppressFollowOutputRef.current = true; - let delayedScrollTimeoutId: ReturnType | null = null; - const rafId1 = requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (!virtuosoRef.current || !stickToBottomRef.current) { - suppressFollowOutputRef.current = false; - return; - } - programmaticScrollToBottomRef.current = true; - // One delayed scroll-to-bottom; skip scrollToIndex to avoid jitter. - delayedScrollTimeoutId = setTimeout(() => { - if (virtuosoRef.current && stickToBottomRef.current) { - virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'auto' }); - } - setTimeout(() => { programmaticScrollToBottomRef.current = false; }, 300); - // Delay releasing suppression to avoid another followOutput trigger. - setTimeout(() => { suppressFollowOutputRef.current = false; }, 150); - }, 100); - }); - }); - return () => { - cancelAnimationFrame(rafId1); - if (delayedScrollTimeoutId !== null) clearTimeout(delayedScrollTimeoutId); - }; - }, [virtualItems.length, stickToBottom]); - - // Expose methods to parent components. useImperativeHandle(ref, () => ({ scrollToTurn, scrollToIndex, scrollToBottom, }), [scrollToTurn, scrollToIndex, scrollToBottom]); - - // Processing state from the state machine. - const activeSessionState = useActiveSessionState(); - const isProcessing = activeSessionState.isProcessing; - const processingPhase = activeSessionState.processingPhase; - - // Get the last item content for time-based checks. + + // ── Core scroll policy: processing → auto-scroll to bottom ──────────── + useEffect(() => { + if (!isProcessing) return; + + if (virtuosoRef.current) { + virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); + } + + const intervalId = setInterval(() => { + if (virtuosoRef.current) { + virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); + } + }, 300); + + return () => clearInterval(intervalId); + }, [isProcessing]); + + const handleFollowOutput = useCallback(() => { + return isProcessing ? 'smooth' as const : false; + }, [isProcessing]); + + const handleAtBottomStateChange = useCallback((atBottom: boolean) => { + setIsAtBottom(atBottom); + }, []); + + // ── Last-item info for breathing indicator ──────────────────────────── const lastItemInfo = React.useMemo(() => { const dialogTurns = activeSession?.dialogTurns; - const lastDialogTurn = dialogTurns && dialogTurns.length > 0 - ? dialogTurns[dialogTurns.length - 1] + const lastDialogTurn = dialogTurns && dialogTurns.length > 0 + ? dialogTurns[dialogTurns.length - 1] : undefined; const modelRounds = lastDialogTurn?.modelRounds; - const lastModelRound = modelRounds && modelRounds.length > 0 - ? modelRounds[modelRounds.length - 1] + const lastModelRound = modelRounds && modelRounds.length > 0 + ? modelRounds[modelRounds.length - 1] : undefined; const items = lastModelRound?.items; - const lastItem = items && items.length > 0 - ? items[items.length - 1] + const lastItem = items && items.length > 0 + ? items[items.length - 1] : undefined; - + const content = lastItem && 'content' in lastItem ? (lastItem as any).content : ''; - const isTurnProcessing = lastDialogTurn?.status === 'processing' || + const isTurnProcessing = lastDialogTurn?.status === 'processing' || lastDialogTurn?.status === 'image_analyzing'; - + return { lastItem, lastDialogTurn, content, isTurnProcessing }; }, [activeSession]); - - // Time-based heuristic: detect whether content is growing. + const [isContentGrowing, setIsContentGrowing] = useState(true); const lastContentRef = useRef(lastItemInfo.content); const contentTimeoutRef = useRef(null); - + useEffect(() => { const currentContent = lastItemInfo.content; - + if (currentContent !== lastContentRef.current) { lastContentRef.current = currentContent; setIsContentGrowing(true); - + if (contentTimeoutRef.current) { clearTimeout(contentTimeoutRef.current); } - + contentTimeoutRef.current = setTimeout(() => { setIsContentGrowing(false); }, 500); } - + return () => { if (contentTimeoutRef.current) { clearTimeout(contentTimeoutRef.current); } }; }, [lastItemInfo.content]); - - // Reset content-growth state when not processing. + useEffect(() => { if (!lastItemInfo.isTurnProcessing && !isProcessing) { setIsContentGrowing(false); } }, [lastItemInfo.isTurnProcessing, isProcessing]); - - // Breathing indicator visibility. + const showBreathingIndicator = React.useMemo(() => { const { lastItem, isTurnProcessing } = lastItemInfo; - - if (!isTurnProcessing && !isProcessing) { - return false; - } - - if (processingPhase === 'tool_confirming') { - return false; - } - - if (!lastItem) { - return true; - } - + + if (!isTurnProcessing && !isProcessing) return false; + if (processingPhase === 'tool_confirming') return false; + if (!lastItem) return true; + if ((lastItem.type === 'text' || lastItem.type === 'thinking')) { const hasContent = 'content' in lastItem && lastItem.content; - if (hasContent && isContentGrowing) { - return false; - } + if (hasContent && isContentGrowing) return false; } - + if (lastItem.type === 'tool') { const toolStatus = lastItem.status; if (toolStatus === 'running' || toolStatus === 'streaming' || toolStatus === 'preparing') { return false; } } - + return isTurnProcessing || isProcessing; }, [isProcessing, processingPhase, lastItemInfo, isContentGrowing]); - // Reserve space while processing to avoid layout jumps. const reserveSpaceForIndicator = React.useMemo(() => { if (!lastItemInfo.isTurnProcessing && !isProcessing) return false; if (processingPhase === 'tool_confirming') return false; return true; }, [lastItemInfo.isTurnProcessing, isProcessing, processingPhase]); - - // Diagnostic logging (development only). - // React.useEffect(() => { - // if (process.env.NODE_ENV === 'development') { - // console.log('[VirtualMessageList] Processing state:', { - // sessionId: activeSession?.sessionId, - // isProcessing, - // processingPhase, - // showBreathingIndicator, - // stickToBottom, - // status: activeSession?.status - // }); - // } - // }, [activeSession?.sessionId, isProcessing, processingPhase, showBreathingIndicator, stickToBottom, activeSession?.status]); - - // Listen to bottom state changes. - const handleAtBottomStateChange = useCallback((atBottom: boolean) => { - if (!isInitializedRef.current) return; - - setIsAtBottom(atBottom); - - if (atBottom && !stickToBottomRef.current && !suppressAutoEnableStickToBottomRef.current) { - stickToBottomRef.current = true; - setStickToBottom(true); - } - }, []); - - // Listen to scroll and detect intentional upward scrolling. - const handleScroll = useCallback((scrolling: boolean) => { - // Mark user scrolling to avoid MutationObserver interference. - if (scrolling) { - isUserScrollingRef.current = true; - - if (userScrollingTimeoutRef.current) { - clearTimeout(userScrollingTimeoutRef.current); - } - - // Clear the marker 200ms after scroll end. - userScrollingTimeoutRef.current = setTimeout(() => { - isUserScrollingRef.current = false; - }, 200); - } - - if (!scrolling) return; - if (!isInitializedRef.current) return; - - if (virtuosoRef.current) { - virtuosoRef.current.getState((state) => { - // Skip when programmatic scroll-to-bottom is in progress. - if (programmaticScrollToBottomRef.current) return; - const currentScrollTop = state.scrollTop; - const scrollDelta = currentScrollTop - lastScrollTopRef.current; - - // Exit stick-to-bottom only after scrolling up past threshold. - if (scrollDelta < -SCROLL_UP_THRESHOLD) { - stickToBottomRef.current = false; - setStickToBottom(false); - } - - lastScrollTopRef.current = currentScrollTop; - }); - } - }, []); - - // followOutput: honor stick-to-bottom; suppress during delayed scroll to avoid jitter. - const handleFollowOutput = useCallback(() => { - if (suppressFollowOutputRef.current) return false; - return stickToBottomRef.current ? 'smooth' : false; - }, []); - // Empty state. + // ── Render ──────────────────────────────────────────────────────────── if (virtualItems.length === 0) { return (
@@ -602,35 +277,29 @@ export const VirtualMessageList = forwardRef((_, ref) => `${item.type}-${item.turnId}-${'data' in item && item.data && typeof item.data === 'object' && 'id' in item.data ? item.data.id : index}` } itemContent={(index, item) => ( - )} - // Auto-follow based on stick-to-bottom. followOutput={handleFollowOutput} - + alignToBottom={false} initialTopMostItemIndex={0} - - // Overscan (lower to reduce memory). - overscan={50} - + + overscan={{ main: 1200, reverse: 1200 }} + atBottomThreshold={50} - atBottomStateChange={handleAtBottomStateChange} - - isScrolling={handleScroll} - + rangeChanged={handleRangeChanged} - - defaultItemHeight={100} - - // Increase viewport by a smaller amount to limit memory. - increaseViewportBy={{ top: 100, bottom: 200 }} - + + defaultItemHeight={200} + + increaseViewportBy={{ top: 1200, bottom: 1200 }} + scrollerRef={handleScrollerRef} - + components={{ Header: () =>
, Footer: () => ( @@ -642,13 +311,13 @@ export const VirtualMessageList = forwardRef((_, ref) => }} /> - 0} + visible={!isAtBottom && !isProcessing && virtualItems.length > 0} onClick={scrollToBottom} isInputActive={isInputActive} isInputExpanded={isInputExpanded} diff --git a/src/web-ui/src/flow_chat/hooks/index.ts b/src/web-ui/src/flow_chat/hooks/index.ts index 7078f989..0ca04e2f 100644 --- a/src/web-ui/src/flow_chat/hooks/index.ts +++ b/src/web-ui/src/flow_chat/hooks/index.ts @@ -2,4 +2,5 @@ export { useFlowChat } from './useFlowChat'; export { useActiveSessionState } from './useActiveSessionState'; export { useAutoScroll } from './useAutoScroll'; export { useCopyDialog } from './useCopyDialog'; +export { useTypewriter } from './useTypewriter'; diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts new file mode 100644 index 00000000..aba1f698 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts @@ -0,0 +1,77 @@ +/** + * Typewriter hook for smoothing batched streaming updates. + * + * The EventBatcher flushes content every ~100ms, which makes text appear + * in jarring chunks. This hook interpolates between batched updates to + * produce a smooth character-by-character reveal. + * + * When `animate` is false the full text is returned immediately — + * suitable for completed / history items. + */ + +import { useState, useEffect, useRef } from 'react'; + +const FRAME_INTERVAL = 50; // ms per tick – aligned with MutationObserver throttle +const REVEAL_DURATION = 800; // ms to reveal a new batch +const MIN_CHARS_PER_TICK = 3; + +export function useTypewriter(targetText: string, animate: boolean): string { + const [displayText, setDisplayText] = useState(animate ? '' : targetText); + const revealedRef = useRef(animate ? 0 : targetText.length); + const targetRef = useRef(targetText); + const timerRef = useRef | null>(null); + const speedRef = useRef(MIN_CHARS_PER_TICK); + + useEffect(() => { + if (!animate) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + revealedRef.current = targetText.length; + targetRef.current = targetText; + setDisplayText(targetText); + return; + } + + targetRef.current = targetText; + + // Reset when target shrinks (e.g. new round). + if (targetText.length < revealedRef.current) { + revealedRef.current = 0; + } + + const delta = targetText.length - revealedRef.current; + if (delta > 0) { + const totalFrames = REVEAL_DURATION / FRAME_INTERVAL; + speedRef.current = Math.max(Math.ceil(delta / totalFrames), MIN_CHARS_PER_TICK); + + if (!timerRef.current) { + timerRef.current = setInterval(() => { + const target = targetRef.current; + const cur = revealedRef.current; + if (cur >= target.length) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return; + } + const next = Math.min(cur + speedRef.current, target.length); + revealedRef.current = next; + setDisplayText(target.slice(0, next)); + }, FRAME_INTERVAL); + } + } + }, [targetText, animate]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, []); + + return displayText; +} diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 60806c6e..7c0faa38 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -15,6 +15,7 @@ import { isCollapsibleTool, READ_TOOL_NAMES, SEARCH_TOOL_NAMES } from '../tool-c export interface ExploreGroupStats { readCount: number; searchCount: number; + thinkingCount: number; } /** @@ -66,8 +67,10 @@ interface ModernFlowChatState { * Check if ModelRound is explore-only (contains only exploration tools) * Explore-only rounds can be collapsed * - * Key check: must contain at least one collapsible tool - * Pure text rounds (like final replies) should not be collapsed + * Key check: must contain at least one collapsible tool OR be a pure thinking round. + * Pure thinking rounds (thinking without critical tools) are merged into + * adjacent explore groups to reduce visual noise from standalone "thinking N chars" lines. + * Pure text rounds (like final replies) should not be collapsed. */ function isExploreOnlyRound(round: ModelRound): boolean { if (!round.items || round.items.length === 0) return false; @@ -76,6 +79,9 @@ function isExploreOnlyRound(round: ModelRound): boolean { item.type === 'tool' && isCollapsibleTool((item as FlowToolItem).toolName) ); + const hasAnyTool = round.items.some(item => item.type === 'tool'); + if (!hasAnyTool) return false; + if (!hasCollapsibleTool) return false; const allItemsCollapsible = round.items.every(item => { @@ -91,19 +97,22 @@ function isExploreOnlyRound(round: ModelRound): boolean { /** * Compute statistics for a single ModelRound */ -function computeRoundStats(round: ModelRound): { readCount: number; searchCount: number } { +function computeRoundStats(round: ModelRound): { readCount: number; searchCount: number; thinkingCount: number } { let readCount = 0; let searchCount = 0; + let thinkingCount = 0; for (const item of round.items) { if (item.type === 'tool') { const toolName = (item as FlowToolItem).toolName; if (READ_TOOL_NAMES.has(toolName)) readCount++; else if (SEARCH_TOOL_NAMES.has(toolName)) searchCount++; + } else if (item.type === 'thinking') { + thinkingCount++; } } - return { readCount, searchCount }; + return { readCount, searchCount, thinkingCount }; } let cachedSession: Session | null = null; @@ -163,6 +172,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { allItems: import('../types/flow-chat').FlowItem[]; readCount: number; searchCount: number; + thinkingCount: number; startIndex: number; endIndex: number; } @@ -178,6 +188,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { currentGroup.allItems.push(...round.items); currentGroup.readCount += stats.readCount; currentGroup.searchCount += stats.searchCount; + currentGroup.thinkingCount += stats.thinkingCount; currentGroup.endIndex = index; } else { currentGroup = { @@ -185,6 +196,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { allItems: [...round.items], readCount: stats.readCount, searchCount: stats.searchCount, + thinkingCount: stats.thinkingCount, startIndex: index, endIndex: index, }; @@ -233,7 +245,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { groupId: group.rounds.map(r => r.id).join('-'), rounds: group.rounds, allItems: group.allItems, - stats: { readCount: group.readCount, searchCount: group.searchCount }, + stats: { readCount: group.readCount, searchCount: group.searchCount, thinkingCount: group.thinkingCount }, isGroupStreaming, isLastGroupInTurn: isLastGroup, isFollowedByCritical, diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index 3a85bbf8..f418e39c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -1,65 +1,27 @@ /** * ModelThinking tool styles. * Shows internal model reasoning. + * + * Single DOM structure for both streaming and completed states. + * Streaming: expanded, muted text with ink-fade shimmer. + * Completed: auto-collapses via CSS grid-template-rows animation. */ @use './_tool-card-common.scss'; /* Minimal, borderless layout */ .flow-thinking-item { - padding: 8px 0; + padding: 4px 0; margin: 4px 0; background: transparent; border: none; - transition: all 0.2s ease; + transition: padding 0.2s ease; &.streaming { - background: transparent; - } - - &.collapsed { - background: transparent; - border: none; - padding: 4px 0; - } -} - -.thinking-header { - display: inline-flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; - height: 14px; /* Fixed height for alignment */ -} - -.thinking-icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 12px; - height: 12px; /* Match Lucide icon size */ - color: var(--text-tertiary, #6b7280); - opacity: 0.6; - - svg { - display: block; /* Remove inline SVG extra space */ - width: 12px; - height: 12px; + padding: 8px 0; } } -.thinking-label { - display: inline-flex; - align-items: center; - font-size: 12px; - font-weight: normal; - color: var(--text-tertiary, #6b7280); - opacity: 0.8; - line-height: 12px; /* Match icon height */ - height: 12px; /* Fixed height */ -} - .thinking-collapsed-header { display: inline-flex; align-items: center; @@ -80,7 +42,7 @@ opacity: 1; } } - + .thinking-chevron { flex-shrink: 0; color: var(--text-tertiary, #6b7280); @@ -90,11 +52,18 @@ } .thinking-label { + font-size: 12px; + font-weight: normal; + color: var(--text-tertiary, #6b7280); + opacity: 0.8; line-height: 12px; height: 12px; + display: inline-flex; + align-items: center; } } +/* Rotate chevron when expanded */ .flow-thinking-item.expanded .thinking-chevron { transform: rotate(90deg); } @@ -104,11 +73,11 @@ display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease-out; - + &--open { grid-template-rows: 1fr; } - + > .thinking-content-wrapper { overflow: hidden; } @@ -116,39 +85,34 @@ .thinking-content { font-size: 12px; - line-height: 1.6; + line-height: 1.4; font-family: var(--tool-card-font-mono); word-break: break-word; white-space: pre-wrap; + color: var(--tool-card-text-muted); + padding: 10px 12px; + background: transparent; + border: none; + border-radius: 6px; + margin-top: 0; + max-height: none; + overflow-y: visible; + cursor: text; + user-select: text; +} - &.streaming { - color: #9ca3af; - // Constrain height while streaming; scroll when overflow. - max-height: 300px; - overflow-y: auto; - } - - &.expanded { - color: var(--tool-card-text-muted); - padding: 10px 12px; - background: transparent; - border: none; - border-radius: 6px; - margin-top: 0; - // No height limit when expanded. - max-height: none; - overflow-y: visible; - cursor: text; - user-select: text; - line-height: 1.4; - - } +/* During streaming, constrain height and auto-scroll */ +.flow-thinking-item.streaming .thinking-content { + color: #9ca3af; + max-height: 300px; + overflow-y: auto; } /* Content wrapper with fade gradients */ .thinking-content-wrapper { position: relative; - + min-height: 0; /* Required for the 0fr grid trick */ + /* Top fade gradient */ &::before { content: ''; @@ -161,7 +125,7 @@ z-index: 1; opacity: 0; transition: opacity 0.2s ease; - + background: linear-gradient( to bottom, var(--color-bg-primary, #121214) 0%, @@ -170,7 +134,7 @@ transparent 100% ); } - + /* Bottom fade gradient */ &::after { content: ''; @@ -183,7 +147,7 @@ z-index: 1; opacity: 0; transition: opacity 0.2s ease; - + background: linear-gradient( to top, var(--color-bg-primary, #121214) 0%, @@ -192,7 +156,7 @@ transparent 100% ); } - + /* Show gradients when content scrolls */ &.has-scroll { &::before, @@ -200,12 +164,12 @@ opacity: 1; } } - + /* Hide top fade at the top */ &.at-top::before { opacity: 0; } - + /* Hide bottom fade at the bottom */ &.at-bottom::after { opacity: 0; @@ -278,4 +242,3 @@ ); } } - diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 4dfc4f97..aefe4c1c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -1,88 +1,71 @@ /** * Model thinking display component. - * Shows internal model reasoning. - * - Streaming: muted text, incremental output. - * - Completed: auto-collapses, click to expand. + * Default expanded. Collapses when isLastItem becomes false + * (i.e. the next atomic step has started). + * Applies typewriter effect during streaming. */ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { FlowThinkingItem } from '../types/flow-chat'; +import { useTypewriter } from '../hooks/useTypewriter'; import './ModelThinkingDisplay.scss'; -// Idle timeout after content stops growing (ms). -const CONTENT_IDLE_TIMEOUT = 500; - interface ModelThinkingDisplayProps { thinkingItem: FlowThinkingItem; + /** Whether this is the last item in the current round. */ + isLastItem?: boolean; } -export const ModelThinkingDisplay: React.FC = ({ thinkingItem }) => { +export const ModelThinkingDisplay: React.FC = ({ thinkingItem, isLastItem = true }) => { const { t } = useTranslation('flow-chat'); - const { content, isStreaming, isCollapsed, status } = thinkingItem; - const [isExpanded, setIsExpanded] = useState(false); - const [scrollState, setScrollState] = useState({ hasScroll: false, atTop: true, atBottom: true }); + const { content, isStreaming, status } = thinkingItem; const contentRef = useRef(null); - - // Time-based heuristic to detect content growth. - const [isContentGrowing, setIsContentGrowing] = useState(true); - const lastContentRef = useRef(content); - const timeoutRef = useRef(null); - - useEffect(() => { - if (content !== lastContentRef.current) { - lastContentRef.current = content; - setIsContentGrowing(true); - - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - setIsContentGrowing(false); - }, CONTENT_IDLE_TIMEOUT); - } - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, [content]); - + + const isActive = isStreaming || status === 'streaming'; + const displayContent = useTypewriter(content, isActive); + + const [isExpanded, setIsExpanded] = useState(true); + const userToggledRef = useRef(false); + useEffect(() => { - if (status === 'completed' || !isStreaming) { - setIsContentGrowing(false); + if (userToggledRef.current) return; + if (!isLastItem) { + setIsExpanded(false); } - }, [status, isStreaming]); + }, [isLastItem]); - // Auto-collapse when streaming ends and the item is still expanded. + // Auto-scroll to bottom while content grows. useEffect(() => { - if (!isStreaming && !isCollapsed && status === 'completed') { - // Give the user a moment to see the full content. - const timer = setTimeout(() => { - // Parent state controls collapse; keep a local expanded flag here. - setIsExpanded(false); - }, 500); - return () => clearTimeout(timer); + if (isExpanded && contentRef.current) { + const el = contentRef.current; + const gap = el.scrollHeight - el.scrollTop - el.clientHeight; + if (gap < 80) { + requestAnimationFrame(() => { + if (contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }); + } } - }, [isStreaming, isCollapsed, status]); + }, [displayContent, isExpanded]); + + // Scroll-state detection for fade gradients. + const [scrollState, setScrollState] = useState({ hasScroll: false, atTop: true, atBottom: true }); const checkScrollState = useCallback(() => { const el = contentRef.current; if (!el) return; - - const hasScroll = el.scrollHeight > el.clientHeight; - const atTop = el.scrollTop <= 5; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 5; - - setScrollState({ hasScroll, atTop, atBottom }); + setScrollState({ + hasScroll: el.scrollHeight > el.clientHeight, + atTop: el.scrollTop <= 5, + atBottom: el.scrollTop + el.clientHeight >= el.scrollHeight - 5, + }); }, []); useEffect(() => { if (isExpanded) { - // Delay to wait for DOM layout. const timer = setTimeout(checkScrollState, 50); return () => clearTimeout(timer); } @@ -93,48 +76,41 @@ export const ModelThinkingDisplay: React.FC = ({ thin return t('toolCards.think.thinkingCharacters', { count: content.length }); }, [content, t]); - if (isStreaming || status === 'streaming') { - const hasContent = content && content.length > 0; - // Only show shimmer when content is actively growing. - const isActivelyStreaming = status === 'streaming' && isContentGrowing; - return ( -
-
- {t('toolCards.think.thinking')} -
-
- {content} -
-
- ); - } - const handleToggleClick = () => { - // Notify VirtualMessageList to prevent auto-scroll on user toggle. window.dispatchEvent(new CustomEvent('tool-card-toggle')); - setIsExpanded(!isExpanded); + userToggledRef.current = true; + setIsExpanded(prev => !prev); }; + const headerLabel = isExpanded + ? (isActive ? t('toolCards.think.thinking') : t('toolCards.think.thinkingProcess')) + : contentLengthText; + + const wrapperClassName = [ + 'flow-thinking-item', + isExpanded ? 'expanded' : 'collapsed', + ].filter(Boolean).join(' '); + + const renderedContent = isActive ? displayContent : content; + return ( -
-
+
- - {isExpanded ? t('toolCards.think.thinkingProcess') : contentLengthText} - + {headerLabel}
-
- {content.split('\n').map((line: string, index: number) => ( + {renderedContent.split('\n').map((line: string, index: number) => (
{line || '\u00A0'}
@@ -145,4 +121,3 @@ export const ModelThinkingDisplay: React.FC = ({ thin
); }; - 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 1ef8c10f..f52d4ad4 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -352,6 +352,9 @@ "searchCount_one": "{{count}} search", "searchCount_other": "{{count}} searches", "searchCount": "{{count}} search(es)", + "thinkingCount_one": "Thought {{count}} time", + "thinkingCount_other": "Thought {{count}} times", + "thinkingCount": "Thought {{count}} time(s)", "exploreCount_one": "{{count}} exploration", "exploreCount_other": "{{count}} explorations", "exploreCount": "{{count}} exploration(s)", 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 ccbe5145..200177d3 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -348,6 +348,7 @@ "exploreRegion": { "readFiles": "读取了 {{count}} 个文件", "searchCount": "进行了 {{count}} 次搜索", + "thinkingCount": "思考了 {{count}} 次", "exploreCount": "执行了 {{count}} 次探索", "separator": ",", "collapse": "收起探索过程"