From 8da9aedaefb5db3e43dd83153fdc30494517c061 Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 29 Jul 2026 12:38:25 +0800 Subject: [PATCH] fix(flow-chat): stabilize pinned turns and viewport diagnostics - Preserve protected footer range when users leave pinned turns or tool cards collapse. - Atomically settle semantic anchors to prevent flashes and permanent viewport drops. - Add opt-in bounded Flow Chat diagnostics with local JSONL rotation. - Coalesce diagnostic writes and keep dropped-event sequences ordered. - Add focused regression tests and update settings, locales, and documentation. --- src/apps/desktop/src/api/config_api.rs | 18 + .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/lib.rs | 1 + src/apps/desktop/src/logging.rs | 154 +++++- .../assembly/core/src/service/config/types.rs | 5 + .../modern/FLOWCHAT_SCROLL_STABILITY.md | 38 +- .../modern/FlowChatViewportCoordinator.ts | 115 ++++- ...rtualMessageList.session-boundary.test.tsx | 104 ++++ .../components/modern/VirtualMessageList.tsx | 477 ++++++++++++++++-- .../modern/flowChatScrollStability.ts | 107 +++- .../config/components/BasicsConfig.tsx | 39 +- .../services/FrontendLogLevelSync.test.ts | 11 + .../config/services/FrontendLogLevelSync.ts | 13 + .../src/infrastructure/config/types/index.ts | 2 + .../diagnostics/flowChatDiagnostics.test.ts | 143 ++++++ .../diagnostics/flowChatDiagnostics.ts | 276 ++++++++++ .../flowChatDiagnosticsTransport.ts | 24 + .../src/locales/en-US/settings/basics.json | 5 + .../src/locales/zh-CN/settings/basics.json | 5 + .../src/locales/zh-TW/settings/basics.json | 5 + 20 files changed, 1497 insertions(+), 49 deletions(-) create mode 100644 src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts create mode 100644 src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts create mode 100644 src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index effb50bcbd..8485f94ad3 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -49,6 +49,12 @@ pub struct GetRuntimeLoggingInfoRequest {} #[derive(Debug, Deserialize, Default)] pub struct ExportDiagnosticsBundleRequest {} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppendFlowChatDiagnosticsRequest { + pub entries: Vec, +} + fn to_json_value(value: T, context: &str) -> Result { serde_json::to_value(value).map_err(|e| format!("Failed to serialize {}: {}", context, e)) } @@ -373,6 +379,18 @@ pub async fn export_diagnostics_bundle( to_json_value(bundle_info, "diagnostics bundle info") } +#[tauri::command] +pub async fn append_flow_chat_diagnostics( + _state: State<'_, AppState>, + request: AppendFlowChatDiagnosticsRequest, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::logging::append_flow_chat_diagnostics(&request.entries) + }) + .await + .map_err(|error| format!("Flow Chat diagnostics writer task failed: {}", error))? +} + #[tauri::command] pub async fn get_agent_profile_configs(_state: State<'_, AppState>) -> Result { let agent_profiles = diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index ae36b3f7b2..d6250a6823 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -143,6 +143,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "account_sync_settings", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "append_flow_chat_diagnostics", + RemoteWorkspacePolicy::LocalOnly, + ), ( "account_token_expired", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index ed5df58ffa..754afdd041 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1275,6 +1275,7 @@ pub async fn run() { get_global_config_health, get_runtime_logging_info, export_diagnostics_bundle, + append_flow_chat_diagnostics, get_runtime_capabilities, speech_list_models, speech_download_model, diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 1e934c1550..52360034cf 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -3,10 +3,13 @@ use bitfun_core::infrastructure::get_path_manager_arc; use chrono::Local; use serde::Serialize; +use serde_json::Value; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::PathBuf; use std::sync::{ atomic::{AtomicU8, Ordering}, - OnceLock, + Mutex, OnceLock, }; use std::thread; use tauri::{plugin::TauriPlugin, Runtime}; @@ -15,9 +18,15 @@ use tauri_plugin_log::{fern, RotationStrategy, Target, TargetKind, TimezoneStrat const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$"; const MAX_LOG_SESSIONS: usize = 10; const FLASHGREP_LOG_TARGET_PREFIX: &str = "flashgrep"; +const FLOW_CHAT_LOG_FILE_NAME: &str = "flowchat.log"; +const FLOW_CHAT_LOG_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; +const FLOW_CHAT_LOG_MAX_BATCH_ENTRIES: usize = 256; +const FLOW_CHAT_LOG_MAX_BATCH_BYTES: usize = 1024 * 1024; +const FLOW_CHAT_LOG_MAX_ENTRY_BYTES: usize = 32 * 1024; static SESSION_LOG_DIR: OnceLock = OnceLock::new(); // Default to Debug in early development for easier diagnostics static CURRENT_LOG_LEVEL: AtomicU8 = AtomicU8::new(level_filter_to_u8(log::LevelFilter::Debug)); +static FLOW_CHAT_DIAGNOSTICS_WRITE_LOCK: Mutex<()> = Mutex::new(()); fn get_thread_id() -> u64 { let thread_id = thread::current().id(); @@ -152,6 +161,12 @@ pub fn session_log_dir() -> Option { SESSION_LOG_DIR.get().cloned() } +pub fn flow_chat_log_path() -> PathBuf { + session_log_dir() + .unwrap_or_else(resolve_logs_root) + .join(FLOW_CHAT_LOG_FILE_NAME) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeLoggingInfo { @@ -161,6 +176,7 @@ pub struct RuntimeLoggingInfo { pub ai_log_path: String, pub flashgrep_log_path: String, pub webview_log_path: String, + pub flow_chat_log_path: String, pub previous_unexpected_exit: Option, } @@ -181,10 +197,110 @@ pub fn get_runtime_logging_info() -> RuntimeLoggingInfo { .join("webview.log") .to_string_lossy() .to_string(), + flow_chat_log_path: session_dir + .join(FLOW_CHAT_LOG_FILE_NAME) + .to_string_lossy() + .to_string(), previous_unexpected_exit: crate::crash_diagnostics::previous_unexpected_exit(), } } +fn rotated_flow_chat_log_path(path: &std::path::Path, index: usize) -> PathBuf { + PathBuf::from(format!("{}.{}", path.to_string_lossy(), index)) +} + +fn rotate_flow_chat_log_if_needed( + path: &std::path::Path, + incoming_bytes: usize, + max_file_size: u64, +) -> Result<(), String> { + let current_size = fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size + incoming_bytes as u64 <= max_file_size { + return Ok(()); + } + + let first_backup = rotated_flow_chat_log_path(path, 1); + let second_backup = rotated_flow_chat_log_path(path, 2); + if second_backup.exists() { + fs::remove_file(&second_backup) + .map_err(|error| format!("Failed to remove old Flow Chat log backup: {}", error))?; + } + if first_backup.exists() { + fs::rename(&first_backup, &second_backup) + .map_err(|error| format!("Failed to rotate Flow Chat log backup: {}", error))?; + } + if path.exists() { + fs::rename(path, &first_backup) + .map_err(|error| format!("Failed to rotate Flow Chat log: {}", error))?; + } + Ok(()) +} + +fn serialize_flow_chat_diagnostics(entries: &[Value]) -> Result, String> { + if entries.is_empty() { + return Ok(Vec::new()); + } + if entries.len() > FLOW_CHAT_LOG_MAX_BATCH_ENTRIES { + return Err(format!( + "Flow Chat diagnostics batch exceeds {} entries", + FLOW_CHAT_LOG_MAX_BATCH_ENTRIES + )); + } + + let mut output = Vec::new(); + for entry in entries { + if !entry.is_object() { + return Err("Flow Chat diagnostics entries must be JSON objects".to_string()); + } + let serialized = serde_json::to_vec(entry) + .map_err(|error| format!("Failed to serialize Flow Chat diagnostic: {}", error))?; + if serialized.len() > FLOW_CHAT_LOG_MAX_ENTRY_BYTES { + return Err(format!( + "Flow Chat diagnostic entry exceeds {} bytes", + FLOW_CHAT_LOG_MAX_ENTRY_BYTES + )); + } + if output.len() + serialized.len() + 1 > FLOW_CHAT_LOG_MAX_BATCH_BYTES { + return Err(format!( + "Flow Chat diagnostics batch exceeds {} bytes", + FLOW_CHAT_LOG_MAX_BATCH_BYTES + )); + } + output.extend_from_slice(&serialized); + output.push(b'\n'); + } + Ok(output) +} + +pub fn append_flow_chat_diagnostics(entries: &[Value]) -> Result { + if entries.is_empty() { + return Ok(0); + } + + let output = serialize_flow_chat_diagnostics(entries)?; + let _guard = FLOW_CHAT_DIAGNOSTICS_WRITE_LOCK + .lock() + .map_err(|_| "Flow Chat diagnostics writer lock is poisoned".to_string())?; + let path = flow_chat_log_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create Flow Chat log directory: {}", error))?; + } + rotate_flow_chat_log_if_needed(&path, output.len(), FLOW_CHAT_LOG_MAX_FILE_SIZE)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| format!("Failed to open Flow Chat log: {}", error))?; + file.write_all(&output) + .map_err(|error| format!("Failed to write Flow Chat log: {}", error))?; + file.flush() + .map_err(|error| format!("Failed to flush Flow Chat log: {}", error))?; + Ok(entries.len()) +} + fn is_flashgrep_target(target: &str) -> bool { target.starts_with(FLASHGREP_LOG_TARGET_PREFIX) } @@ -431,3 +547,39 @@ pub fn spawn_log_cleanup_task() { cleanup_old_log_sessions().await; }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_flow_chat_diagnostics_as_bounded_json_lines() { + let entries = vec![ + serde_json::json!({"sequence": 1, "message": "first"}), + serde_json::json!({"sequence": 2, "message": "second"}), + ]; + + let serialized = serialize_flow_chat_diagnostics(&entries).expect("serialize entries"); + let text = String::from_utf8(serialized).expect("valid UTF-8"); + + assert_eq!(text.lines().count(), 2); + assert!(text.contains("\"sequence\":1")); + assert!(text.ends_with('\n')); + assert!(serialize_flow_chat_diagnostics(&[serde_json::json!("invalid")]).is_err()); + } + + #[test] + fn rotates_flow_chat_log_before_the_next_batch_exceeds_the_limit() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let path = temp_dir.path().join(FLOW_CHAT_LOG_FILE_NAME); + fs::write(&path, b"12345678").expect("write active log"); + + rotate_flow_chat_log_if_needed(&path, 4, 10).expect("rotate log"); + + assert!(!path.exists()); + assert_eq!( + fs::read(rotated_flow_chat_log_path(&path, 1)).expect("read backup"), + b"12345678" + ); + } +} diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 7f762f4f71..134c741b44 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -244,6 +244,9 @@ pub struct AppLoggingConfig { /// Whether diagnostic logs may include sensitive troubleshooting payloads. #[serde(default = "default_true")] pub include_sensitive_diagnostics: bool, + /// Whether the local UI records detailed Flow Chat viewport diagnostics. + #[serde(default)] + pub flow_chat_diagnostics: bool, /// Per-request AI model exchange tracing configuration for developer diagnostics. #[serde(default)] pub model_exchange_tracing: ModelExchangeTracingConfig, @@ -1727,6 +1730,7 @@ impl Default for AppLoggingConfig { // Set to Debug in early development for easier diagnostics level: "debug".to_string(), include_sensitive_diagnostics: true, + flow_chat_diagnostics: false, model_exchange_tracing: ModelExchangeTracingConfig::default(), } } @@ -2822,6 +2826,7 @@ mod tests { .expect("logging config without sensitive preference should deserialize"); assert!(config.include_sensitive_diagnostics); + assert!(!config.flow_chat_diagnostics); assert_eq!( config.model_exchange_tracing.mode, ModelExchangeTracingMode::Off diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index a4596a2bbd..c3a2f333f2 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -113,7 +113,9 @@ Important details: - the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx` - reservation space is not real content height - reservations may define a `floorPx` -- only reservation space above the floor is consumable +- a floor prevents unrelated shrink reconciliation from dropping live scroll range +- collapse floors still drain from measured content growth or deliberate downward + user navigation; pin floors drain only through the sticky-pin settlement path - all measurements that compare old vs new content height must use: ```ts @@ -261,7 +263,13 @@ timer fallback for browsers that delay timers. While the intent is alive, the grow branch of `measureHeightChange` protects the collapse reservation, but it may still consume measured content growth from the sticky pin reservation. Once the intent settles, residual collapse space is transferred to the settled -sticky pin in one state/DOM update and any deferred follow is replayed. +sticky pin in one state/DOM update when the pinned item still owns the viewport. +If a collapsing header owns the viewport, the footer is instead reduced +atomically to the minimum range that can retain the current `scrollTop`, then +that settled range is promoted to a protected collapse floor before the +semantic anchor is restored. This prevents a clear-and-reacquire frame without +retaining the full provisional estimate; later content growth can still drain +the protected range. Any deferred follow is then replayed. ## C. Follow-Output Mode (continuous tail) @@ -347,8 +355,11 @@ If a future collapsible component shows the same "header drops" or "flash on col - Sticky pin floors must shrink from measured content growth, not a transient target-element position. - A user gesture that exits pinned mode must release the semantic anchor and - clear or atomically transfer the pin reservation in the same operation; an - idle coordinator must never retain a live pin reservation. + atomically transfer the pin reservation to a protected collapse range in the + same operation; an idle coordinator must never retain a live pin reservation. +- Unsignaled shrink reconciliation must not reduce a protected collapse floor; + only measured growth, downward navigation, bottom arrival, or an explicit + reservation reset may consume it. - Pre-collapse intent must capture the anchor before the component shrinks. - Compensation must not be consumed too early during active layout transitions. - Session changes and empty-list resets must clear compensation and anchor state. @@ -388,6 +399,25 @@ If a future collapsible component shows the same "header drops" or "flash on col ## If You Need To Change This Logic +### Opt-in viewport diagnostics + +Enable `app.logging.flow_chat_diagnostics` from the logging settings only while +reproducing a viewport stability issue. The frontend records bounded JSONL +batches to `flowchat.log` in the current session log directory. When disabled, +probe payloads are not evaluated and no timer, IPC request, or file is created. + +The diagnostic schema groups events by hypothesis: + +- `A`: user scroll intent, pin release, reservation transfer, and tail handoff +- `B`: semantic anchor capture, correction, release, or unexpected reacquisition +- `C`: content measurement, Footer compensation, and physical range changes +- `D`: Virtuoso scroll compensation and tail-follow ownership +- `E`: streaming tool-card collapse intent and anchor preservation + +Do not add message content, tool arguments, file contents, or other sensitive +payloads to this channel. Keep all data producers lazy and guard hot-path probes +with `flowChatDiagnostics.isEnabled()` before allocating probe objects. + Use this checklist: 1. Verify the live tail stays expanded when a conversation ends with an action. diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts index 8b8f22245f..55395befeb 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -1,3 +1,5 @@ +import { flowChatDiagnostics } from '@/infrastructure/diagnostics/flowChatDiagnostics'; + export type FlowChatViewportAnchorMode = | 'idle' | 'pinned-item' @@ -63,10 +65,19 @@ export class FlowChatViewportCoordinator { ); } - pinItem(_reason = 'unspecified'): void { + pinItem(reason = 'unspecified'): void { + const previousMode = this.mode; this.stopAnchorGuard(); this.elementAnchor = null; this.mode = 'pinned-item'; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.pinItem', + message: 'Viewport coordinator entered pinned item mode', + data: () => ({ previousMode, reason }), + }); + } } pinElement(element: HTMLElement | null | undefined): boolean { @@ -76,18 +87,43 @@ export class FlowChatViewportCoordinator { followTail(options?: { force?: boolean }): boolean { this.expireElementAnchor(); if (this.mode === 'preserving-element' && !options?.force) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.followTail', + message: 'Tail follow rejected while preserving an element', + data: () => ({ mode: this.mode, force: options?.force === true }), + }); + } return false; } + const previousMode = this.mode; this.stopAnchorGuard(); this.elementAnchor = null; this.mode = 'following-tail'; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.followTail', + message: 'Viewport coordinator entered tail follow mode', + data: () => ({ previousMode, force: options?.force === true }), + }); + } return true; } preserveElement(element: HTMLElement | null | undefined): boolean { this.expireElementAnchor(); if (!element || this.mode === 'following-tail' || this.mode === 'pinned-item') { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'E', + location: 'FlowChatViewportCoordinator.preserveElement', + message: 'Element preservation request rejected', + data: () => ({ hasElement: Boolean(element), mode: this.mode }), + }); + } return false; } @@ -109,6 +145,14 @@ export class FlowChatViewportCoordinator { const scroller = element.closest('[data-virtuoso-scroller="true"]'); if (!scroller) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.captureElement', + message: 'Element anchor capture failed without a scroller', + data: () => ({ mode }), + }); + } return false; } @@ -122,6 +166,21 @@ export class FlowChatViewportCoordinator { }; this.mode = mode; this.startAnchorGuard(); + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: mode === 'preserving-element' ? 'E' : 'B', + location: 'FlowChatViewportCoordinator.captureElement', + message: 'Semantic element anchor captured', + data: () => ({ + mode, + elementConnected: element.isConnected, + offsetFromScrollerTop: this.elementAnchor?.offsetFromScrollerTop ?? null, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } return true; } @@ -132,6 +191,14 @@ export class FlowChatViewportCoordinator { return false; } if (!anchor.element.isConnected) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.restoreElementAnchor', + message: 'Semantic anchor restore skipped for disconnected element', + data: () => ({ mode: this.mode, source }), + }); + } return false; } @@ -152,8 +219,26 @@ export class FlowChatViewportCoordinator { return false; } + const diagnosticsEnabled = flowChatDiagnostics.isEnabled(); + const scrollTopBefore = diagnosticsEnabled ? scroller.scrollTop : null; applyCorrection(initialCorrection); let remainingCorrection = readCorrection(); + if (diagnosticsEnabled) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.restoreElementAnchor', + message: 'Semantic anchor correction applied', + data: () => ({ + mode: this.mode, + source, + initialCorrection, + remainingCorrection, + scrollTopBefore, + scrollTopAfter: scroller.scrollTop, + maxScrollTop: Math.max(0, scroller.scrollHeight - scroller.clientHeight), + }), + }); + } if ( remainingCorrection > ELEMENT_ANCHOR_EPSILON_PX && @@ -172,6 +257,22 @@ export class FlowChatViewportCoordinator { applyCorrection(remainingCorrection); } } + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'FlowChatViewportCoordinator.restoreElementAnchor', + message: 'Semantic anchor requested additional bottom range', + data: () => ({ + mode: this.mode, + source, + rangeExtended, + remainingCorrection, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } } return true; } @@ -197,10 +298,20 @@ export class FlowChatViewportCoordinator { return true; } - release(_reason = 'unspecified'): void { + release(reason = 'unspecified'): void { + const previousMode = this.mode; + const hadElementAnchor = Boolean(this.elementAnchor); this.stopAnchorGuard(); this.elementAnchor = null; this.mode = 'idle'; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'FlowChatViewportCoordinator.release', + message: 'Viewport coordinator released semantic ownership', + data: () => ({ previousMode, hadElementAnchor, reason }), + }); + } } private expireElementAnchor(): void { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index e5b439aec0..2813e8089f 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -11,12 +11,16 @@ import { import { consumeBottomReservationForContentGrowth, getCanceledUnsettledStickyPinGrowthPx, + protectCurrentCollapseReservation, + reconcileUnsignaledShrinkReservation, resolveAutoCollapseAnchorScrollTop, + settleCollapseReservationForPreservedViewport, shouldBypassShrinkCompensationInTailFollow, shouldPreserveCollapseReservationAfterIntent, shouldSyncPhysicalBottom, shouldSuppressFollowingTailNegativeScrollBy, transferCollapseReservationToPin, + transferPinReservationToProtectedCollapse, } from './flowChatScrollStability'; import { activeSessionHistoryProjectionHandoff } from './historyProjectionHandoff'; import type { Session } from '../../types/flow-chat'; @@ -425,6 +429,82 @@ describe('VirtualMessageList session boundary', () => { }); }); + it('transfers a released pin into a protected viewport range', () => { + const currentState = { + collapse: { kind: 'collapse' as const, px: 12, floorPx: 4 }, + pin: { + kind: 'pin' as const, + px: 100, + floorPx: 100, + mode: 'sticky-latest' as const, + targetTurnId: 'turn-a', + }, + }; + + expect(transferPinReservationToProtectedCollapse(currentState)).toEqual({ + collapse: { kind: 'collapse', px: 112, floorPx: 104 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }); + }); + + it('protects a settled element range from later unsignaled shrink reconciliation', () => { + const settledState = settleCollapseReservationForPreservedViewport({ + collapse: { kind: 'collapse', px: 1_022, floorPx: 670 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }, { + scrollTop: 686, + scrollHeight: 2_030, + clientHeight: 1_023, + }); + expect(settledState.collapse).toEqual({ + kind: 'collapse', + px: 702, + floorPx: 702, + }); + + const protectedState = protectCurrentCollapseReservation({ + collapse: { kind: 'collapse', px: 784, floorPx: 670 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }); + + expect(protectedState.collapse).toEqual({ + kind: 'collapse', + px: 784, + floorPx: 784, + }); + expect(reconcileUnsignaledShrinkReservation(protectedState, 2).collapse).toEqual({ + kind: 'collapse', + px: 784, + floorPx: 784, + }); + expect(reconcileUnsignaledShrinkReservation({ + ...protectedState, + collapse: { kind: 'collapse', px: 784, floorPx: 0 }, + }, 2).collapse).toEqual({ + kind: 'collapse', + px: 2, + floorPx: 0, + }); + }); + it('drains a sticky pin floor only from measured content growth', () => { const currentState = { collapse: { kind: 'collapse' as const, px: 20, floorPx: 0 }, @@ -457,6 +537,14 @@ describe('VirtualMessageList session boundary', () => { floorPx: 65, }, }); + + expect(consumeBottomReservationForContentGrowth({ + ...currentState, + collapse: { kind: 'collapse', px: 30, floorPx: 20 }, + }, 25, false)).toEqual({ + collapse: { kind: 'collapse', px: 5, floorPx: 5 }, + pin: currentState.pin, + }); }); it('does not let physical-bottom follow compete with a semantic element anchor', () => { @@ -533,11 +621,27 @@ describe('VirtualMessageList session boundary', () => { expect(shouldPreserveCollapseReservationAfterIntent({ isFollowingOutput: true, isStreamingOutput: true, + isPreservingElement: false, + hasProtectedCollapseRange: false, })).toBe(true); expect(shouldPreserveCollapseReservationAfterIntent({ isFollowingOutput: false, isStreamingOutput: true, + isPreservingElement: false, + hasProtectedCollapseRange: false, })).toBe(false); + expect(shouldPreserveCollapseReservationAfterIntent({ + isFollowingOutput: false, + isStreamingOutput: true, + isPreservingElement: true, + hasProtectedCollapseRange: false, + })).toBe(true); + expect(shouldPreserveCollapseReservationAfterIntent({ + isFollowingOutput: false, + isStreamingOutput: true, + isPreservingElement: false, + hasProtectedCollapseRange: true, + })).toBe(true); }); it('recovers the last stable scroll position when an auto collapse arrives after clamp', () => { 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 620bfb6609..383c0d9f56 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -46,6 +46,7 @@ import { } from '../../utils/flowChatTurnScrollPolicy'; import { flowChatStore } from '../../store/FlowChatStore'; import { startupTrace } from '@/shared/utils/startupTrace'; +import { flowChatDiagnostics } from '@/infrastructure/diagnostics/flowChatDiagnostics'; import { estimateVirtualMessageItemHeight, getVirtualMessageDefaultItemHeight, @@ -71,13 +72,17 @@ import { createInitialBottomReservationState, getCanceledUnsettledStickyPinGrowthPx, getReservationTotalPx, + protectCurrentCollapseReservation, + reconcileUnsignaledShrinkReservation, resolveAutoCollapseAnchorScrollTop, sanitizeBottomReservationState, + settleCollapseReservationForPreservedViewport, shouldBypassShrinkCompensationInTailFollow, shouldPreserveCollapseReservationAfterIntent, shouldSuppressFollowingTailNegativeScrollBy, shouldSyncPhysicalBottom, transferCollapseReservationToPin, + transferPinReservationToProtectedCollapse, type BottomReservationState, type PinBottomReservation, } from './flowChatScrollStability'; @@ -618,6 +623,27 @@ const VirtualMessageListSession = forwardRef { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.notifyUserScrollIntent', + message: 'User scroll intent requested viewport ownership release', + data: () => { + const scroller = scrollerElementRef.current; + return { + reason, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + reservation: bottomReservationStateRef.current, + pendingCollapseIntent: pendingCollapseIntentRef.current.active, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + }; + }, + }); + } exitPinnedViewportForUserIntentRef.current(reason); followOutputControllerRef.current.handleUserScrollIntent(); onUserScrollIntent?.(); @@ -757,6 +783,23 @@ const VirtualMessageListSession = forwardRef ({ + before: previous, + after: next, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }), + }); + } bottomReservationStateRef.current = next; setBottomReservationState(prev => { return areBottomReservationStatesEqual(next, prev) ? prev : next; @@ -834,6 +877,21 @@ const VirtualMessageListSession = forwardRef ({ + compensationPx, + renderedHeight: footer.getBoundingClientRect().height, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } }, [applyFooterHeightToElement, getTotalBottomCompensationPx]); const clearPendingStickyPinGrowth = useCallback((_reason: string) => { @@ -1025,7 +1083,6 @@ const VirtualMessageListSession = forwardRef ({ + heightDelta, + previousMeasuredHeight, + effectiveScrollHeight, + currentScrollTop, + previousScrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + reservation: bottomReservationStateRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + collapseIntentActive: pendingCollapseIntentRef.current.active, + wasAtPhysicalBottom, + viewportGeometryChanged, + }), + }); + } + const ownsElementAnchor = viewportCoordinatorRef.current.ownsElementAnchor(); if (shouldSyncPhysicalBottom({ viewportGeometryChanged, @@ -1130,6 +1211,18 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.measureHeightChange', + message: 'Content measurement synchronized the physical bottom', + data: () => ({ + scrollTopBefore: scroller.scrollTop, + requestedScrollTop: maxScrollTop, + heightDelta, + }), + }); + } scroller.scrollTop = maxScrollTop; } } @@ -1191,6 +1284,14 @@ const VirtualMessageListSession = forwardRef ({ heightDelta, currentScrollTop, previousScrollTop }), + }); + } previousScrollTopRef.current = currentScrollTop; recordScrollerGeometry(scroller); return; @@ -1237,9 +1338,17 @@ const VirtualMessageListSession = forwardRef ({ + heightDelta, + shrinkAmount, + distanceFromBottom, + hasValidCollapseIntent, + fallbackRequiredCollapseCompensation, + currentTotalCompensation, + nextTotalCompensation, + reservationBefore: bottomReservationStateRef.current, + reservationAfter: nextReservationState, + }), + }); + } updateBottomReservationState(nextReservationState); if (nextTotalCompensation > COMPENSATION_EPSILON_PX) { const anchorTarget = @@ -2151,14 +2277,46 @@ const VirtualMessageListSession = forwardRef ({ + reason, + intent, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }), + }); + } + clearCollapseIntentScheduling(); pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + const coordinatorMode = viewportCoordinatorRef.current.getMode(); const preserveReservation = shouldPreserveCollapseReservationAfterIntent({ isFollowingOutput: isFollowingOutputRef.current, isStreamingOutput: isStreamingOutputRef.current, + isPreservingElement: coordinatorMode === 'preserving-element', + hasProtectedCollapseRange: + bottomReservationStateRef.current.collapse.floorPx > COMPENSATION_EPSILON_PX, }); + const scroller = scrollerElementRef.current; const nextState = preserveReservation - ? bottomReservationStateRef.current + ? coordinatorMode === 'preserving-element' + ? scroller + ? settleCollapseReservationForPreservedViewport( + bottomReservationStateRef.current, + { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }, + ) + : protectCurrentCollapseReservation(bottomReservationStateRef.current) + : bottomReservationStateRef.current : drainCollapseReservationPreservingPinnedItem(reason); if (nextState === null) { pendingCollapseIntentRef.current = intent; @@ -2172,6 +2330,27 @@ const VirtualMessageListSession = forwardRef { return ((...args: unknown[]) => { - let requestedScrollByTop: number | null = null; - if (method === 'scrollBy') { - const firstArg = args[0]; - if (typeof firstArg === 'number' && typeof args[1] === 'number') { - requestedScrollByTop = args[1]; - } else if ( - firstArg !== null && - typeof firstArg === 'object' && - 'top' in firstArg && - typeof firstArg.top === 'number' - ) { - requestedScrollByTop = firstArg.top; - } + let requestedTop: number | null = null; + const firstArg = args[0]; + if (typeof firstArg === 'number' && typeof args[1] === 'number') { + requestedTop = args[1]; + } else if ( + firstArg !== null && + typeof firstArg === 'object' && + 'top' in firstArg && + typeof firstArg.top === 'number' + ) { + requestedTop = firstArg.top; } const previousGeometry = previousScrollerGeometryRef.current; const previousMaxScrollTop = previousGeometry @@ -2268,16 +2452,41 @@ const VirtualMessageListSession = forwardRef ({ + method, + requestedTop, + suppressVirtualizerCompensation, + hasSemanticAnchor, + wasAtPhysicalBottomBeforeScrollBy, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + scrollTopBefore, + scrollTopAfter: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }), + }); + } }) as typeof el.scrollTo; }; const wrappedScrollTo = typeof originalScrollTo === 'function' @@ -2653,6 +2862,30 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX + ) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.handleScroll', + message: 'Scroller position changed', + data: () => ({ + scrollTop: intentCheckScrollTop, + previousScrollTop: intentCheckPreviousScrollTop, + scrollDelta: intentCheckScrollDelta, + scrollHeight: scrollerElement.scrollHeight, + clientHeight: scrollerElement.clientHeight, + hasRecentUserUpwardIntent, + scrollbarPointerInteractionActive: scrollbarPointerInteractionActiveRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + collapseProtectionActive, + }), + }); + } if ( intentCheckScrollDelta < -COMPENSATION_EPSILON_PX && isFollowingOutputRef.current && @@ -2660,6 +2893,18 @@ const VirtualMessageListSession = forwardRef ({ + scrollTop: intentCheckScrollTop, + previousScrollTop: intentCheckPreviousScrollTop, + scrollDelta: intentCheckScrollDelta, + }), + }); + } // Follow+streaming: do not inject compensation or restore old // scrollTop. Let the follow loop handle the scroll naturally on the // next animation frame. Return here to prevent the downstream follow @@ -2710,6 +2955,18 @@ const VirtualMessageListSession = forwardRef { + if (flowChatDiagnostics.isEnabled() && event.deltaY !== 0) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.handleWheel', + message: 'Wheel scroll intent observed', + data: () => ({ + deltaY: event.deltaY, + scrollTop: scrollerElement.scrollTop, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } if (event.deltaY !== 0) { notifyUserScrollIntent(); clearTurnPinRequest(); @@ -2737,6 +2994,14 @@ const VirtualMessageListSession = forwardRef TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.handleTouchMove', + message: 'Touch scroll intent crossed the exit threshold', + data: () => ({ deltaY: currentY - startY, scrollTop: scrollerElement.scrollTop }), + }); + } notifyUserScrollIntent(); clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); @@ -2762,6 +3027,14 @@ const VirtualMessageListSession = forwardRef ({ key: event.key, scrollTop: scrollerElement.scrollTop }), + }); + } clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); @@ -2786,6 +3059,14 @@ const VirtualMessageListSession = forwardRef ({ scrollTop: scrollerElement.scrollTop }), + }); + } notifyUserScrollIntent(); clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); @@ -2807,6 +3088,14 @@ const VirtualMessageListSession = forwardRef ({ scrollTop: scrollerElement.scrollTop }), + }); + } notifyUserScrollIntent(); cancelLatestEndAnchorStabilization(); staticInitialHistoryUserLeftBottomRef.current = true; @@ -2848,6 +3137,28 @@ const VirtualMessageListSession = forwardRef).detail; const previousIntent = pendingCollapseIntentRef.current; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'E', + location: 'VirtualMessageList.handleToolCardCollapseIntent', + message: 'Tool card collapse intent received', + data: () => ({ + reason: detail?.reason ?? null, + toolName: detail?.toolName ?? null, + cardHeight: detail?.cardHeight ?? null, + hasAnchorElement: Boolean(detail?.anchorElement), + anchorElementConnected: detail?.anchorElement?.isConnected ?? null, + previousIntent, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + scrollTop: scrollerElement.scrollTop, + scrollHeight: scrollerElement.scrollHeight, + clientHeight: scrollerElement.clientHeight, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }), + }); + } // Coalesce overlapping collapses instead of finalizing the previous // intent. Finalizing mid-animation can briefly drop footer protection and // flash the pane when several cards compact in the same stream burst. @@ -2901,13 +3212,30 @@ const VirtualMessageListSession = forwardRef ({ + nextIntent, + baseTotalCompensationPx, + currentTotalCompensationPx, + provisionalTotalCompensationPx, + effectiveDistanceFromBottom, + estimatedShrink, + currentScrollTop, + previousStableScrollTop, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } if (provisionalTotalCompensationPx - baseTotalCompensationPx > COMPENSATION_EPSILON_PX) { const nextReservationState: BottomReservationState = { ...bottomReservationStateRef.current, collapse: { ...bottomReservationStateRef.current.collapse, px: Math.max(0, provisionalTotalCompensationPx - getReservationTotalPx(bottomReservationStateRef.current.pin)), - floorPx: 0, }, }; updateBottomReservationState(nextReservationState); @@ -3407,6 +3735,24 @@ const VirtualMessageListSession = forwardRef ({ + reason, + preserveCurrentRange: options?.preserveCurrentRange === true, + hasActivePin, + reservationBefore: currentState, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + }), + }); + } + clearTurnPinRequest(); clearPendingStickyPinGrowth(reason); @@ -3414,22 +3760,31 @@ const VirtualMessageListSession = forwardRef ({ + reason, + preserveCurrentRange: options?.preserveCurrentRange === true, + reservationBefore: currentState, + reservationAfter: nextReservationState, + }), + }); + } updateBottomReservationState(nextReservationState); applyFooterCompensationNow(nextReservationState); @@ -3448,6 +3803,18 @@ const VirtualMessageListSession = forwardRef { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.exitPinnedViewportForUserIntent', + message: 'User intent exited the pinned viewport', + data: () => ({ + reason, + coordinatorModeBefore: viewportCoordinatorRef.current.getMode(), + reservationBefore: bottomReservationStateRef.current, + }), + }); + } viewportCoordinatorRef.current.release(reason); clearPinReservationForUserNavigation(reason, { preserveCurrentRange: true }); }; @@ -3903,7 +4270,7 @@ const VirtualMessageListSession = forwardRef { + const maybeHandoffPinnedTurnToTail = useCallback((reason: string) => { const trackingState = latestTurnAutoFollowStateRef.current; if ( !latestTurnId || @@ -3942,10 +4309,31 @@ const VirtualMessageListSession = forwardRef ({ + reason, + reservation: reservationState, + hasPendingCollapseIntent, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } return false; } if (activateArmedFollowOutput()) { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'VirtualMessageList.maybeHandoffPinnedTurnToTail', + message: 'Pinned item handed off to tail follow', + data: () => ({ reason, reservation: reservationState }), + }); + } latestTurnAutoFollowStateRef.current = { turnId: null, sawPositiveFloor: false, @@ -3983,6 +4371,19 @@ const VirtualMessageListSession = forwardRef { + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'D', + location: 'VirtualMessageList.followOutputEffect', + message: 'Follow output state synchronized with the viewport coordinator', + data: () => ({ + isFollowingOutput, + isStreamingOutput, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + }), + }); + } if (isFollowingOutput) { viewportCoordinatorRef.current.followTail({ force: true }); return; diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts b/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts index 3759732140..78536daa8c 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts @@ -40,6 +40,95 @@ export function transferCollapseReservationToPin( }; } +export function transferPinReservationToProtectedCollapse( + currentState: BottomReservationState, +): BottomReservationState { + const transferredPx = getReservationTotalPx(currentState.pin); + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + px: currentState.collapse.px + transferredPx, + floorPx: currentState.collapse.floorPx + transferredPx, + }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }); +} + +export function protectCurrentCollapseReservation( + currentState: BottomReservationState, +): BottomReservationState { + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + floorPx: currentState.collapse.px, + }, + }); +} + +export function settleCollapseReservationForPreservedViewport( + currentState: BottomReservationState, + geometry: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + rangeGuardPx?: number; + }, +): BottomReservationState { + const currentTotalPx = getReservationTotalPx(currentState.collapse) + + getReservationTotalPx(currentState.pin); + const contentHeightWithoutReservation = Math.max( + 0, + geometry.scrollHeight - currentTotalPx, + ); + const requiredTotalPx = Math.max( + 0, + geometry.scrollTop + geometry.clientHeight - contentHeightWithoutReservation + + sanitizeReservationPx(geometry.rangeGuardPx ?? 1), + ); + const protectedTotalPx = Math.max( + getReservationTotalPx(currentState.pin) + currentState.collapse.floorPx, + requiredTotalPx, + ); + const settledTotalPx = Math.min(currentTotalPx, protectedTotalPx); + const settledCollapsePx = Math.max( + 0, + settledTotalPx - getReservationTotalPx(currentState.pin), + ); + + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + px: settledCollapsePx, + floorPx: settledCollapsePx, + }, + }); +} + +export function reconcileUnsignaledShrinkReservation( + currentState: BottomReservationState, + fallbackRequiredCollapsePx: number, +): BottomReservationState { + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + px: Math.max( + currentState.collapse.floorPx, + sanitizeReservationPx(fallbackRequiredCollapsePx), + ), + }, + }); +} + export function createInitialBottomReservationState(): BottomReservationState { return { collapse: { @@ -116,8 +205,13 @@ export function consumeBottomReservationForContentGrowth( const collapseConsumablePx = preserveCollapseReservation ? 0 : getReservationConsumablePx(state.collapse); - const collapseConsumed = Math.min(collapseConsumablePx, remaining); - remaining -= collapseConsumed; + const collapseAboveFloorConsumed = Math.min(collapseConsumablePx, remaining); + remaining -= collapseAboveFloorConsumed; + const collapseFloorConsumed = preserveCollapseReservation + ? 0 + : Math.min(state.collapse.floorPx, remaining); + const collapseConsumed = collapseAboveFloorConsumed + collapseFloorConsumed; + remaining -= collapseFloorConsumed; const pinConsumablePx = getReservationConsumablePx(state.pin); const pinConsumed = Math.min(pinConsumablePx, remaining); @@ -131,6 +225,7 @@ export function consumeBottomReservationForContentGrowth( collapse: { ...state.collapse, px: state.collapse.px - collapseConsumed, + floorPx: state.collapse.floorPx - collapseFloorConsumed, }, pin: { ...state.pin, @@ -198,8 +293,14 @@ export function shouldBypassShrinkCompensationInTailFollow(options: { export function shouldPreserveCollapseReservationAfterIntent(options: { isFollowingOutput: boolean; isStreamingOutput: boolean; + isPreservingElement: boolean; + hasProtectedCollapseRange: boolean; }): boolean { - return options.isFollowingOutput && options.isStreamingOutput; + return ( + (options.isFollowingOutput && options.isStreamingOutput) || + options.isPreservingElement || + options.hasProtectedCollapseRange + ); } export function resolveAutoCollapseAnchorScrollTop(options: { diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index 0833850131..ee27fef826 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -338,6 +338,7 @@ function BasicsLoggingSection() { const { t } = useTranslation('settings/basics'); const [configLevel, setConfigLevel] = useState('info'); const [includeSensitiveDiagnostics, setIncludeSensitiveDiagnostics] = useState(true); + const [flowChatDiagnostics, setFlowChatDiagnostics] = useState(false); const [runtimeInfo, setRuntimeInfo] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -366,14 +367,16 @@ function BasicsLoggingSection() { try { setLoading(true); - const [savedLevel, savedIncludeSensitiveDiagnostics, info] = await Promise.all([ + const [savedLevel, savedIncludeSensitiveDiagnostics, savedFlowChatDiagnostics, info] = await Promise.all([ configManager.getConfig('app.logging.level'), configManager.getConfig('app.logging.include_sensitive_diagnostics'), + configManager.getConfig('app.logging.flow_chat_diagnostics'), configAPI.getRuntimeLoggingInfo(), ]); setConfigLevel(savedLevel || info.effectiveLevel || 'info'); setIncludeSensitiveDiagnostics(savedIncludeSensitiveDiagnostics ?? true); + setFlowChatDiagnostics(savedFlowChatDiagnostics ?? false); setRuntimeInfo(info); } catch (error) { log.error('Failed to load logging config', error); @@ -433,6 +436,27 @@ function BasicsLoggingSection() { [includeSensitiveDiagnostics, showMessage, t] ); + const handleFlowChatDiagnosticsChange = useCallback( + async (checked: boolean) => { + const previousValue = flowChatDiagnostics; + setFlowChatDiagnostics(checked); + setSaving(true); + + try { + await configManager.setConfig('app.logging.flow_chat_diagnostics', checked); + configManager.clearCache(); + showMessage('success', t('logging.messages.flowChatDiagnosticsUpdated')); + } catch (error) { + setFlowChatDiagnostics(previousValue); + log.error('Failed to update Flow Chat diagnostics preference', { checked, error }); + showMessage('error', t('logging.messages.saveFailed')); + } finally { + setSaving(false); + } + }, + [flowChatDiagnostics, showMessage, t] + ); + const handleOpenFolder = useCallback(async () => { const folder = runtimeInfo?.sessionLogDir; if (!folder) { @@ -523,6 +547,19 @@ function BasicsLoggingSection() { disabled={saving} /> + + { + void handleFlowChatDiagnosticsChange(e.target.checked); + }} + disabled={saving} + /> + ({ warn: vi.fn(), error: vi.fn(), setIncludeSensitiveDiagnostics: vi.fn(), + setFlowChatDiagnosticsEnabled: vi.fn(), })); vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ @@ -40,8 +41,13 @@ vi.mock('@/shared/utils/logger', () => ({ setIncludeSensitiveDiagnostics: loggerMocks.setIncludeSensitiveDiagnostics, })); +vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ + setFlowChatDiagnosticsEnabled: loggerMocks.setFlowChatDiagnosticsEnabled, +})); + const LOGGING_LEVEL_PATH = 'app.logging.level'; const LOGGING_INCLUDE_SENSITIVE_PATH = 'app.logging.include_sensitive_diagnostics'; +const FLOW_CHAT_DIAGNOSTICS_PATH = 'app.logging.flow_chat_diagnostics'; async function importSyncModule() { vi.resetModules(); @@ -59,6 +65,7 @@ describe('FrontendLogLevelSync startup reads', () => { configApiMocks.getConfigs.mockResolvedValueOnce({ [LOGGING_LEVEL_PATH]: 'debug', [LOGGING_INCLUDE_SENSITIVE_PATH]: false, + [FLOW_CHAT_DIAGNOSTICS_PATH]: true, }); const { initializeFrontendLogLevelSync } = await importSyncModule(); @@ -68,17 +75,20 @@ describe('FrontendLogLevelSync startup reads', () => { expect(configApiMocks.getConfigs).toHaveBeenCalledWith([ LOGGING_LEVEL_PATH, LOGGING_INCLUDE_SENSITIVE_PATH, + FLOW_CHAT_DIAGNOSTICS_PATH, ]); expect(configApiMocks.getConfig).not.toHaveBeenCalled(); expect(configApiMocks.getRuntimeLoggingInfo).toHaveBeenCalledTimes(1); expect(loggerMocks.setLevel).toHaveBeenCalledWith(1); expect(loggerMocks.setIncludeSensitiveDiagnostics).toHaveBeenCalledWith(false); + expect(loggerMocks.setFlowChatDiagnosticsEnabled).toHaveBeenCalledWith(true); }); it('falls back to the runtime log level when the saved frontend level is invalid', async () => { configApiMocks.getConfigs.mockResolvedValueOnce({ [LOGGING_LEVEL_PATH]: 'verbose', [LOGGING_INCLUDE_SENSITIVE_PATH]: true, + [FLOW_CHAT_DIAGNOSTICS_PATH]: false, }); configApiMocks.getRuntimeLoggingInfo.mockResolvedValueOnce({ effectiveLevel: 'error' }); @@ -87,5 +97,6 @@ describe('FrontendLogLevelSync startup reads', () => { expect(loggerMocks.setLevel).toHaveBeenCalledWith(4); expect(loggerMocks.setIncludeSensitiveDiagnostics).toHaveBeenCalledWith(true); + expect(loggerMocks.setFlowChatDiagnosticsEnabled).toHaveBeenCalledWith(false); }); }); diff --git a/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts b/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts index 5311a5f5d6..299142a24a 100644 --- a/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts +++ b/src/web-ui/src/infrastructure/config/services/FrontendLogLevelSync.ts @@ -6,10 +6,12 @@ import { setIncludeSensitiveDiagnostics, } from '@/shared/utils/logger'; import type { BackendLogLevel } from '../types'; +import { setFlowChatDiagnosticsEnabled } from '@/infrastructure/diagnostics/flowChatDiagnostics'; const log = createLogger('FrontendLogLevelSync'); const LOGGING_LEVEL_PATH = 'app.logging.level'; const LOGGING_INCLUDE_SENSITIVE_PATH = 'app.logging.include_sensitive_diagnostics'; +const FLOW_CHAT_DIAGNOSTICS_PATH = 'app.logging.flow_chat_diagnostics'; let initialSettingsLoaded = false; let configWatcherInstalled = false; @@ -17,6 +19,7 @@ let configWatcherInstalled = false; interface InitialLoggingSettings { level?: string; includeSensitiveDiagnostics: boolean; + flowChatDiagnostics: boolean; } function toFrontendLogLevel(level: string | null | undefined): LogLevel | null { @@ -82,6 +85,7 @@ async function resolveInitialLoggingSettings(): Promise configAPI.getConfigs([ LOGGING_LEVEL_PATH, LOGGING_INCLUDE_SENSITIVE_PATH, + FLOW_CHAT_DIAGNOSTICS_PATH, ]), configAPI.getRuntimeLoggingInfo(), ]); @@ -99,6 +103,7 @@ async function resolveInitialLoggingSettings(): Promise typeof configs[LOGGING_INCLUDE_SENSITIVE_PATH] === 'boolean' ? configs[LOGGING_INCLUDE_SENSITIVE_PATH] : true, + flowChatDiagnostics: configs[FLOW_CHAT_DIAGNOSTICS_PATH] === true, }; } @@ -111,6 +116,7 @@ async function resolveInitialLoggingSettings(): Promise typeof configs[LOGGING_INCLUDE_SENSITIVE_PATH] === 'boolean' ? configs[LOGGING_INCLUDE_SENSITIVE_PATH] : true, + flowChatDiagnostics: configs[FLOW_CHAT_DIAGNOSTICS_PATH] === true, }; } } @@ -120,6 +126,7 @@ async function resolveInitialLoggingSettings(): Promise typeof configs[LOGGING_INCLUDE_SENSITIVE_PATH] === 'boolean' ? configs[LOGGING_INCLUDE_SENSITIVE_PATH] : true, + flowChatDiagnostics: configs[FLOW_CHAT_DIAGNOSTICS_PATH] === true, }; } @@ -134,6 +141,7 @@ export async function initializeFrontendLogLevelSync(): Promise { const initialSettings = await resolveInitialLoggingSettings(); applyFrontendLogLevel(initialSettings.level, 'startup'); setIncludeSensitiveDiagnostics(initialSettings.includeSensitiveDiagnostics); + setFlowChatDiagnosticsEnabled(initialSettings.flowChatDiagnostics); } catch (error) { log.error('Failed to initialize frontend log level sync', error); } @@ -156,6 +164,11 @@ export async function installFrontendLogLevelConfigWatcher(): Promise { if (path === LOGGING_INCLUDE_SENSITIVE_PATH) { setIncludeSensitiveDiagnostics(typeof newValue === 'boolean' ? newValue : true); + return; + } + + if (path === FLOW_CHAT_DIAGNOSTICS_PATH) { + setFlowChatDiagnosticsEnabled(newValue === true); } }); } catch (error) { diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index ea3c26332d..66ed91072b 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -108,6 +108,7 @@ export interface ModelExchangeTracingConfig { export interface AppLoggingConfig { level: BackendLogLevel; include_sensitive_diagnostics: boolean; + flow_chat_diagnostics: boolean; model_exchange_tracing: ModelExchangeTracingConfig; } @@ -692,6 +693,7 @@ export interface RuntimeLoggingInfo { aiLogPath: string; flashgrepLogPath: string; webviewLogPath: string; + flowChatLogPath: string; previousUnexpectedExit?: UnexpectedExitInfo | null; } diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts new file mode 100644 index 0000000000..9e13537b85 --- /dev/null +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const transportMocks = vi.hoisted(() => ({ + append: vi.fn(async () => undefined), +})); + +vi.mock('./flowChatDiagnosticsTransport', () => ({ + appendFlowChatDiagnosticEntries: transportMocks.append, +})); + +vi.mock('@/infrastructure/runtime', () => ({ + isTauriRuntime: () => true, +})); + +import { flowChatDiagnostics } from './flowChatDiagnostics'; +import type { FlowChatDiagnosticTransportEntry } from './flowChatDiagnosticsTransport'; + +describe('flowChatDiagnostics', () => { + beforeEach(() => { + vi.useFakeTimers(); + transportMocks.append.mockClear(); + flowChatDiagnostics.resetForTests(); + }); + + afterEach(() => { + flowChatDiagnostics.resetForTests(); + vi.useRealTimers(); + }); + + it('does not evaluate diagnostic payloads while disabled', () => { + const data = vi.fn(() => ({ scrollTop: 120 })); + + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'test.disabled', + message: 'Disabled probe', + data, + }); + vi.runAllTimers(); + + expect(data).not.toHaveBeenCalled(); + expect(transportMocks.append).not.toHaveBeenCalled(); + }); + + it('batches enabled diagnostics with ordered structured metadata', async () => { + flowChatDiagnostics.setEnabled(true); + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'test.enabled', + message: 'Enabled probe', + data: () => ({ scrollTop: 240 }), + }); + + await vi.advanceTimersByTimeAsync(150); + + expect(transportMocks.append).toHaveBeenCalledTimes(1); + const entries = transportMocks.append.mock.calls[0]?.[0]; + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + sequence: 1, + hypothesis: 'I', + location: 'FlowChatDiagnosticsRecorder.setEnabled', + }); + expect(entries[1]).toMatchObject({ + sequence: 2, + hypothesis: 'A', + location: 'test.enabled', + message: 'Enabled probe', + data: { scrollTop: 240 }, + }); + }); + + it('flushes queued diagnostics when disabled', async () => { + flowChatDiagnostics.setEnabled(true); + flowChatDiagnostics.trace({ + hypothesis: 'B', + location: 'test.disable-flush', + message: 'Queued probe', + }); + + flowChatDiagnostics.setEnabled(false); + await Promise.resolve(); + await Promise.resolve(); + + expect(transportMocks.append).toHaveBeenCalledTimes(1); + expect(flowChatDiagnostics.isEnabled()).toBe(false); + }); + + it('coalesces flush requests and preserves sequence order after queue overflow', async () => { + let resolveFirstWrite!: () => void; + transportMocks.append.mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstWrite = resolve; + })); + + flowChatDiagnostics.setEnabled(true); + for (let index = 0; index < 127; index += 1) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'test.first-batch', + message: `First batch ${index}`, + }); + } + expect(transportMocks.append).toHaveBeenCalledTimes(1); + + const firstDrain = flowChatDiagnostics.flushNow(); + const secondDrain = flowChatDiagnostics.flushNow(); + expect(secondDrain).toBe(firstDrain); + + for (let index = 0; index < 1_152; index += 1) { + flowChatDiagnostics.trace({ + hypothesis: 'A', + location: 'test.overflow', + message: `Overflow ${index}`, + }); + } + expect(transportMocks.append).toHaveBeenCalledTimes(1); + + resolveFirstWrite(); + await firstDrain; + + const entries = transportMocks.append.mock.calls.flatMap( + call => call[0] as FlowChatDiagnosticTransportEntry[], + ); + expect(entries.length).toBeGreaterThan(128); + for (let index = 1; index < entries.length; index += 1) { + expect(entries[index]!.sequence).toBeGreaterThan(entries[index - 1]!.sequence); + } + + const droppedMarker = entries.find( + entry => entry.location === 'FlowChatDiagnosticsRecorder.flush', + ); + expect(droppedMarker).toMatchObject({ + sequence: 256, + data: { + droppedEntries: 128, + firstDroppedSequence: 129, + lastDroppedSequence: 256, + }, + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts new file mode 100644 index 0000000000..ff790c3b3b --- /dev/null +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts @@ -0,0 +1,276 @@ +import { + appendFlowChatDiagnosticEntries, + type FlowChatDiagnosticTransportEntry, +} from './flowChatDiagnosticsTransport'; +import { isTauriRuntime } from '@/infrastructure/runtime'; + +const FLUSH_INTERVAL_MS = 150; +const FLUSH_BATCH_SIZE = 128; +const MAX_QUEUED_ENTRIES = 1024; + +export interface FlowChatDiagnosticProbe { + hypothesis: string; + location: string; + message: string; + data?: () => Record; +} + +interface DroppedEntrySummary { + count: number; + firstSequence: number; + lastSequence: number; + lastTimestamp: string; + lastPerformanceTimeMs: number; +} + +class FlowChatDiagnosticsRecorder { + private enabled = false; + private sequence = 0; + private queue: FlowChatDiagnosticTransportEntry[] = []; + private flushTimer: number | null = null; + private flushInFlight: Promise | null = null; + private flushRequested = false; + private droppedEntrySummary: DroppedEntrySummary | null = null; + + isEnabled(): boolean { + return this.enabled; + } + + setEnabled(enabled: boolean): void { + const supportedEnabled = enabled && isTauriRuntime(); + if (this.enabled === supportedEnabled) { + return; + } + + this.enabled = supportedEnabled; + if (supportedEnabled) { + window.addEventListener('pagehide', this.handlePageHide); + this.trace({ + hypothesis: 'I', + location: 'FlowChatDiagnosticsRecorder.setEnabled', + message: 'Flow Chat diagnostics enabled', + }); + return; + } + + window.removeEventListener('pagehide', this.handlePageHide); + this.clearFlushTimer(); + void this.flush(); + } + + trace(probe: FlowChatDiagnosticProbe): void { + if (!this.enabled) { + return; + } + + let data: Record | undefined; + if (probe.data) { + try { + data = probe.data(); + } catch (error) { + data = { + probeDataError: error instanceof Error ? error.message : String(error), + }; + } + } + + const entry: FlowChatDiagnosticTransportEntry = { + sequence: ++this.sequence, + timestamp: new Date().toISOString(), + performanceTimeMs: typeof performance === 'undefined' ? 0 : performance.now(), + hypothesis: probe.hypothesis, + location: probe.location, + message: probe.message, + ...(data ? { data } : {}), + }; + + if (this.queue.length >= MAX_QUEUED_ENTRIES) { + const droppedEntry = this.queue.shift(); + if (droppedEntry) { + this.recordDroppedEntries([droppedEntry]); + } + } + this.queue.push(entry); + + if (this.queue.length >= FLUSH_BATCH_SIZE) { + this.clearFlushTimer(); + void this.flush(); + return; + } + this.scheduleFlush(); + } + + flushNow(): Promise { + this.clearFlushTimer(); + return this.flush(); + } + + resetForTests(): void { + this.enabled = false; + this.sequence = 0; + this.queue = []; + this.flushRequested = false; + this.droppedEntrySummary = null; + this.clearFlushTimer(); + this.flushInFlight = null; + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', this.handlePageHide); + } + } + + private scheduleFlush(): void { + if (this.flushTimer !== null || typeof window === 'undefined') { + return; + } + this.flushTimer = window.setTimeout(() => { + this.flushTimer = null; + void this.flush(); + }, FLUSH_INTERVAL_MS); + } + + private clearFlushTimer(): void { + if (this.flushTimer === null || typeof window === 'undefined') { + this.flushTimer = null; + return; + } + window.clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + private mergeDroppedEntrySummary(summary: DroppedEntrySummary): void { + const current = this.droppedEntrySummary; + if (!current) { + this.droppedEntrySummary = summary; + return; + } + + this.droppedEntrySummary = { + count: current.count + summary.count, + firstSequence: Math.min(current.firstSequence, summary.firstSequence), + lastSequence: Math.max(current.lastSequence, summary.lastSequence), + lastTimestamp: summary.lastSequence >= current.lastSequence + ? summary.lastTimestamp + : current.lastTimestamp, + lastPerformanceTimeMs: summary.lastSequence >= current.lastSequence + ? summary.lastPerformanceTimeMs + : current.lastPerformanceTimeMs, + }; + } + + private recordDroppedEntries(entries: FlowChatDiagnosticTransportEntry[]): void { + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); + if (!firstEntry || !lastEntry) { + return; + } + + this.mergeDroppedEntrySummary({ + count: entries.length, + firstSequence: firstEntry.sequence, + lastSequence: lastEntry.sequence, + lastTimestamp: lastEntry.timestamp, + lastPerformanceTimeMs: lastEntry.performanceTimeMs, + }); + } + + private takeDroppedEntrySummary(): DroppedEntrySummary | null { + const summary = this.droppedEntrySummary; + this.droppedEntrySummary = null; + return summary; + } + + private createDroppedEntryMarker( + summary: DroppedEntrySummary, + ): FlowChatDiagnosticTransportEntry { + return { + sequence: summary.lastSequence, + timestamp: summary.lastTimestamp, + performanceTimeMs: summary.lastPerformanceTimeMs, + hypothesis: 'I', + location: 'FlowChatDiagnosticsRecorder.flush', + message: 'Flow Chat diagnostic entries dropped before flush', + data: { + droppedEntries: summary.count, + firstDroppedSequence: summary.firstSequence, + lastDroppedSequence: summary.lastSequence, + }, + }; + } + + private flush = (): Promise => { + this.flushRequested = true; + if (this.flushInFlight) { + return this.flushInFlight; + } + + this.flushInFlight = this.drainQueue().finally(() => { + this.flushInFlight = null; + if (this.flushRequested) { + void this.flush(); + } + }); + return this.flushInFlight; + }; + + private drainQueue = async (): Promise => { + while (true) { + this.flushRequested = false; + if (this.queue.length === 0 && !this.droppedEntrySummary) { + return; + } + + const droppedSummary = this.takeDroppedEntrySummary(); + const normalBatchSize = droppedSummary + ? FLUSH_BATCH_SIZE - 1 + : FLUSH_BATCH_SIZE; + const normalEntries = this.queue.splice(0, normalBatchSize); + const batch = droppedSummary + ? [this.createDroppedEntryMarker(droppedSummary), ...normalEntries] + : normalEntries; + + try { + await appendFlowChatDiagnosticEntries(batch); + } catch { + if (droppedSummary) { + this.mergeDroppedEntrySummary(droppedSummary); + } + this.recordDroppedEntries(normalEntries); + this.flushRequested = false; + if (this.enabled && this.queue.length > 0) { + this.scheduleFlush(); + } + return; + } + + const hasPendingEntries = this.queue.length > 0 || Boolean(this.droppedEntrySummary); + if (!hasPendingEntries) { + return; + } + if ( + !this.enabled || + this.flushRequested || + this.queue.length >= FLUSH_BATCH_SIZE + ) { + continue; + } + + this.scheduleFlush(); + return; + } + }; + + private handlePageHide = (): void => { + this.clearFlushTimer(); + void this.flush(); + }; +} + +export const flowChatDiagnostics = new FlowChatDiagnosticsRecorder(); + +export function setFlowChatDiagnosticsEnabled(enabled: boolean): void { + flowChatDiagnostics.setEnabled(enabled); +} + +export function isFlowChatDiagnosticsEnabled(): boolean { + return flowChatDiagnostics.isEnabled(); +} diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts new file mode 100644 index 0000000000..c08c68e098 --- /dev/null +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts @@ -0,0 +1,24 @@ +import { isTauriRuntime } from '@/infrastructure/runtime'; + +export interface FlowChatDiagnosticTransportEntry { + sequence: number; + timestamp: string; + performanceTimeMs: number; + hypothesis: string; + location: string; + message: string; + data?: Record; +} + +export async function appendFlowChatDiagnosticEntries( + entries: FlowChatDiagnosticTransportEntry[], +): Promise { + if (entries.length === 0 || !isTauriRuntime()) { + return; + } + + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('append_flow_chat_diagnostics', { + request: { entries }, + }); +} diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index 9bcc49c8ac..0d4a911bcb 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -128,6 +128,10 @@ "label": "Include sensitive diagnostic data", "description": "When enabled, local logs may include prompts, model request and response payloads, tool arguments, file paths, and webview event payloads for troubleshooting. BitFun does not upload these log files automatically." }, + "flowChatDiagnostics": { + "label": "Flow Chat viewport diagnostics", + "description": "Record detailed local scroll, anchor, and layout events to flowchat.log. Enable only while reproducing a Flow Chat stability issue." + }, "path": { "description": "Directory where log files for this run are written." }, @@ -168,6 +172,7 @@ "saveFailed": "Failed to save logging level", "levelUpdated": "Logging level updated", "sensitiveDiagnosticsUpdated": "Sensitive diagnostic logging preference updated", + "flowChatDiagnosticsUpdated": "Flow Chat diagnostics preference updated", "refreshed": "Runtime status refreshed", "openedFolder": "Log folder opened", "openFailed": "Failed to open log folder", diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 4fff0d54cd..e2d4373d7c 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -128,6 +128,10 @@ "label": "包含敏感调试信息", "description": "开启后,本地日志可能包含用户 Prompt、模型请求和响应 payload、工具参数、文件路径以及 Webview 事件 payload,用于问题排查。BitFun 不会自动上传这些日志文件。" }, + "flowChatDiagnostics": { + "label": "Flow Chat 视口诊断", + "description": "将详细滚动、锚点和布局事件记录到 flowchat.log。仅在复现 Flow Chat 稳定性问题时开启。" + }, "path": { "description": "本次启动写入日志文件所在的目录。" }, @@ -168,6 +172,7 @@ "saveFailed": "保存日志级别失败", "levelUpdated": "日志级别已更新", "sensitiveDiagnosticsUpdated": "敏感调试信息日志偏好已更新", + "flowChatDiagnosticsUpdated": "Flow Chat 诊断偏好已更新", "refreshed": "运行时状态已刷新", "openedFolder": "日志文件夹已打开", "openFailed": "打开日志文件夹失败", diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index 731e3ffbdc..52c5d6312a 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -114,6 +114,10 @@ "label": "包含敏感除錯資訊", "description": "開啟後,本機日誌可能包含使用者 Prompt、模型請求與回應 payload、工具參數、檔案路徑以及 Webview 事件 payload,用於問題排查。BitFun 不會自動上傳這些日誌檔案。" }, + "flowChatDiagnostics": { + "label": "Flow Chat 視口診斷", + "description": "將詳細捲動、錨點和版面事件記錄到 flowchat.log。僅在重現 Flow Chat 穩定性問題時開啟。" + }, "path": { "description": "本次啟動寫入日誌檔案所在的目錄。" }, @@ -154,6 +158,7 @@ "saveFailed": "儲存日誌級別失敗", "levelUpdated": "日誌級別已更新", "sensitiveDiagnosticsUpdated": "敏感除錯資訊日誌偏好已更新", + "flowChatDiagnosticsUpdated": "Flow Chat 診斷偏好已更新", "refreshed": "運行時狀態已重新整理", "openedFolder": "日誌資料夾已開啟", "openFailed": "開啟日誌資料夾失敗",