Skip to content

Commit ff47581

Browse files
authored
Merge pull request #101 from bobleer/main
fix relay room disconnect and chat link/input handling
2 parents 2c617c3 + eaef033 commit ff47581

6 files changed

Lines changed: 141 additions & 20 deletions

File tree

‎src/apps/relay-server/src/relay/room.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,17 @@ impl RoomManager {
203203
pub fn heartbeat(&self, conn_id: ConnId) -> bool {
204204
if let Some(room_id) = self.conn_to_room.get(&conn_id) {
205205
if let Some(mut room) = self.rooms.get_mut(room_id.value()) {
206-
if let Some(ref mut desktop) = room.desktop {
207-
if desktop.conn_id == conn_id {
208-
desktop.last_heartbeat = Utc::now().timestamp();
209-
return true;
206+
let is_match = room
207+
.desktop
208+
.as_ref()
209+
.map_or(false, |d| d.conn_id == conn_id);
210+
if is_match {
211+
let now = Utc::now().timestamp();
212+
room.last_activity = now;
213+
if let Some(ref mut desktop) = room.desktop {
214+
desktop.last_heartbeat = now;
210215
}
216+
return true;
211217
}
212218
}
213219
}

‎src/crates/core/src/agentic/execution/stream_processor.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,9 @@ impl StreamProcessor {
552552

553553
/// Handle thinking chunk
554554
async fn handle_thinking_chunk(&self, ctx: &mut StreamContext, thinking_content: String) {
555-
ctx.has_effective_output = true;
555+
// Thinking-only output does NOT count as "effective" for retry purposes:
556+
// if the stream fails after producing only thinking (no text/tool calls),
557+
// it is safe to retry because the model will re-think from scratch.
556558
ctx.full_thinking.push_str(&thinking_content);
557559
ctx.thinking_chunks_count += 1;
558560

‎src/crates/core/src/service/remote_connect/remote_server.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,20 @@ impl RemoteSessionStateTracker {
848848
)
849849
}
850850

851+
/// Seed initial turn state when the tracker is created after the
852+
/// `DialogTurnStarted` event already fired (e.g. desktop-triggered turns).
853+
/// Subsequent streaming events will be captured normally by the subscriber.
854+
pub fn initialize_active_turn(&self, turn_id: String) {
855+
let mut s = self.state.write().unwrap();
856+
if s.turn_id.is_none() {
857+
s.turn_id = Some(turn_id);
858+
s.turn_status = "active".to_string();
859+
s.session_state = "running".to_string();
860+
}
861+
drop(s);
862+
self.bump_version();
863+
}
864+
851865
/// Clear tracker state after the persisted historical message is confirmed
852866
/// available. Called by the poll handler to complete the atomic transition.
853867
pub fn finalize_completed_turn(&self) {
@@ -1323,6 +1337,13 @@ pub fn get_global_dispatcher() -> Option<Arc<RemoteExecutionDispatcher>> {
13231337

13241338
impl RemoteExecutionDispatcher {
13251339
/// Ensure a state tracker exists for the given session and return it.
1340+
///
1341+
/// When the tracker is freshly created and the session already has an active
1342+
/// turn (e.g. a desktop-triggered dialog), the tracker is seeded with the
1343+
/// turn id so that `snapshot_active_turn()` immediately returns a valid
1344+
/// snapshot. Without this, a late-created tracker would miss the
1345+
/// `DialogTurnStarted` event and the mobile would see no active-turn
1346+
/// overlay until the turn completes.
13261347
pub fn ensure_tracker(&self, session_id: &str) -> Arc<RemoteSessionStateTracker> {
13271348
if let Some(tracker) = self.state_trackers.get(session_id) {
13281349
return tracker.clone();
@@ -1336,6 +1357,20 @@ impl RemoteExecutionDispatcher {
13361357
let sub_id = format!("remote_tracker_{}", session_id);
13371358
coordinator.subscribe_internal(sub_id, tracker.clone());
13381359
info!("Registered state tracker for session {session_id}");
1360+
1361+
let session_mgr = coordinator.get_session_manager();
1362+
if let Some(session) = session_mgr.get_session(session_id) {
1363+
if let crate::agentic::core::SessionState::Processing {
1364+
current_turn_id, ..
1365+
} = &session.state
1366+
{
1367+
tracker.initialize_active_turn(current_turn_id.clone());
1368+
info!(
1369+
"Seeded tracker with existing active turn {} for session {}",
1370+
current_turn_id, session_id
1371+
);
1372+
}
1373+
}
13391374
}
13401375

13411376
tracker

‎src/mobile-web/package-lock.json‎

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/mobile-web/src/pages/ChatPage.tsx‎

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,77 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => {
8989

9090
const COMPUTER_LINK_PREFIX = 'computer://';
9191

92+
const CODE_FILE_EXTENSIONS = new Set([
93+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts',
94+
'py', 'pyw', 'pyi',
95+
'rs', 'go', 'java', 'kt', 'kts', 'scala', 'groovy',
96+
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx', 'hh',
97+
'cs', 'rb', 'php', 'swift',
98+
'vue', 'svelte',
99+
'html', 'htm', 'css', 'scss', 'less', 'sass',
100+
'json', 'jsonc', 'yaml', 'yml', 'toml', 'xml',
101+
'md', 'mdx', 'rst', 'txt',
102+
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
103+
'sql', 'graphql', 'gql', 'proto',
104+
'lock', 'env', 'ini', 'cfg', 'conf',
105+
'cj', 'ets',
106+
'editorconfig', 'gitignore',
107+
'log',
108+
]);
109+
110+
const DOWNLOADABLE_EXTENSIONS = new Set([
111+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
112+
'odt', 'ods', 'odp', 'rtf', 'pages', 'numbers', 'key',
113+
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tiff', 'tif',
114+
'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'dmg', 'iso', 'xz',
115+
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma',
116+
'mp4', 'avi', 'mkv', 'mov', 'webm', 'wmv', 'flv',
117+
'csv', 'tsv', 'sqlite', 'db', 'parquet',
118+
'epub', 'mobi',
119+
'apk', 'ipa', 'exe', 'msi', 'deb', 'rpm',
120+
'ttf', 'otf', 'woff', 'woff2',
121+
]);
122+
123+
/**
124+
* Detect local file links: absolute paths, file:// URLs, and relative paths
125+
* pointing to downloadable files. Returns the file path or null.
126+
*
127+
* - Absolute paths (`/Users/.../file.pdf`): use CODE_FILE_EXTENSIONS blacklist
128+
* - Relative paths (`report.pptx`, `./output.pdf`): use DOWNLOADABLE_EXTENSIONS whitelist
129+
*/
130+
function isLocalFileLink(href: string): string | null {
131+
if (!href || href === '/') return null;
132+
133+
let filePath: string;
134+
if (href.startsWith('file://')) {
135+
filePath = href.slice(7);
136+
} else if (href.includes('://') || href.startsWith('#') || href.startsWith('//')) {
137+
return null;
138+
} else {
139+
filePath = href;
140+
}
141+
142+
if (filePath.startsWith('/')) {
143+
const segments = filePath.split('/').filter(Boolean);
144+
if (segments.length < 2) return null;
145+
}
146+
147+
const fileName = filePath.split('/').pop() || '';
148+
const dotIdx = fileName.lastIndexOf('.');
149+
if (dotIdx <= 0) return null;
150+
151+
const ext = fileName.slice(dotIdx + 1).toLowerCase();
152+
if (!ext) return null;
153+
154+
if (filePath.startsWith('/')) {
155+
if (CODE_FILE_EXTENSIONS.has(ext)) return null;
156+
} else {
157+
if (!DOWNLOADABLE_EXTENSIONS.has(ext)) return null;
158+
}
159+
160+
return filePath;
161+
}
162+
92163
function formatFileSize(bytes: number): string {
93164
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
94165
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -337,6 +408,20 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, onFileDownlo
337408
);
338409
}
339410

411+
// Local file path (e.g. /Users/.../report.pdf) → FileCard, excluding code files
412+
if (onGetFileInfo && onFileDownload) {
413+
const localPath = typeof href === 'string' ? isLocalFileLink(href) : null;
414+
if (localPath) {
415+
return (
416+
<FileCard
417+
path={localPath}
418+
onGetFileInfo={onGetFileInfo}
419+
onDownload={onFileDownload}
420+
/>
421+
);
422+
}
423+
}
424+
340425
// Fallback: render as plain text for computer:// links without handler,
341426
// or as a regular link for http(s) links.
342427
if (typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://'))) {
@@ -373,13 +458,14 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, onFileDownlo
373458
remarkPlugins={[remarkGfm]}
374459
components={components}
375460
urlTransform={(url) => {
376-
// react-markdown v9 strips non-standard protocols by default.
377-
// Preserve computer:// so our FileCard renderer receives the href intact.
378461
if (url.startsWith('computer://')) return url;
379-
// Keep default-safe behaviour for everything else.
380-
if (/^(https?|mailto|tel):/i.test(url) || url.startsWith('#') || url.startsWith('/')) {
462+
if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) {
381463
return url;
382464
}
465+
// Preserve relative paths without a protocol (e.g. "report.pptx",
466+
// "./output.pdf"). Content is from our own AI so javascript:/data:
467+
// injection is not a concern; those contain ':' and are blocked above.
468+
if (!url.includes(':')) return url;
383469
return '';
384470
}}
385471
>

‎src/web-ui/src/flow_chat/components/RichTextInput.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
455455
useEffect(() => {
456456
const editor = internalRef.current;
457457
if (!editor) return;
458+
459+
if (isComposingRef.current) return;
458460

459461
// Detect template fill mode via placeholder elements
460462
const hasPlaceholders = editor.querySelector('.rich-text-placeholder') !== null;
@@ -538,11 +540,7 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
538540
}, [onCompositionStart]);
539541

540542
const handleCompositionEnd = useCallback(() => {
541-
// Delay clearing to handle Safari's event ordering where
542-
// compositionend fires before the final keydown(Enter)
543-
setTimeout(() => {
544-
isComposingRef.current = false;
545-
}, 0);
543+
isComposingRef.current = false;
546544
onCompositionEnd?.();
547545
handleInput();
548546
}, [handleInput, onCompositionEnd]);

0 commit comments

Comments
 (0)