Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/apps/relay-server/src/relay/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/crates/core/src/agentic/execution/stream_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
35 changes: 35 additions & 0 deletions src/crates/core/src/service/remote_connect/remote_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1323,6 +1337,13 @@ pub fn get_global_dispatcher() -> Option<Arc<RemoteExecutionDispatcher>> {

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<RemoteSessionStateTracker> {
if let Some(tracker) = self.state_trackers.get(session_id) {
return tracker.clone();
Expand All @@ -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
Expand Down
6 changes: 0 additions & 6 deletions src/mobile-web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 90 additions & 4 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -337,6 +408,20 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ 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 (
<FileCard
path={localPath}
onGetFileInfo={onGetFileInfo}
onDownload={onFileDownload}
/>
);
}
}

// 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://'))) {
Expand Down Expand Up @@ -373,13 +458,14 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ 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 '';
}}
>
Expand Down
8 changes: 3 additions & 5 deletions src/web-ui/src/flow_chat/components/RichTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
useEffect(() => {
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;
Expand Down Expand Up @@ -538,11 +540,7 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
}, [onCompositionStart]);

const handleCompositionEnd = useCallback(() => {
// 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]);
Expand Down