From fe48420a7e3c921281b26ac586b2ded022c437e9 Mon Sep 17 00:00:00 2001 From: bobleer Date: Tue, 10 Mar 2026 00:00:53 +0800 Subject: [PATCH 1/2] fix: relay server room disconnection --- src/apps/relay-server/src/relay/room.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/apps/relay-server/src/relay/room.rs b/src/apps/relay-server/src/relay/room.rs index 9122f86c..85a263ce 100644 --- a/src/apps/relay-server/src/relay/room.rs +++ b/src/apps/relay-server/src/relay/room.rs @@ -203,11 +203,17 @@ impl RoomManager { pub fn heartbeat(&self, conn_id: ConnId) -> bool { if let Some(room_id) = self.conn_to_room.get(&conn_id) { if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - if let Some(ref mut desktop) = room.desktop { - if desktop.conn_id == conn_id { - desktop.last_heartbeat = Utc::now().timestamp(); - return true; + let is_match = room + .desktop + .as_ref() + .map_or(false, |d| d.conn_id == conn_id); + if is_match { + let now = Utc::now().timestamp(); + room.last_activity = now; + if let Some(ref mut desktop) = room.desktop { + desktop.last_heartbeat = now; } + return true; } } } From eaef033827cb50e09b5eaa98ab6a9a048dc1b1e3 Mon Sep 17 00:00:00 2001 From: bobleer Date: Tue, 10 Mar 2026 08:33:46 +0800 Subject: [PATCH 2/2] fix: Hyperlinke and Input box --- .../src/agentic/execution/stream_processor.rs | 4 +- .../service/remote_connect/remote_server.rs | 35 +++++++ src/mobile-web/package-lock.json | 6 -- src/mobile-web/src/pages/ChatPage.tsx | 94 ++++++++++++++++++- .../flow_chat/components/RichTextInput.tsx | 8 +- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 2deba5ad..4acf7c2e 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -552,7 +552,9 @@ impl StreamProcessor { /// Handle thinking chunk async fn handle_thinking_chunk(&self, ctx: &mut StreamContext, thinking_content: String) { - ctx.has_effective_output = true; + // Thinking-only output does NOT count as "effective" for retry purposes: + // if the stream fails after producing only thinking (no text/tool calls), + // it is safe to retry because the model will re-think from scratch. ctx.full_thinking.push_str(&thinking_content); ctx.thinking_chunks_count += 1; 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 ec6d772d..28d29eee 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -848,6 +848,20 @@ impl RemoteSessionStateTracker { ) } + /// Seed initial turn state when the tracker is created after the + /// `DialogTurnStarted` event already fired (e.g. desktop-triggered turns). + /// Subsequent streaming events will be captured normally by the subscriber. + pub fn initialize_active_turn(&self, turn_id: String) { + let mut s = self.state.write().unwrap(); + if s.turn_id.is_none() { + s.turn_id = Some(turn_id); + s.turn_status = "active".to_string(); + s.session_state = "running".to_string(); + } + drop(s); + self.bump_version(); + } + /// Clear tracker state after the persisted historical message is confirmed /// available. Called by the poll handler to complete the atomic transition. pub fn finalize_completed_turn(&self) { @@ -1323,6 +1337,13 @@ pub fn get_global_dispatcher() -> Option> { impl RemoteExecutionDispatcher { /// Ensure a state tracker exists for the given session and return it. + /// + /// When the tracker is freshly created and the session already has an active + /// turn (e.g. a desktop-triggered dialog), the tracker is seeded with the + /// turn id so that `snapshot_active_turn()` immediately returns a valid + /// snapshot. Without this, a late-created tracker would miss the + /// `DialogTurnStarted` event and the mobile would see no active-turn + /// overlay until the turn completes. pub fn ensure_tracker(&self, session_id: &str) -> Arc { if let Some(tracker) = self.state_trackers.get(session_id) { return tracker.clone(); @@ -1336,6 +1357,20 @@ impl RemoteExecutionDispatcher { let sub_id = format!("remote_tracker_{}", session_id); coordinator.subscribe_internal(sub_id, tracker.clone()); info!("Registered state tracker for session {session_id}"); + + let session_mgr = coordinator.get_session_manager(); + if let Some(session) = session_mgr.get_session(session_id) { + if let crate::agentic::core::SessionState::Processing { + current_turn_id, .. + } = &session.state + { + tracker.initialize_active_turn(current_turn_id.clone()); + info!( + "Seeded tracker with existing active turn {} for session {}", + current_turn_id, session_id + ); + } + } } tracker diff --git a/src/mobile-web/package-lock.json b/src/mobile-web/package-lock.json index 51368066..d4e536c0 100644 --- a/src/mobile-web/package-lock.json +++ b/src/mobile-web/package-lock.json @@ -59,7 +59,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1571,7 +1570,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1673,7 +1671,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3285,7 +3282,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3607,7 +3603,6 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -3889,7 +3884,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 6cca4c2a..e7631362 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -89,6 +89,77 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => { const COMPUTER_LINK_PREFIX = 'computer://'; +const CODE_FILE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', + 'py', 'pyw', 'pyi', + 'rs', 'go', 'java', 'kt', 'kts', 'scala', 'groovy', + 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx', 'hh', + 'cs', 'rb', 'php', 'swift', + 'vue', 'svelte', + 'html', 'htm', 'css', 'scss', 'less', 'sass', + 'json', 'jsonc', 'yaml', 'yml', 'toml', 'xml', + 'md', 'mdx', 'rst', 'txt', + 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'sql', 'graphql', 'gql', 'proto', + 'lock', 'env', 'ini', 'cfg', 'conf', + 'cj', 'ets', + 'editorconfig', 'gitignore', + 'log', +]); + +const DOWNLOADABLE_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'odt', 'ods', 'odp', 'rtf', 'pages', 'numbers', 'key', + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tiff', 'tif', + 'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'dmg', 'iso', 'xz', + 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', + 'mp4', 'avi', 'mkv', 'mov', 'webm', 'wmv', 'flv', + 'csv', 'tsv', 'sqlite', 'db', 'parquet', + 'epub', 'mobi', + 'apk', 'ipa', 'exe', 'msi', 'deb', 'rpm', + 'ttf', 'otf', 'woff', 'woff2', +]); + +/** + * Detect local file links: absolute paths, file:// URLs, and relative paths + * pointing to downloadable files. Returns the file path or null. + * + * - Absolute paths (`/Users/.../file.pdf`): use CODE_FILE_EXTENSIONS blacklist + * - Relative paths (`report.pptx`, `./output.pdf`): use DOWNLOADABLE_EXTENSIONS whitelist + */ +function isLocalFileLink(href: string): string | null { + if (!href || href === '/') return null; + + let filePath: string; + if (href.startsWith('file://')) { + filePath = href.slice(7); + } else if (href.includes('://') || href.startsWith('#') || href.startsWith('//')) { + return null; + } else { + filePath = href; + } + + if (filePath.startsWith('/')) { + const segments = filePath.split('/').filter(Boolean); + if (segments.length < 2) return null; + } + + const fileName = filePath.split('/').pop() || ''; + const dotIdx = fileName.lastIndexOf('.'); + if (dotIdx <= 0) return null; + + const ext = fileName.slice(dotIdx + 1).toLowerCase(); + if (!ext) return null; + + if (filePath.startsWith('/')) { + if (CODE_FILE_EXTENSIONS.has(ext)) return null; + } else { + if (!DOWNLOADABLE_EXTENSIONS.has(ext)) return null; + } + + return filePath; +} + function formatFileSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; @@ -337,6 +408,20 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo ); } + // Local file path (e.g. /Users/.../report.pdf) → FileCard, excluding code files + if (onGetFileInfo && onFileDownload) { + const localPath = typeof href === 'string' ? isLocalFileLink(href) : null; + if (localPath) { + return ( + + ); + } + } + // Fallback: render as plain text for computer:// links without handler, // or as a regular link for http(s) links. if (typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://'))) { @@ -373,13 +458,14 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => { - // react-markdown v9 strips non-standard protocols by default. - // Preserve computer:// so our FileCard renderer receives the href intact. if (url.startsWith('computer://')) return url; - // Keep default-safe behaviour for everything else. - if (/^(https?|mailto|tel):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { + if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { return url; } + // Preserve relative paths without a protocol (e.g. "report.pptx", + // "./output.pdf"). Content is from our own AI so javascript:/data: + // injection is not a concern; those contain ':' and are blocked above. + if (!url.includes(':')) return url; return ''; }} > diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index e1f064aa..bac2f9d3 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -455,6 +455,8 @@ export const RichTextInput = React.forwardRef { const editor = internalRef.current; if (!editor) return; + + if (isComposingRef.current) return; // Detect template fill mode via placeholder elements const hasPlaceholders = editor.querySelector('.rich-text-placeholder') !== null; @@ -538,11 +540,7 @@ export const RichTextInput = React.forwardRef { - // Delay clearing to handle Safari's event ordering where - // compositionend fires before the final keydown(Enter) - setTimeout(() => { - isComposingRef.current = false; - }, 0); + isComposingRef.current = false; onCompositionEnd?.(); handleInput(); }, [handleInput, onCompositionEnd]);