From 9933e782c9de59208aa75ff3a03590375bda97cb Mon Sep 17 00:00:00 2001 From: bowen628 Date: Mon, 9 Mar 2026 20:59:42 +0800 Subject: [PATCH] feat: bot file transfer, mobile file download, and relay server improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bot file transfer (Feishu + Telegram) - Extract `computer://` links from AI responses and offer download buttons - Feishu: upload files via IM file API (≤30MB), send as file messages - Telegram: send files via sendDocument API (≤30MB), inline keyboard buttons - Shared utilities in bot/mod.rs: resolve_workspace_path, read_workspace_file, detect_mime_type, extract_computer_file_paths, format_file_size - ForwardedTurnResult replaces plain String to carry both display_text and full_text for link extraction - pending_files map on BotChatState for download token management - Auto-inherit desktop workspace when bot session has none - Graceful stop signal (watch channel) for bot pairing loops ## Remote server file commands - Add ReadFile, ReadFileChunk, GetFileInfo commands - Chunked transfer with 3MB raw chunks (4MB base64) for large files - Terminal pre-warming for remote sessions (avoids 30s shell readiness wait) - Increase broadcast channel buffer from 256 to 1024 - accumulated_text() accessor on RemoteSessionStateTracker ## Mobile web file download - FileCard component renders `computer://` links as interactive cards with name, size, and download progress indicator - MarkdownContent: custom `a` renderer for computer:// links + urlTransform to preserve the protocol - RemoteSessionManager: readFile() with chunked transfer + progress callback, getFileInfo() for metadata-only queries - Download triggers browser save dialog via blob URL ## Relay server deployment - Standalone Dockerfile (no workspace dependency, Rust 1.85) - Simplified docker-compose.yml (Caddy commented out) - Add remote-deploy.sh for SSH-based deployment from dev machine - Updated README with local vs remote deploy instructions - WS max_frame_size and max_write_buffer_size set to 64MB - Relay client WebSocket config with matching 64MB limits ## Terminal & input fixes - Fix pty_to_session mapping race: retry lookup for non-Data events - Accept Starting session status when shell integration reaches Prompt - RichTextInput: fix IME composition handling for Safari (delayed clear) - Mobile ChatPage: IME compositionStart/End guards on textarea - Improved scroll behavior: only auto-scroll when near bottom - useLayoutEffect for initial scroll to prevent flash - Theme switching: CSS transition class instead of opacity crossfade - index.html: inline script reads saved theme before first paint - Navigation animations: iOS-style full-width slide with z-index layering Made-with: Cursor --- src/apps/relay-server/Dockerfile | 57 +-- src/apps/relay-server/README.md | 63 ++- src/apps/relay-server/deploy.sh | 15 +- src/apps/relay-server/docker-compose.yml | 45 +- src/apps/relay-server/remote-deploy.sh | 194 ++++++++ src/apps/relay-server/src/routes/websocket.rs | 2 + .../remote_connect/bot/command_router.rs | 101 ++++- .../src/service/remote_connect/bot/feishu.rs | 208 ++++++++- .../src/service/remote_connect/bot/mod.rs | 194 +++++++- .../service/remote_connect/bot/telegram.rs | 419 ++++++++++++++++-- .../core/src/service/remote_connect/mod.rs | 42 +- .../service/remote_connect/relay_client.rs | 13 +- .../service/remote_connect/remote_server.rs | 258 ++++++++++- .../service/terminal/src/session/manager.rs | 41 +- src/mobile-web/index.html | 18 +- src/mobile-web/src/App.tsx | 7 +- src/mobile-web/src/assets/file-text.svg | 7 + src/mobile-web/src/pages/ChatPage.tsx | 378 ++++++++++++++-- .../src/services/RemoteSessionManager.ts | 79 ++++ .../src/styles/components/markdown.scss | 10 + src/mobile-web/src/styles/global.scss | 51 ++- src/mobile-web/src/theme/ThemeProvider.tsx | 112 ++--- .../flow_chat/components/RichTextInput.tsx | 18 +- 23 files changed, 2056 insertions(+), 276 deletions(-) create mode 100755 src/apps/relay-server/remote-deploy.sh create mode 100644 src/mobile-web/src/assets/file-text.svg diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index b10b2908..c6512999 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -1,57 +1,28 @@ -# Multi-stage build for BitFun Relay Server -FROM rust:1.82-slim AS builder +# BitFun Relay Server — standalone Docker build. +# Build context is relay-server root (Cargo.toml + src/), no workspace needed. +FROM rust:1.85-slim AS builder WORKDIR /build -# Install build dependencies RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* -# Copy all workspace Cargo.toml files (including nested path deps in bitfun-core) -COPY Cargo.toml Cargo.lock ./ -COPY src/crates/events/Cargo.toml src/crates/events/Cargo.toml -COPY src/crates/core/Cargo.toml src/crates/core/Cargo.toml -COPY src/crates/core/src/infrastructure/ai/ai_stream_handlers/Cargo.toml src/crates/core/src/infrastructure/ai/ai_stream_handlers/Cargo.toml -COPY src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml -COPY src/crates/core/src/service/terminal/Cargo.toml src/crates/core/src/service/terminal/Cargo.toml -COPY src/crates/transport/Cargo.toml src/crates/transport/Cargo.toml -COPY src/crates/api-layer/Cargo.toml src/crates/api-layer/Cargo.toml -COPY src/apps/cli/Cargo.toml src/apps/cli/Cargo.toml -COPY src/apps/desktop/Cargo.toml src/apps/desktop/Cargo.toml -COPY src/apps/server/Cargo.toml src/apps/server/Cargo.toml -COPY src/apps/relay-server/Cargo.toml src/apps/relay-server/Cargo.toml - -# Create dummy source files for dependency caching -RUN mkdir -p src/crates/events/src && echo "pub fn dummy() {}" > src/crates/events/src/lib.rs && \ - mkdir -p src/crates/core/src && echo "pub fn dummy() {}" > src/crates/core/src/lib.rs && \ - mkdir -p src/crates/core/src/infrastructure/ai/ai_stream_handlers/src && echo "pub fn dummy() {}" > src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/lib.rs && \ - mkdir -p src/crates/core/src/agentic/tools/implementations/tool-runtime/src && echo "pub fn dummy() {}" > src/crates/core/src/agentic/tools/implementations/tool-runtime/src/lib.rs && \ - mkdir -p src/crates/core/src/service/terminal/src && echo "pub fn dummy() {}" > src/crates/core/src/service/terminal/src/lib.rs && \ - mkdir -p src/crates/transport/src && echo "pub fn dummy() {}" > src/crates/transport/src/lib.rs && \ - mkdir -p src/crates/api-layer/src && echo "pub fn dummy() {}" > src/crates/api-layer/src/lib.rs && \ - mkdir -p src/apps/cli/src && echo "fn main() {}" > src/apps/cli/src/main.rs && \ - mkdir -p src/apps/desktop/src && echo "fn main() {}" > src/apps/desktop/src/main.rs && \ - mkdir -p src/apps/server/src && echo "fn main() {}" > src/apps/server/src/main.rs && \ - mkdir -p src/apps/relay-server/src && echo "pub fn dummy() {}" > src/apps/relay-server/src/lib.rs && \ - echo "fn main() {}" > src/apps/relay-server/src/main.rs - -# Build dependencies only (cached layer) -RUN cargo build --release -p bitfun-relay-server 2>/dev/null || true - -# Copy actual source code -COPY src/apps/relay-server/src src/apps/relay-server/src - -# Build the relay server -RUN cargo build --release -p bitfun-relay-server - -# ── Runtime stage ───────────────────────────────────────── +COPY Cargo.toml ./ +RUN mkdir -p src && echo 'fn main() { println!("placeholder"); }' > src/main.rs +RUN cargo build --release 2>/dev/null || true + +RUN rm -rf src target/release/bitfun-relay-server target/release/deps/bitfun* + +COPY src/ src/ + +RUN cargo build --release + FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server - -COPY src/apps/relay-server/static /app/static +RUN mkdir -p /app/static ENV RELAY_PORT=9700 ENV RELAY_STATIC_DIR=/app/static diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 2e1698e3..ceac2e7f 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -118,13 +118,44 @@ Only desktop clients connect via WebSocket. Mobile clients use the HTTP endpoint ## Self-Hosted Deployment -1. Clone the repository -2. Navigate to `src/apps/relay-server/` -3. Run `bash deploy.sh` -4. Configure DNS/firewall as needed -5. In BitFun desktop, select "Custom Server" and enter your server URL +### Option A: Local Deploy (on the server itself) -### Deployment Checklist (Recommended) +If you have the repo cloned **on the server**: + +```bash +cd src/apps/relay-server/ +bash deploy.sh +``` + +This builds the Docker image locally and starts the container. It will **automatically stop any previously running relay container** before restarting. + +### Option B: Remote Deploy (from your dev machine) + +Push code changes from your local dev machine to a remote server via SSH: + +```bash +cd src/apps/relay-server/ + +# First-time setup (creates /opt/bitfun-relay, copies static/) +bash remote-deploy.sh 116.204.120.240 --first + +# Subsequent updates (syncs src + rebuilds) +bash remote-deploy.sh 116.204.120.240 +``` + +The script will: +1. Test SSH connectivity +2. **Stop the old container** if running +3. Sync source code (`src/`), `Cargo.toml`, `Dockerfile`, `docker-compose.yml` +4. Rebuild the Docker image on the server +5. Start the new container +6. Run a health check + +**Prerequisites:** +- SSH key-based auth to the server (configured in `~/.ssh/config`) +- Docker + Docker Compose installed on the server + +### Deployment Checklist 1. Open required ports: - `9700` (relay direct access, optional if only via reverse proxy) @@ -134,7 +165,25 @@ Only desktop clients connect via WebSocket. Mobile clients use the HTTP endpoint 3. Configure your final URL strategy: - root domain (`https://relay.example.com`) or - path prefix (`https://relay.example.com/relay`) -4. Fill the same URL into BitFun Desktop "Custom Server". +4. Fill the same URL into BitFun Desktop "Custom Server" + +### Directory Structure + +``` +relay-server/ +├── src/ # Rust source code +├── static/ # Mobile-web static files +├── Cargo.toml # Crate manifest (standalone, no workspace deps) +├── Dockerfile # Docker build (standalone single-crate build) +├── docker-compose.yml # Docker Compose config +├── Caddyfile # Caddy reverse proxy config (optional) +├── deploy.sh # Local deploy (run on the server itself) +├── remote-deploy.sh # Remote deploy (run from dev machine via SSH) +└── README.md +``` + +Relay server is a **standalone crate** — one set of code, one Dockerfile, one docker-compose.yml. +Whether deployed as a public relay, LAN relay, or NAT traversal relay, the build and runtime are identical. ### About `src/apps/server` vs `src/apps/relay-server` diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 48b81a30..c05f87d5 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -61,16 +61,23 @@ echo "=== BitFun Relay Server Deploy ===" check_command docker check_docker_compose -# Build and start containers cd "$SCRIPT_DIR" + +# Stop old containers if running +echo "[1/3] Stopping old containers (if running)..." +docker compose down 2>/dev/null || true +echo " Done." + +# Build if [ "$SKIP_BUILD" = true ]; then - echo "[1/2] Skipping Docker build (--skip-build)" + echo "[2/3] Skipping Docker build (--skip-build)" else - echo "[1/2] Building Docker images..." + echo "[2/3] Building Docker images..." docker compose build fi -echo "[2/2] Starting services..." +# Start +echo "[3/3] Starting services..." docker compose up -d if [ "$SKIP_HEALTH_CHECK" = false ]; then diff --git a/src/apps/relay-server/docker-compose.yml b/src/apps/relay-server/docker-compose.yml index d8ed1f72..40874a5a 100644 --- a/src/apps/relay-server/docker-compose.yml +++ b/src/apps/relay-server/docker-compose.yml @@ -1,10 +1,8 @@ -version: '3.8' - services: relay-server: build: - context: ../../.. - dockerfile: src/apps/relay-server/Dockerfile + context: . + dockerfile: Dockerfile container_name: bitfun-relay restart: unless-stopped ports: @@ -12,26 +10,29 @@ services: environment: - RELAY_PORT=9700 - RELAY_STATIC_DIR=/app/static + - RELAY_ROOM_WEB_DIR=/app/room-web - RELAY_ROOM_TTL=3600 volumes: - - relay-data:/app/data + - ./static:/app/static:ro + - room-web:/app/room-web - # Caddy reverse proxy for automatic HTTPS - caddy: - image: caddy:2-alpine - container_name: bitfun-caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - caddy-data:/data - - caddy-config:/config - depends_on: - - relay-server + # Optional: Caddy reverse proxy for automatic HTTPS. + # Uncomment if you need HTTPS / domain-based access. + # caddy: + # image: caddy:2-alpine + # container_name: bitfun-caddy + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./Caddyfile:/etc/caddy/Caddyfile + # - caddy-data:/data + # - caddy-config:/config + # depends_on: + # - relay-server volumes: - relay-data: - caddy-data: - caddy-config: + room-web: + # caddy-data: + # caddy-config: diff --git a/src/apps/relay-server/remote-deploy.sh b/src/apps/relay-server/remote-deploy.sh new file mode 100755 index 00000000..80ceb689 --- /dev/null +++ b/src/apps/relay-server/remote-deploy.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# BitFun Relay Server — remote deploy script. +# +# Syncs the relay-server source to a remote server, rebuilds the Docker image +# and restarts the container. All three deployment scenarios (public relay, +# LAN relay, NAT traversal relay) use the same code and the same config. +# +# Usage: +# bash remote-deploy.sh [options] +# +# Example: +# bash remote-deploy.sh 116.204.120.240 +# bash remote-deploy.sh relay.example.com --first +# +# Prerequisites: +# - SSH access to the server (key-based auth configured in ~/.ssh/config) +# - Docker + Docker Compose installed on the server + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +REMOTE_DIR="/opt/bitfun-relay" +SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" + +SKIP_HEALTH_CHECK=false +FIRST_DEPLOY=false + +usage() { + cat <<'EOF' +BitFun Relay Server — remote deploy script + +Usage: + bash remote-deploy.sh [options] + +Arguments: + SSH host (IP or hostname from ~/.ssh/config) + +Options: + --first First-time deploy (creates remote dir structure) + --skip-health-check Skip post-deploy health check + -h, --help Show this help message + +Examples: + bash remote-deploy.sh 116.204.120.240 --first # first time + bash remote-deploy.sh 116.204.120.240 # update +EOF +} + +# ── Parse arguments ────────────────────────────────────────────── + +if [ $# -lt 1 ]; then + usage + exit 1 +fi + +SERVER="" +for arg in "$@"; do + case "$arg" in + --first) FIRST_DEPLOY=true ;; + --skip-health-check) SKIP_HEALTH_CHECK=true ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $arg"; usage; exit 1 ;; + *) + if [ -z "$SERVER" ]; then + SERVER="$arg" + else + echo "Unexpected argument: $arg"; usage; exit 1 + fi + ;; + esac +done + +if [ -z "$SERVER" ]; then + echo "Error: argument is required." + usage + exit 1 +fi + +if [ ! -d "$SCRIPT_DIR/src" ]; then + echo "Error: Source directory not found: $SCRIPT_DIR/src" + exit 1 +fi + +echo "=== BitFun Relay Server — Remote Deploy ===" +echo "Server: $SERVER" +echo "Remote: $REMOTE_DIR" +if [ "$FIRST_DEPLOY" = true ]; then + echo "Mode: First-time deploy" +else + echo "Mode: Update" +fi +echo "" + +# ── 1. Test SSH connectivity ───────────────────────────────────── + +echo "[1/6] Testing SSH connection..." +if ! ssh $SSH_OPTS "$SERVER" "echo ok" >/dev/null 2>&1; then + echo "Error: Cannot connect to $SERVER via SSH." + exit 1 +fi +echo " OK." + +# ── 2. Ensure remote directory ─────────────────────────────────── + +if [ "$FIRST_DEPLOY" = true ]; then + echo "[2/6] Creating remote directory $REMOTE_DIR ..." + ssh $SSH_OPTS "$SERVER" "mkdir -p $REMOTE_DIR/src $REMOTE_DIR/static" +else + echo "[2/6] Verifying remote directory..." + if ! ssh $SSH_OPTS "$SERVER" "test -d $REMOTE_DIR"; then + echo "Error: $REMOTE_DIR not found. Use --first for initial deploy." + exit 1 + fi +fi +echo " OK." + +# ── 3. Stop old container ──────────────────────────────────────── + +echo "[3/6] Stopping old container (if running)..." +ssh $SSH_OPTS "$SERVER" "cd $REMOTE_DIR && docker compose down 2>/dev/null || true" +echo " Done." + +# ── 4. Sync files ──────────────────────────────────────────────── + +echo "[4/6] Syncing files..." + +echo " src/ ..." +rsync -az --delete \ + -e "ssh $SSH_OPTS" \ + "$SCRIPT_DIR/src/" \ + "$SERVER:$REMOTE_DIR/src/" + +echo " Cargo.toml, Dockerfile, docker-compose.yml ..." +scp -q $SSH_OPTS \ + "$SCRIPT_DIR/Cargo.toml" \ + "$SCRIPT_DIR/Dockerfile" \ + "$SCRIPT_DIR/docker-compose.yml" \ + "$SERVER:$REMOTE_DIR/" + +if [ "$FIRST_DEPLOY" = true ]; then + echo " static/ ..." + rsync -az \ + -e "ssh $SSH_OPTS" \ + "$SCRIPT_DIR/static/" \ + "$SERVER:$REMOTE_DIR/static/" +fi + +echo " Done." + +# ── 5. Build ───────────────────────────────────────────────────── + +echo "[5/6] Building Docker image (may take a few minutes)..." +ssh $SSH_OPTS "$SERVER" "cd $REMOTE_DIR && docker compose build 2>&1 | tail -5" +echo " Build complete." + +# ── 6. Start ───────────────────────────────────────────────────── + +echo "[6/6] Starting container..." +ssh $SSH_OPTS "$SERVER" "cd $REMOTE_DIR && docker compose up -d" + +# ── Health check ───────────────────────────────────────────────── + +if [ "$SKIP_HEALTH_CHECK" = true ]; then + echo "" + echo "Health check skipped." +else + echo "" + echo "Waiting for service to start..." + sleep 3 + MAX_RETRIES=6 + RETRY=0 + while [ $RETRY -lt $MAX_RETRIES ]; do + HEALTH=$(ssh $SSH_OPTS "$SERVER" "curl -fsS --max-time 5 http://127.0.0.1:9700/health 2>/dev/null" || echo "FAIL") + if [ "$HEALTH" != "FAIL" ]; then + echo "Health check passed:" + echo " $HEALTH" + break + fi + RETRY=$((RETRY + 1)) + if [ $RETRY -lt $MAX_RETRIES ]; then + echo " Retry $RETRY/$MAX_RETRIES in 3s..." + sleep 3 + else + echo "Warning: health check failed after $MAX_RETRIES attempts." + echo "Check: ssh $SERVER 'cd $REMOTE_DIR && docker compose logs --tail=30'" + fi + done +fi + +echo "" +echo "=== Deploy complete ===" +echo "Relay: http://$SERVER:9700" +echo "Health: http://$SERVER:9700/health" diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs index ca264d16..13cf88e9 100644 --- a/src/apps/relay-server/src/routes/websocket.rs +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -71,6 +71,8 @@ pub async fn websocket_handler( State(state): State, ) -> Response { ws.max_message_size(64 * 1024 * 1024) + .max_frame_size(64 * 1024 * 1024) + .max_write_buffer_size(64 * 1024 * 1024) .on_upgrade(move |socket| handle_socket(socket, state)) } diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index c7d52d00..76dfbb80 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -21,6 +21,12 @@ pub struct BotChatState { pub current_session_id: Option, #[serde(skip)] pub pending_action: Option, + /// Pending file downloads awaiting user confirmation. + /// Key: short token embedded in the download button callback. + /// Value: absolute file path on the desktop. + /// Not persisted — cleared on bot restart. + #[serde(skip)] + pub pending_files: std::collections::HashMap, } impl BotChatState { @@ -31,6 +37,7 @@ impl BotChatState { current_workspace: None, current_session_id: None, pending_action: None, + pending_files: std::collections::HashMap::new(), } } } @@ -103,6 +110,15 @@ pub struct ForwardRequest { pub image_contexts: Vec, } +/// Result returned by [`execute_forwarded_turn`]. +pub struct ForwardedTurnResult { + /// Truncated text suitable for display in bot messages (≤ 4000 chars). + pub display_text: String, + /// Full untruncated response text from the tracker, suitable for + /// `computer://` link extraction. Not affected by broadcast lag. + pub full_text: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotQuestionOption { pub label: String, @@ -220,7 +236,7 @@ pub fn main_menu_actions() -> Vec { BotAction::secondary("Resume Session", "/resume_session"), BotAction::secondary("New Code Session", "/new_code_session"), BotAction::secondary("New Cowork Session", "/new_cowork_session"), - BotAction::secondary("Help", "/help"), + BotAction::secondary("Help (send /help for menu)", "/help"), ] } @@ -258,6 +274,18 @@ pub async fn handle_command( super::super::remote_server::images_to_contexts( if images.is_empty() { None } else { Some(&images) }, ); + + // If the bot session has no workspace yet, silently inherit the desktop's + // currently-open workspace. This avoids asking users to run + // /switch_workspace right after pairing when the desktop already has a + // project open. + if state.current_workspace.is_none() { + use crate::infrastructure::get_workspace_path; + if let Some(ws_path) = get_workspace_path() { + state.current_workspace = Some(ws_path.to_string_lossy().to_string()); + } + } + match cmd { BotCommand::Start | BotCommand::Help => { if state.paired { @@ -1302,7 +1330,7 @@ async fn handle_chat_message( if state.current_session_id.is_none() { return HandleResult { reply: "No active session. Use /resume_session to resume one or \ - /new_code_session to create a new one." + /new_code_session /new_cowork_session to create a new one." .to_string(), actions: session_entry_actions(), forward_to_session: None, @@ -1343,7 +1371,7 @@ pub async fn execute_forwarded_turn( forward: ForwardRequest, interaction_handler: Option, message_sender: Option, -) -> String { +) -> ForwardedTurnResult { use crate::agentic::coordination::DialogTriggerSource; use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, @@ -1365,7 +1393,11 @@ pub async fn execute_forwarded_turn( ) .await { - return format!("Failed to send message: {e}"); + let msg = format!("Failed to send message: {e}"); + return ForwardedTurnResult { + display_text: msg.clone(), + full_text: msg, + }; } let result = tokio::time::timeout(std::time::Duration::from_secs(300), async { @@ -1410,9 +1442,19 @@ pub async fn execute_forwarded_turn( } } TrackerEvent::TurnCompleted => break, - TrackerEvent::TurnFailed(e) => return format!("Error: {e}"), + TrackerEvent::TurnFailed(e) => { + let msg = format!("Error: {e}"); + return ForwardedTurnResult { + display_text: msg.clone(), + full_text: msg, + }; + } TrackerEvent::TurnCancelled => { - return "Task was cancelled.".to_string(); + let msg = "Task was cancelled.".to_string(); + return ForwardedTurnResult { + display_text: msg.clone(), + full_text: msg, + }; } _ => {} }, @@ -1424,24 +1466,37 @@ pub async fn execute_forwarded_turn( } } } - response - }) - .await; - match result { - Ok(text) if text.is_empty() => "(No response)".to_string(), - Ok(mut text) => { - const MAX_BOT_MSG_LEN: usize = 4000; - if text.len() > MAX_BOT_MSG_LEN { - let mut end = MAX_BOT_MSG_LEN; - while !text.is_char_boundary(end) { - end -= 1; - } - text.truncate(end); - text.push_str("\n\n... (truncated)"); + // Use the tracker's authoritative accumulated_text as the full + // response — it is maintained directly from AgenticEvent and is not + // subject to broadcast channel lag. + let full_text = tracker.accumulated_text(); + let full_text = if full_text.is_empty() { response } else { full_text }; + + let mut display_text = full_text.clone(); + const MAX_BOT_MSG_LEN: usize = 4000; + if display_text.len() > MAX_BOT_MSG_LEN { + let mut end = MAX_BOT_MSG_LEN; + while !display_text.is_char_boundary(end) { + end -= 1; } - text + display_text.truncate(end); + display_text.push_str("\n\n... (truncated)"); } - Err(_) => "Response timed out after 5 minutes.".to_string(), - } + + ForwardedTurnResult { + display_text: if display_text.is_empty() { + "(No response)".to_string() + } else { + display_text + }, + full_text, + } + }) + .await; + + result.unwrap_or_else(|_| ForwardedTurnResult { + display_text: "Response timed out after 5 minutes.".to_string(), + full_text: String::new(), + }) } diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index 4260c8a5..b0fd171b 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -442,6 +442,188 @@ impl FeishuBot { } } + /// Upload a local file to Feishu and return its `file_key`. + /// + /// Files larger than 30 MB are rejected (Feishu IM file-upload limit). + async fn upload_file_to_feishu(&self, file_path: &str) -> Result { + let token = self.get_access_token().await?; + + const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) + let content = super::read_workspace_file(file_path, MAX_SIZE).await?; + + // Feishu uses its own file_type enum rather than MIME types. + let ext = std::path::Path::new(&content.name) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let file_type = match ext.as_str() { + "pdf" => "pdf", + "doc" | "docx" => "doc", + "xls" | "xlsx" => "xls", + "ppt" | "pptx" => "ppt", + "mp4" => "mp4", + _ => "stream", + }; + + let part = reqwest::multipart::Part::bytes(content.bytes) + .file_name(content.name.clone()) + .mime_str("application/octet-stream")?; + + let form = reqwest::multipart::Form::new() + .text("file_type", file_type.to_string()) + .text("file_name", content.name) + .part("file", part); + + let client = reqwest::Client::new(); + let resp = client + .post("https://open.feishu.cn/open-apis/im/v1/files") + .bearer_auth(&token) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Feishu file upload failed: {body}")); + } + + let body: serde_json::Value = resp.json().await?; + body.pointer("/data/file_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Feishu upload response missing file_key")) + } + + /// Upload a local file and send it to a Feishu chat as a file message. + async fn send_file_to_feishu_chat(&self, chat_id: &str, file_path: &str) -> Result<()> { + let file_key = self.upload_file_to_feishu(file_path).await?; + let token = self.get_access_token().await?; + + let client = reqwest::Client::new(); + let resp = client + .post("https://open.feishu.cn/open-apis/im/v1/messages") + .query(&[("receive_id_type", "chat_id")]) + .bearer_auth(&token) + .json(&serde_json::json!({ + "receive_id": chat_id, + "msg_type": "file", + "content": serde_json::to_string(&serde_json::json!({"file_key": file_key}))?, + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Feishu file message failed: {body}")); + } + debug!("Feishu file sent to {chat_id}: {file_path}"); + Ok(()) + } + + /// Scan `text` for `computer://` links, store them as pending downloads and + /// send an interactive card with one download button per file. + /// The actual transfer only starts when the user clicks the button. + async fn notify_files_ready(&self, chat_id: &str, text: &str) { + let paths = super::extract_computer_file_paths(text); + if paths.is_empty() { + return; + } + + let mut actions: Vec = Vec::new(); + { + let mut states = self.chat_states.write().await; + let state = states + .entry(chat_id.to_string()) + .or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); + for path in &paths { + if let Some((name, size)) = super::get_file_metadata(path) { + let token: String = { + use std::time::{SystemTime, UNIX_EPOCH}; + let ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + format!("{:08x}", ns ^ (chat_id.len() as u32)) + }; + state.pending_files.insert(token.clone(), path.clone()); + actions.push(BotAction::secondary( + &format!("📥 {} ({})", name, super::format_file_size(size)), + &format!("download_file:{token}"), + )); + } + } + } + + if actions.is_empty() { + return; + } + + let intro = if actions.len() == 1 { + "📎 1 file ready to download:".to_string() + } else { + format!("📎 {} files ready to download:", actions.len()) + }; + + let result = HandleResult { + reply: intro, + actions, + forward_to_session: None, + }; + if let Err(e) = self.send_handle_result(chat_id, &result).await { + warn!("Failed to send file notification to Feishu: {e}"); + } + } + + /// Handle a `download_file:` action: look up the pending file and + /// upload it to Feishu. Sends a plain-text error if the token has expired + /// or the transfer fails. + async fn handle_download_request(&self, chat_id: &str, token: &str) { + let path = { + let mut states = self.chat_states.write().await; + states + .get_mut(chat_id) + .and_then(|s| s.pending_files.remove(token)) + }; + + match path { + None => { + let _ = self + .send_message( + chat_id, + "This download link has expired. Please ask the agent again.", + ) + .await; + } + Some(path) => { + let file_name = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let _ = self + .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .await; + match self.send_file_to_feishu_chat(chat_id, &path).await { + Ok(()) => info!("Sent file to Feishu chat {chat_id}: {path}"), + Err(e) => { + warn!("Failed to send file to Feishu: {e}"); + let _ = self + .send_message( + chat_id, + &format!("⚠️ Could not send \"{file_name}\": {e}"), + ) + .await; + } + } + } + } + } + fn build_action_card(chat_id: &str, content: &str, actions: &[BotAction]) -> serde_json::Value { let body = Self::card_body_text(content); let mut elements = vec![serde_json::json!({ @@ -813,9 +995,16 @@ impl FeishuBot { } /// Start polling for pairing codes. Returns the chat_id on success. - pub async fn wait_for_pairing(&self) -> Result { + pub async fn wait_for_pairing( + &self, + stop_rx: &mut tokio::sync::watch::Receiver, + ) -> Result { info!("Feishu bot waiting for pairing code via WebSocket..."); + if *stop_rx.borrow() { + return Err(anyhow!("bot stop requested")); + } + let (ws_url, config) = self.get_ws_endpoint().await?; let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) @@ -837,6 +1026,10 @@ impl FeishuBot { loop { tokio::select! { + _ = stop_rx.changed() => { + info!("Feishu wait_for_pairing stopped by signal"); + return Err(anyhow!("bot stop requested")); + } msg = read.next() => { match msg { Some(Ok(WsMessage::Binary(data))) => { @@ -1098,6 +1291,14 @@ impl FeishuBot { return; } + // Intercept file download callbacks before normal command routing. + if text.starts_with("download_file:") { + let token = text["download_file:".len()..].trim().to_string(); + drop(states); + self.handle_download_request(chat_id, &token).await; + return; + } + let cmd = parse_command(text); let result = handle_command(state, cmd, images).await; @@ -1130,8 +1331,9 @@ impl FeishuBot { msg_bot.send_message(&msg_cid, &text).await.ok(); }) }); - let response = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; - bot.send_message(&cid, &response).await.ok(); + let result = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; + bot.send_message(&cid, &result.display_text).await.ok(); + bot.notify_files_ready(&cid, &result.full_text).await; }); } } diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index 8a482664..1ce087fd 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -10,7 +10,7 @@ pub mod telegram; use serde::{Deserialize, Serialize}; -pub use command_router::{BotChatState, HandleResult, ForwardRequest}; +pub use command_router::{BotChatState, HandleResult, ForwardRequest, ForwardedTurnResult}; /// Configuration for a bot-based connection. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,6 +65,198 @@ impl BotPersistenceData { } } +// ── Shared workspace-file utilities ──────────────────────────────── + +/// File content read from the local workspace, ready to be sent over any channel. +pub struct WorkspaceFileContent { + pub name: String, + pub bytes: Vec, + pub mime_type: &'static str, + pub size: u64, +} + +/// Resolve a raw path (with or without `computer://` prefix) to an absolute +/// `PathBuf`. Relative paths are joined with the current workspace root. +/// Returns `None` when a relative path is given but no workspace is open. +pub fn resolve_workspace_path(raw: &str) -> Option { + use crate::infrastructure::get_workspace_path; + + let stripped = raw.strip_prefix("computer://").unwrap_or(raw); + + if stripped.starts_with('/') + || (stripped.len() >= 3 && stripped.as_bytes()[1] == b':') + { + Some(std::path::PathBuf::from(stripped)) + } else if let Some(ws) = get_workspace_path() { + Some(ws.join(stripped)) + } else { + None + } +} + +/// Return the best-effort MIME type for a file based on its extension. +pub fn detect_mime_type(path: &std::path::Path) -> &'static str { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + match ext.as_str() { + "txt" | "log" => "text/plain", + "md" => "text/markdown", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" + | "toml" | "yaml" | "yml" => "text/plain", + "json" => "application/json", + "xml" => "application/xml", + "csv" => "text/csv", + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "zip" => "application/zip", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => { + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + } + "mp4" => "video/mp4", + "opus" => "audio/opus", + _ => "application/octet-stream", + } +} + +/// Read a workspace file, resolving `computer://` prefixes and relative paths. +/// +/// `max_size` is the caller-specific byte limit (e.g. 50 MB for Telegram, +/// 30 MB for Feishu, 10 MB for mobile relay). +/// +/// Returns an error when the file is missing, is a directory, or exceeds +/// `max_size`. +pub async fn read_workspace_file( + raw_path: &str, + max_size: u64, +) -> anyhow::Result { + let abs_path = resolve_workspace_path(raw_path) + .ok_or_else(|| anyhow::anyhow!("No workspace open to resolve path: {raw_path}"))?; + + if !abs_path.exists() { + return Err(anyhow::anyhow!("File not found: {}", abs_path.display())); + } + if !abs_path.is_file() { + return Err(anyhow::anyhow!( + "Path is not a regular file: {}", + abs_path.display() + )); + } + + let metadata = tokio::fs::metadata(&abs_path).await.map_err(|e| { + anyhow::anyhow!("Cannot read file metadata for {}: {e}", abs_path.display()) + })?; + + if metadata.len() > max_size { + return Err(anyhow::anyhow!( + "File too large ({} bytes, limit {max_size} bytes): {}", + metadata.len(), + abs_path.display() + )); + } + + let bytes = tokio::fs::read(&abs_path) + .await + .map_err(|e| anyhow::anyhow!("Cannot read file {}: {e}", abs_path.display()))?; + + let name = abs_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let mime_type = detect_mime_type(&abs_path); + + Ok(WorkspaceFileContent { + name, + bytes, + mime_type, + size: metadata.len(), + }) +} + +/// Get file metadata (name and size in bytes) without reading the full content. +/// Returns `None` if the path cannot be resolved, does not exist, or is not a +/// regular file. +pub fn get_file_metadata(raw_path: &str) -> Option<(String, u64)> { + let abs = resolve_workspace_path(raw_path)?; + if !abs.is_file() { + return None; + } + let name = abs + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let size = std::fs::metadata(&abs).ok()?.len(); + Some((name, size)) +} + +/// Format a byte count as a human-readable string (e.g. "1.4 MB", "320 KB"). +pub fn format_file_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{bytes} B") + } +} + +// ── computer:// link extraction ──────────────────────────────────── + +/// Extract local file paths referenced via `computer://` links in `text`. +/// +/// Relative paths (e.g. `computer://artifacts/report.docx`) are resolved +/// against `workspace_path` when provided. Only paths that exist as regular +/// files on disk are returned; directories and missing paths are skipped. +/// Duplicate paths are deduplicated before returning. +pub fn extract_computer_file_paths(text: &str) -> Vec { + const PREFIX: &str = "computer://"; + let mut paths: Vec = Vec::new(); + let mut search = text; + + while let Some(idx) = search.find(PREFIX) { + let rest = &search[idx + PREFIX.len()..]; + + // Collect the path until whitespace or link-terminating punctuation. + let end = rest + .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'')) + .unwrap_or(rest.len()); + + // Strip trailing punctuation that is unlikely to be part of a path. + let raw_suffix = rest[..end] + .trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | ')' | ']')); + + if !raw_suffix.is_empty() { + // Reconstruct the full computer:// URL for resolve_workspace_path + let raw = format!("{PREFIX}{raw_suffix}"); + if let Some(abs) = resolve_workspace_path(&raw) { + let abs_str = abs.to_string_lossy().into_owned(); + if abs.exists() && abs.is_file() && !paths.contains(&abs_str) { + paths.push(abs_str); + } + } + } + + search = &rest[end..]; + } + + paths +} + const BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; pub fn bot_persistence_path() -> Option { diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index fa7b7eff..0c4ffdda 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -13,9 +13,11 @@ use tokio::sync::RwLock; use super::command_router::{ execute_forwarded_turn, handle_command, paired_success_message, parse_command, - BotInteractiveRequest, BotInteractionHandler, BotMessageSender, BotChatState, WELCOME_MESSAGE, + BotAction, BotChatState, BotInteractiveRequest, BotInteractionHandler, BotMessageSender, + HandleResult, WELCOME_MESSAGE, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; +use crate::service::remote_connect::remote_server::ImageAttachment; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TelegramConfig { @@ -75,6 +77,230 @@ impl TelegramBot { Ok(()) } + /// Send a message with Telegram inline keyboard buttons. + /// + /// Each `BotAction` becomes one button row. The `callback_data` carries + /// the full command string so the bot receives it as a synthetic message + /// when the user taps the button. + /// + /// Telegram limits `callback_data` to 64 bytes. All commands used here + /// (including `/cancel_task turn_`) fit within that limit. + async fn send_message_with_keyboard( + &self, + chat_id: i64, + text: &str, + actions: &[BotAction], + ) -> Result<()> { + // Build inline keyboard: one button per row for clarity. + let keyboard: Vec> = actions + .iter() + .map(|action| { + vec![serde_json::json!({ + "text": action.label, + "callback_data": action.command, + })] + }) + .collect(); + + let client = reqwest::Client::new(); + let resp = client + .post(&self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": text, + "reply_markup": { + "inline_keyboard": keyboard, + }, + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("telegram sendMessage (keyboard) failed: {body}")); + } + debug!("Telegram keyboard message sent to chat {chat_id}"); + Ok(()) + } + + /// Send a local file to a Telegram chat as a document attachment. + /// + /// Skips files larger than 50 MB (Telegram Bot API hard limit). + async fn send_file_as_document(&self, chat_id: i64, file_path: &str) -> Result<()> { + const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) + let content = super::read_workspace_file(file_path, MAX_SIZE).await?; + + let part = reqwest::multipart::Part::bytes(content.bytes) + .file_name(content.name.clone()) + .mime_str("application/octet-stream")?; + + let form = reqwest::multipart::Form::new() + .text("chat_id", chat_id.to_string()) + .part("document", part); + + let client = reqwest::Client::new(); + let resp = client + .post(&self.api_url("sendDocument")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("telegram sendDocument failed: {body}")); + } + debug!("Telegram document sent to chat {chat_id}: {}", content.name); + Ok(()) + } + + /// Scan `text` for `computer://` links, store them as pending downloads and + /// send a notification message with one inline-keyboard button per file. + /// The actual transfer only starts when the user clicks the button. + async fn notify_files_ready(&self, chat_id: i64, text: &str) { + let paths = super::extract_computer_file_paths(text); + if paths.is_empty() { + return; + } + + let mut actions: Vec = Vec::new(); + { + let mut states = self.chat_states.write().await; + let state = states.entry(chat_id).or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); + for path in &paths { + if let Some((name, size)) = super::get_file_metadata(path) { + let token: String = { + use std::time::{SystemTime, UNIX_EPOCH}; + let ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + format!("{:08x}", ns ^ (chat_id as u32)) + }; + state.pending_files.insert(token.clone(), path.clone()); + actions.push(BotAction::secondary( + &format!("📥 {} ({})", name, super::format_file_size(size)), + &format!("download_file:{token}"), + )); + } + } + } + + if actions.is_empty() { + return; + } + + let intro = if actions.len() == 1 { + "📎 1 file ready to download:".to_string() + } else { + format!("📎 {} files ready to download:", actions.len()) + }; + + if let Err(e) = self.send_message_with_keyboard(chat_id, &intro, &actions).await { + warn!("Failed to send file notification to Telegram: {e}"); + } + } + + /// Handle a `download_file:` callback: look up the pending file and + /// send it. Sends a plain-text error if the token has expired or the + /// transfer fails. + async fn handle_download_request(&self, chat_id: i64, token: &str) { + let path = { + let mut states = self.chat_states.write().await; + states + .get_mut(&chat_id) + .and_then(|s| s.pending_files.remove(token)) + }; + + match path { + None => { + let _ = self + .send_message( + chat_id, + "This download link has expired. Please ask the agent again.", + ) + .await; + } + Some(path) => { + let file_name = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let _ = self + .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .await; + match self.send_file_as_document(chat_id, &path).await { + Ok(()) => info!("Sent file to Telegram chat {chat_id}: {path}"), + Err(e) => { + warn!("Failed to send file to Telegram: {e}"); + let _ = self + .send_message( + chat_id, + &format!("⚠️ Could not send \"{file_name}\": {e}"), + ) + .await; + } + } + } + } + } + + /// Acknowledge a callback query so Telegram removes the button loading state. + async fn answer_callback_query(&self, callback_query_id: &str) { + let client = reqwest::Client::new(); + let _ = client + .post(&self.api_url("answerCallbackQuery")) + .json(&serde_json::json!({ "callback_query_id": callback_query_id })) + .send() + .await; + } + + /// Send a `HandleResult`, using an inline keyboard when actions are present. + /// + /// For the "Processing your message…" reply the cancel command line in the + /// text is replaced with a friendlier prompt, and a Cancel Task button is + /// added via the inline keyboard. + async fn send_handle_result(&self, chat_id: i64, result: &HandleResult) { + let text = Self::clean_reply_text(&result.reply, !result.actions.is_empty()); + if result.actions.is_empty() { + self.send_message(chat_id, &text).await.ok(); + } else { + if let Err(e) = self.send_message_with_keyboard(chat_id, &text, &result.actions).await + { + warn!("Failed to send Telegram keyboard message: {e}; falling back to plain text"); + self.send_message(chat_id, &result.reply).await.ok(); + } + } + } + + /// Remove raw `/cancel_task ` instruction lines and replace them + /// with a short hint that the button below can be used instead. + fn clean_reply_text(text: &str, has_actions: bool) -> String { + let mut lines: Vec = Vec::new(); + let mut replaced_cancel = false; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.contains("/cancel_task ") { + if has_actions && !replaced_cancel { + lines.push( + "If needed, tap the Cancel Task button below to stop this request." + .to_string(), + ); + replaced_cancel = true; + } + continue; + } + lines.push(line.to_string()); + } + + lines.join("\n").trim().to_string() + } + /// Register the bot command menu visible in Telegram's "/" menu. pub async fn set_bot_commands(&self) -> Result<()> { let client = reqwest::Client::new(); @@ -119,7 +345,65 @@ impl TelegramBot { false } - pub async fn poll_updates(&self) -> Result> { + /// Download a Telegram photo by file_id and return it as an `ImageAttachment`. + /// + /// Telegram photo updates contain multiple `PhotoSize` entries; callers should + /// pass the `file_id` of the last (largest) entry. + async fn download_photo(&self, file_id: &str) -> Result { + let client = reqwest::Client::new(); + + // Step 1: resolve file_path via getFile + let get_file_url = self.api_url("getFile"); + let resp = client + .post(&get_file_url) + .json(&serde_json::json!({ "file_id": file_id })) + .send() + .await?; + let body: serde_json::Value = resp.json().await?; + let file_path = body + .pointer("/result/file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Telegram getFile: missing file_path for file_id={file_id}"))? + .to_string(); + + // Step 2: download the actual bytes + let download_url = format!( + "https://api.telegram.org/file/bot{}/{}", + self.config.bot_token, file_path + ); + let bytes = client.get(&download_url).send().await?.bytes().await?; + + // Step 3: encode as base64 data-URL + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mime_type = if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") { + "image/jpeg" + } else if file_path.ends_with(".png") { + "image/png" + } else if file_path.ends_with(".gif") { + "image/gif" + } else if file_path.ends_with(".webp") { + "image/webp" + } else { + "image/jpeg" + }; + let data_url = format!("data:{mime_type};base64,{b64}"); + let name = file_path + .rsplit('/') + .next() + .unwrap_or("photo.jpg") + .to_string(); + + debug!("Telegram photo downloaded: file_id={file_id}, size={}B", bytes.len()); + Ok(ImageAttachment { name, data_url }) + } + + /// Returns `(chat_id, text, images)` tuples for each incoming message. + /// + /// Handles both plain-text messages and photo messages with an optional + /// caption. For photo messages the highest-resolution variant is downloaded + /// and returned as an `ImageAttachment`. + pub async fn poll_updates(&self) -> Result)>> { let offset = *self.last_update_id.read().await; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(35)) @@ -146,11 +430,63 @@ impl TelegramBot { } } - if let (Some(chat_id), Some(text)) = ( - update.pointer("/message/chat/id").and_then(|v| v.as_i64()), - update.pointer("/message/text").and_then(|v| v.as_str()), - ) { - messages.push((chat_id, text.trim().to_string())); + // Inline keyboard button press – treat callback_data as a message. + if let Some(cq) = update.get("callback_query") { + let cq_id = cq["id"].as_str().unwrap_or("").to_string(); + let chat_id = cq + .pointer("/message/chat/id") + .and_then(|v| v.as_i64()); + let data = cq["data"].as_str().map(|s| s.trim().to_string()); + + if let (Some(chat_id), Some(data)) = (chat_id, data) { + // Answer the callback query to dismiss the button spinner. + self.answer_callback_query(&cq_id).await; + messages.push((chat_id, data, vec![])); + } + continue; + } + + let Some(chat_id) = update + .pointer("/message/chat/id") + .and_then(|v| v.as_i64()) + else { + continue; + }; + + // Plain-text message + if let Some(text) = update.pointer("/message/text").and_then(|v| v.as_str()) { + messages.push((chat_id, text.trim().to_string(), vec![])); + continue; + } + + // Photo message (caption is optional) + if let Some(photo_array) = update.pointer("/message/photo").and_then(|v| v.as_array()) { + // The last PhotoSize entry has the highest resolution + let file_id = photo_array + .last() + .and_then(|p| p["file_id"].as_str()) + .map(|s| s.to_string()); + + let caption = update + .pointer("/message/caption") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let images = if let Some(fid) = file_id { + match self.download_photo(&fid).await { + Ok(attachment) => vec![attachment], + Err(e) => { + warn!("Failed to download Telegram photo file_id={fid}: {e}"); + vec![] + } + } + } else { + vec![] + }; + + messages.push((chat_id, caption, images)); } } @@ -159,12 +495,25 @@ impl TelegramBot { /// Start a polling loop that checks for pairing codes. /// Returns the chat_id when a valid pairing code is received. - pub async fn wait_for_pairing(&self) -> Result { + pub async fn wait_for_pairing( + &self, + stop_rx: &mut tokio::sync::watch::Receiver, + ) -> Result { info!("Telegram bot waiting for pairing code..."); loop { - match self.poll_updates().await { + if *stop_rx.borrow() { + return Err(anyhow!("bot stop requested")); + } + let poll_result = tokio::select! { + result = self.poll_updates() => result, + _ = stop_rx.changed() => { + info!("Telegram wait_for_pairing stopped by signal"); + return Err(anyhow!("bot stop requested")); + } + }; + match poll_result { Ok(messages) => { - for (chat_id, text) in messages { + for (chat_id, text, _images) in messages { let trimmed = text.trim(); if trimmed == "/start" { @@ -233,10 +582,10 @@ impl TelegramBot { match poll_result { Ok(messages) => { - for (chat_id, text) in messages { + for (chat_id, text, images) in messages { let bot = self.clone(); tokio::spawn(async move { - bot.handle_incoming_message(chat_id, &text).await; + bot.handle_incoming_message(chat_id, &text, images).await; }); } } @@ -248,7 +597,12 @@ impl TelegramBot { } } - async fn handle_incoming_message(self: &Arc, chat_id: i64, text: &str) { + async fn handle_incoming_message( + self: &Arc, + chat_id: i64, + text: &str, + images: Vec, + ) { let mut states = self.chat_states.write().await; let state = states .entry(chat_id) @@ -291,26 +645,33 @@ impl TelegramBot { return; } + // Intercept file download callbacks before normal command routing. + if text.starts_with("download_file:") { + let token = text["download_file:".len()..].trim().to_string(); + drop(states); + self.handle_download_request(chat_id, &token).await; + return; + } + let cmd = parse_command(text); - let result = handle_command(state, cmd, vec![]).await; + let result = handle_command(state, cmd, images).await; self.persist_chat_state(chat_id, state).await; drop(states); - self.send_message(chat_id, &result.reply).await.ok(); + self.send_handle_result(chat_id, &result).await; if let Some(forward) = result.forward_to_session { let bot = self.clone(); tokio::spawn(async move { let interaction_bot = bot.clone(); - let handler: BotInteractionHandler = std::sync::Arc::new(move |interaction: BotInteractiveRequest| { - let interaction_bot = interaction_bot.clone(); - Box::pin(async move { - interaction_bot - .deliver_interaction(chat_id, interaction) - .await; - }) - }); + let handler: BotInteractionHandler = + std::sync::Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + Box::pin(async move { + interaction_bot.deliver_interaction(chat_id, interaction).await; + }) + }); let msg_bot = bot.clone(); let sender: BotMessageSender = std::sync::Arc::new(move |text: String| { let msg_bot = msg_bot.clone(); @@ -318,8 +679,9 @@ impl TelegramBot { msg_bot.send_message(chat_id, &text).await.ok(); }) }); - let response = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; - bot.send_message(chat_id, &response).await.ok(); + let result = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; + bot.send_message(chat_id, &result.display_text).await.ok(); + bot.notify_files_ready(chat_id, &result.full_text).await; }); } } @@ -337,7 +699,12 @@ impl TelegramBot { self.persist_chat_state(chat_id, state).await; drop(states); - self.send_message(chat_id, &interaction.reply).await.ok(); + let result = HandleResult { + reply: interaction.reply, + actions: interaction.actions, + forward_to_session: None, + }; + self.send_handle_result(chat_id, &result).await; } async fn persist_chat_state(&self, chat_id: i64, state: &BotChatState) { diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index dc5b9d09..6d81d1c4 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -27,7 +27,7 @@ pub use relay_client::RelayClient; pub use remote_server::RemoteServer; use anyhow::Result; -use log::{debug, error, info, warn}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -503,15 +503,23 @@ impl RemoteConnectService { *tg_bot_ref.write().await = Some(tg_bot.clone()); tokio::spawn(async move { - match bot_for_pair.wait_for_pairing().await { + let mut stop_rx = stop_rx; + match bot_for_pair.wait_for_pairing(&mut stop_rx).await { Ok(chat_id) => { - *bot_connected_info.write().await = - Some(format!("Telegram({chat_id})")); - info!("Telegram bot paired, starting message loop"); - bot_for_loop.run_message_loop(stop_rx).await; + // Guard against the race where stop_bots() cleared + // bot_connected_info between pairing completing and + // this task running. + if !*stop_rx.borrow() { + *bot_connected_info.write().await = + Some(format!("Telegram({chat_id})")); + info!("Telegram bot paired, starting message loop"); + bot_for_loop.run_message_loop(stop_rx).await; + } else { + info!("Telegram pairing completed but bot was stopped; discarding"); + } } Err(e) => { - error!("Telegram pairing failed: {e}"); + info!("Telegram pairing ended: {e}"); } } }); @@ -555,15 +563,23 @@ impl RemoteConnectService { *fs_bot_ref.write().await = Some(fs_bot.clone()); tokio::spawn(async move { - match bot_for_pair.wait_for_pairing().await { + let mut stop_rx = stop_rx; + match bot_for_pair.wait_for_pairing(&mut stop_rx).await { Ok(chat_id) => { - *bot_connected_info.write().await = - Some(format!("Feishu({chat_id})")); - info!("Feishu bot paired, starting message loop"); - bot_for_loop.run_message_loop(stop_rx).await; + // Guard against the race where stop_bots() cleared + // bot_connected_info between pairing completing and + // this task running. + if !*stop_rx.borrow() { + *bot_connected_info.write().await = + Some(format!("Feishu({chat_id})")); + info!("Feishu bot paired, starting message loop"); + bot_for_loop.run_message_loop(stop_rx).await; + } else { + info!("Feishu pairing completed but bot was stopped; discarding"); + } } Err(e) => { - error!("Feishu pairing failed: {e}"); + info!("Feishu pairing ended: {e}"); } } }); diff --git a/src/crates/core/src/service/remote_connect/relay_client.rs b/src/crates/core/src/service/remote_connect/relay_client.rs index bcbe5c83..2ead81a2 100644 --- a/src/crates/core/src/service/remote_connect/relay_client.rs +++ b/src/crates/core/src/service/remote_connect/relay_client.rs @@ -397,8 +397,15 @@ impl RelayClient { } async fn dial(ws_url: &str) -> Result { - let (stream, _) = tokio_tungstenite::connect_async(ws_url) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; + let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig { + max_message_size: Some(64 * 1024 * 1024), + max_frame_size: Some(64 * 1024 * 1024), + max_write_buffer_size: 64 * 1024 * 1024, + ..Default::default() + }; + let (stream, _) = + tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) + .await + .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; Ok(stream) } 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 f1b8bedd..ec6d772d 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -12,7 +12,7 @@ use anyhow::{anyhow, Result}; use dashmap::DashMap; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; @@ -86,6 +86,29 @@ pub enum RemoteCommand { since_version: u64, known_msg_count: usize, }, + /// Read a workspace file and return its base64-encoded content. + /// + /// `path` may be an absolute path or a path relative to the current + /// workspace root (e.g. `artifacts/report.docx`). Files larger than + /// 10 MB are rejected with an `Error` response. + ReadFile { + path: String, + }, + /// Read a chunk of a workspace file. `offset` is the byte offset into the + /// raw file and `limit` is the maximum number of raw bytes to return. + /// The response contains the base64-encoded chunk plus total file size so + /// the client knows when it has all the data. + ReadFileChunk { + path: String, + offset: u64, + limit: u64, + }, + /// Get metadata (name, size, mime_type) for a workspace file without + /// transferring its content. Used by the mobile client to display file + /// cards before the user confirms the download. + GetFileInfo { + path: String, + }, Ping, } @@ -162,6 +185,28 @@ pub enum RemoteResponse { action: String, target_id: String, }, + /// Response to `ReadFile`: the file contents encoded as a base64 data-URL. + FileContent { + name: String, + content_base64: String, + mime_type: String, + size: u64, + }, + /// Response to `ReadFileChunk`. + FileChunk { + name: String, + chunk_base64: String, + offset: u64, + chunk_size: u64, + total_size: u64, + mime_type: String, + }, + /// Response to `GetFileInfo`: metadata only, no file content. + FileInfo { + name: String, + size: u64, + mime_type: String, + }, Pong, Error { message: String, @@ -722,7 +767,7 @@ pub struct RemoteSessionStateTracker { impl RemoteSessionStateTracker { pub fn new(session_id: String) -> Self { - let (event_tx, _) = tokio::sync::broadcast::channel(256); + let (event_tx, _) = tokio::sync::broadcast::channel(1024); Self { target_session_id: session_id, version: AtomicU64::new(0), @@ -783,6 +828,15 @@ impl RemoteSessionStateTracker { self.state.read().unwrap().turn_status.clone() } + /// Return the full accumulated response text for the current turn. + /// + /// Unlike the broadcast channel (which can lag and drop chunks), this + /// is maintained directly from the source `AgenticEvent` stream and is + /// therefore authoritative. + pub fn accumulated_text(&self) -> String { + self.state.read().unwrap().accumulated_text.clone() + } + /// Returns true if the turn has ended (completed/failed/cancelled) but /// the tracker state hasn't been cleaned up yet (waiting for persistence). pub fn is_turn_finished(&self) -> bool { @@ -1327,6 +1381,51 @@ impl RemoteExecutionDispatcher { None => coordinator.restore_session(session_id).await.ok(), }; + // Pre-warm the terminal so shell integration is ready before BashTool runs. + // Bot/remote sessions have no Terminal panel to pre-create the session, so the + // AI model's processing time (typically 5-15 s) gives shell integration a head + // start. When BashTool eventually calls get_or_create, the binding already + // exists and the 30-second readiness wait is skipped entirely. + { + use crate::infrastructure::get_workspace_path; + use terminal_core::{TerminalApi, TerminalBindingOptions}; + let sid = session_id.to_string(); + tokio::spawn(async move { + let Ok(api) = TerminalApi::from_singleton() else { + return; + }; + let binding = api.session_manager().binding(); + if binding.get(&sid).is_some() { + return; + } + let workspace = get_workspace_path().map(|p| p.to_string_lossy().into_owned()); + let name = format!("Chat-{}", &sid[..8.min(sid.len())]); + match binding + .get_or_create( + &sid, + TerminalBindingOptions { + working_directory: workspace, + session_id: Some(sid.clone()), + session_name: Some(name), + env: Some({ + let mut m = std::collections::HashMap::new(); + m.insert( + "BITFUN_NONINTERACTIVE".to_string(), + "1".to_string(), + ); + m + }), + ..Default::default() + }, + ) + .await + { + Ok(_) => info!("Terminal pre-warmed for remote session {sid}"), + Err(e) => debug!("Terminal pre-warm skipped for {sid}: {e}"), + } + }); + } + let resolved_agent_type = agent_type .map(|t| resolve_agent_type(Some(t)).to_string()) .unwrap_or_else(|| "agentic".to_string()); @@ -1477,6 +1576,14 @@ impl RemoteServer { } RemoteCommand::PollSession { .. } => self.handle_poll_command(cmd).await, + + RemoteCommand::ReadFile { path } => self.handle_read_file(path).await, + RemoteCommand::ReadFileChunk { + path, + offset, + limit, + } => self.handle_read_file_chunk(path, *offset, *limit).await, + RemoteCommand::GetFileInfo { path } => self.handle_get_file_info(path).await, } } @@ -1651,6 +1758,153 @@ impl RemoteServer { } } + // ── ReadFile ──────────────────────────────────────────────────── + + /// Read a workspace file and return its base64-encoded content. + /// + /// Relative paths are resolved against the current workspace root. + /// Rejects files larger than 10 MB. + async fn handle_read_file(&self, raw_path: &str) -> RemoteResponse { + use crate::service::remote_connect::bot::{read_workspace_file, WorkspaceFileContent}; + + const MAX_SIZE: u64 = 30 * 1024 * 1024; // Unified 30 MB cap (Feishu API hard limit) + match read_workspace_file(raw_path, MAX_SIZE).await { + Ok(WorkspaceFileContent { + name, + bytes, + mime_type, + size, + }) => { + use base64::Engine as _; + let content_base64 = + base64::engine::general_purpose::STANDARD.encode(&bytes); + RemoteResponse::FileContent { + name, + content_base64, + mime_type: mime_type.to_string(), + size, + } + } + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + + async fn handle_read_file_chunk( + &self, + raw_path: &str, + offset: u64, + limit: u64, + ) -> RemoteResponse { + use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; + + let abs = match resolve_workspace_path(raw_path) { + Some(p) => p, + None => { + return RemoteResponse::Error { + message: format!("No workspace open to resolve path: {raw_path}"), + } + } + }; + if !abs.exists() || !abs.is_file() { + return RemoteResponse::Error { + message: format!("File not found or not a regular file: {}", abs.display()), + }; + } + + let total_size = match tokio::fs::metadata(&abs).await { + Ok(m) => m.len(), + Err(e) => { + return RemoteResponse::Error { + message: format!("Cannot read file metadata: {e}"), + } + } + }; + + // Must be divisible by 3 so each intermediate chunk's base64 has no + // padding; the client joins chunk base64 strings and `atob()` requires + // padding only at the very end. + const MAX_CHUNK: u64 = 3 * 1024 * 1024; // 3 MB raw → 4 MB base64 + let actual_limit = limit.min(MAX_CHUNK); + + let bytes = match tokio::fs::read(&abs).await { + Ok(b) => b, + Err(e) => { + return RemoteResponse::Error { + message: format!("Cannot read file: {e}"), + } + } + }; + + let start = (offset as usize).min(bytes.len()); + let end = (start + actual_limit as usize).min(bytes.len()); + let chunk = &bytes[start..end]; + + use base64::Engine as _; + let chunk_base64 = base64::engine::general_purpose::STANDARD.encode(chunk); + + let name = abs + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + RemoteResponse::FileChunk { + name, + chunk_base64, + offset, + chunk_size: (end - start) as u64, + total_size, + mime_type: detect_mime_type(&abs).to_string(), + } + } + + async fn handle_get_file_info(&self, raw_path: &str) -> RemoteResponse { + use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; + + let abs = match resolve_workspace_path(raw_path) { + Some(p) => p, + None => { + return RemoteResponse::Error { + message: format!("No workspace open to resolve path: {raw_path}"), + } + } + }; + + if !abs.exists() { + return RemoteResponse::Error { + message: format!("File not found: {}", abs.display()), + }; + } + if !abs.is_file() { + return RemoteResponse::Error { + message: format!("Path is not a regular file: {}", abs.display()), + }; + } + + let size = match std::fs::metadata(&abs) { + Ok(m) => m.len(), + Err(e) => { + return RemoteResponse::Error { + message: format!("Cannot read file metadata: {e}"), + } + } + }; + + let name = abs + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + RemoteResponse::FileInfo { + name, + size, + mime_type: detect_mime_type(&abs).to_string(), + } + } + // ── Workspace commands ────────────────────────────────────────── async fn handle_workspace_command(&self, cmd: &RemoteCommand) -> RemoteResponse { diff --git a/src/crates/core/src/service/terminal/src/session/manager.rs b/src/crates/core/src/service/terminal/src/session/manager.rs index 983618a6..a62d7fa6 100644 --- a/src/crates/core/src/service/terminal/src/session/manager.rs +++ b/src/crates/core/src/service/terminal/src/session/manager.rs @@ -209,9 +209,29 @@ impl SessionManager { PtyServiceEvent::ResizeCompleted { id, .. } => *id, }; + // Retry the pty_to_session lookup a few times for + // non-Data events. create_session sets the mapping + // AFTER create_process returns, but event forwarding + // can deliver ProcessReady before the mapping exists. let session_id = { let mapping = pty_to_session.read().await; - mapping.get(&pty_id).cloned() + match mapping.get(&pty_id).cloned() { + Some(sid) => Some(sid), + None if !matches!(event, PtyServiceEvent::ProcessData { .. }) => { + drop(mapping); + let mut found = None; + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(10)).await; + let m = pty_to_session.read().await; + if let Some(sid) = m.get(&pty_id).cloned() { + found = Some(sid); + break; + } + } + found + } + None => None, + } }; if let Some(session_id) = session_id { @@ -651,7 +671,6 @@ impl SessionManager { let ready_timeout = Duration::from_secs(30); let ready_start = std::time::Instant::now(); let mut initial_integration_state = None; - while ready_start.elapsed() < ready_timeout { // Check session status let session_status = { @@ -671,33 +690,28 @@ impl SessionManager { } match (session_status, integration_state) { - // Session must be Active - (Some(SessionStatus::Active), Some(int_state)) => { - // For NEW sessions: wait for state to transition from Idle to Prompt/Input - // This indicates shell integration has loaded + // Session active or starting with integration info available. + // Accept Starting here because ProcessReady can be delayed by the + // pty_to_session mapping race; the shell is functional once + // integration reaches Prompt/Input regardless of session status. + (Some(SessionStatus::Active), Some(int_state)) + | (Some(SessionStatus::Starting), Some(int_state)) => { if initial_integration_state == Some(CommandState::Idle) { - // This is a newly created session, wait for prompt match int_state { CommandState::Prompt | CommandState::Input => { return Ok(()); } CommandState::Idle => { - // Still at initial Idle, wait for transition to Prompt - // But don't wait forever - use a shorter timeout for new sessions if ready_start.elapsed() >= ready_timeout { - // Give up after ready_timeout and try anyway return Ok(()); } - // Wait before next check to avoid busy loop tokio::time::sleep(Duration::from_millis(500)).await; } _ => { - // Executing or Finished - shell is working, can send command return Ok(()); } } } else { - // Not a new session (initial state was not Idle), can proceed immediately return Ok(()); } } @@ -708,7 +722,6 @@ impl SessionManager { ))); } _ => { - // Still starting, wait tokio::time::sleep(Duration::from_millis(500)).await; } } diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html index c3d9099e..85ac8ac0 100644 --- a/src/mobile-web/index.html +++ b/src/mobile-web/index.html @@ -6,14 +6,20 @@ BitFun Remote + diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 5a596cd0..d283a253 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -11,8 +11,7 @@ import './styles/index.scss'; type Page = 'pairing' | 'workspace' | 'sessions' | 'chat'; type NavDirection = 'push' | 'pop' | null; -const NAV_PUSH_DURATION = 350; -const NAV_POP_DURATION = 250; +const NAV_DURATION = 300; function getNavClass( targetPage: Page, @@ -47,7 +46,7 @@ const AppContent: React.FC = () => { }); setNavDir(direction); clearTimeout(timerRef.current); - const duration = direction === 'pop' ? NAV_POP_DURATION : NAV_PUSH_DURATION; + const duration = NAV_DURATION; timerRef.current = setTimeout(() => { setPrevPage(null); setNavDir(null); @@ -82,7 +81,7 @@ const AppContent: React.FC = () => { const handleBackToSessions = useCallback(() => { navigateTo('sessions', 'pop'); - setTimeout(() => setActiveSessionId(null), NAV_POP_DURATION); + setTimeout(() => setActiveSessionId(null), NAV_DURATION); }, [navigateTo]); const isAnimating = navDir !== null; diff --git a/src/mobile-web/src/assets/file-text.svg b/src/mobile-web/src/assets/file-text.svg new file mode 100644 index 00000000..0ef82052 --- /dev/null +++ b/src/mobile-web/src/assets/file-text.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index eaae6b63..6cca4c2a 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; @@ -87,7 +87,169 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => { ); }; -const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { +const COMPUTER_LINK_PREFIX = 'computer://'; + +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`; + return `${bytes} B`; +} + +const FileTextIcon: React.FC<{ size?: number; style?: React.CSSProperties }> = ({ size = 20, style }) => ( + +); + +type FileCardState = + | { status: 'loading' } + | { status: 'ready'; name: string; size: number; mimeType: string } + | { status: 'downloading'; name: string; size: number; mimeType: string; progress: number } + | { status: 'done'; name: string; size: number; mimeType: string } + | { status: 'error'; message: string }; + +interface FileCardProps { + path: string; + onGetFileInfo: (path: string) => Promise<{ name: string; size: number; mimeType: string }>; + onDownload: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise; +} + +const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) => { + const { isDark } = useTheme(); + const [state, setState] = useState({ status: 'loading' }); + const onGetFileInfoRef = useRef(onGetFileInfo); + onGetFileInfoRef.current = onGetFileInfo; + + useEffect(() => { + let cancelled = false; + onGetFileInfoRef.current(path) + .then(({ name, size, mimeType }) => { + if (!cancelled) setState({ status: 'ready', name, size, mimeType }); + }) + .catch((err) => { + if (!cancelled) + setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + }); + return () => { cancelled = true; }; + }, [path]); + + const handleClick = useCallback(async () => { + if (state.status !== 'ready' && state.status !== 'done') return; + const info = state as { status: 'ready' | 'done'; name: string; size: number; mimeType: string }; + setState({ status: 'downloading', name: info.name, size: info.size, mimeType: info.mimeType, progress: 0 }); + try { + await onDownload(path, (downloaded, total) => { + setState(prev => { + if (prev.status !== 'downloading') return prev; + return { ...prev, progress: total > 0 ? downloaded / total : 0 }; + }); + }); + setState({ status: 'done', name: info.name, size: info.size, mimeType: info.mimeType }); + } catch { + setState({ status: 'ready', name: info.name, size: info.size, mimeType: info.mimeType }); + } + }, [state, path, onDownload]); + + const cardStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: '10px', + padding: '10px 14px', + border: `1px solid ${isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'}`, + borderRadius: '10px', + background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)', + cursor: state.status === 'ready' || state.status === 'done' ? 'pointer' : 'default', + maxWidth: '300px', + verticalAlign: 'middle', + transition: 'background 0.15s', + }; + + const iconColor = isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.5)'; + + if (state.status === 'loading') { + return ( + + + Loading… + + ); + } + if (state.status === 'error') { + return ( + + + File unavailable + + ); + } + + const { name, size } = state as { name: string; size: number; mimeType: string; status: string }; + const isDownloading = state.status === 'downloading'; + const isDone = state.status === 'done'; + + return ( + { if (e.key === 'Enter' || e.key === ' ') handleClick(); }} + title={isDownloading ? 'Downloading…' : isDone ? 'Downloaded' : 'Click to download'} + > + + + + {name} + + + {formatFileSize(size)} + + + + {isDownloading ? `${Math.round((state as any).progress * 100)}%` : isDone ? '✓' : '↓'} + + + ); +}; + +interface MarkdownContentProps { + content: string; + onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise; + onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>; +} + +const MarkdownContent: React.FC = ({ content, onFileDownload, onGetFileInfo }) => { const { isDark } = useTheme(); const syntaxTheme = isDark ? vscDarkPlus : vs; @@ -138,6 +300,61 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { ); }, + a({ href, children }: any) { + const isComputerLink = + typeof href === 'string' && href.startsWith(COMPUTER_LINK_PREFIX); + + if (isComputerLink && onGetFileInfo && onFileDownload) { + const filePath = href.slice(COMPUTER_LINK_PREFIX.length); + return ( + + ); + } + // Fallback: plain clickable link when only onFileDownload is available. + if (isComputerLink && onFileDownload) { + const filePath = href.slice(COMPUTER_LINK_PREFIX.length); + 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://'))) { + return ( + + {children} + + ); + } + + return {children}; + }, + table({ children }: any) { return (
@@ -149,10 +366,23 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { blockquote({ children }: any) { return
{children}
; }, - }), [syntaxTheme, isDark]); + }), [syntaxTheme, isDark, onFileDownload, onGetFileInfo]); return ( - + { + // 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('/')) { + return url; + } + return ''; + }} + > {content} ); @@ -760,9 +990,13 @@ function useTypewriter(targetText: string, animate: boolean): string { return displayText; } -const TypewriterText: React.FC<{ content: string }> = ({ content }) => { +const TypewriterText: React.FC<{ + content: string; + onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise; + onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>; +}> = ({ content, onFileDownload, onGetFileInfo }) => { const displayText = useTypewriter(content, true); - return ; + return ; }; // ─── AskUserQuestion Card ───────────────────────────────────────────────── @@ -1002,6 +1236,8 @@ function renderStandardGroups( now: number, onCancelTool?: (toolId: string) => void, animate?: boolean, + onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise, + onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>, ) { return groups.map((g, gi) => { if (g.type === 'thinking') { @@ -1058,7 +1294,9 @@ function renderStandardGroups( const text = g.entries.map(e => e.content || '').join(''); return text ? (
- {animate ? : } + {animate + ? + : }
) : null; } @@ -1073,11 +1311,13 @@ function renderOrderedItems( now: number, onCancelTool?: (toolId: string) => void, onAnswer?: (toolId: string, answers: any) => Promise, + onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise, + onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>, ) { const items = filterSubagentItems(rawItems); const askEntries = items.filter(item => isPendingAskUserQuestion(item.tool)); if (askEntries.length === 0) { - return renderStandardGroups(groupChatItems(items), 'ordered', now, onCancelTool); + return renderStandardGroups(groupChatItems(items), 'ordered', now, onCancelTool, false, onFileDownload, onGetFileInfo); } const beforeAskItems: ChatMessageItem[] = []; @@ -1095,9 +1335,9 @@ function renderOrderedItems( return ( <> - {renderStandardGroups(groupChatItems(beforeAskItems), 'ordered-before', now, onCancelTool)} + {renderStandardGroups(groupChatItems(beforeAskItems), 'ordered-before', now, onCancelTool, false, onFileDownload, onGetFileInfo)} {renderQuestionEntries(askEntries, 'ordered', onAnswer)} - {renderStandardGroups(groupChatItems(afterAskItems), 'ordered-after', now, onCancelTool)} + {renderStandardGroups(groupChatItems(afterAskItems), 'ordered-after', now, onCancelTool, false, onFileDownload, onGetFileInfo)} ); } @@ -1110,6 +1350,8 @@ function renderActiveTurnItems( sessionMgr: RemoteSessionManager, setError: (e: string) => void, onAnswer: (toolId: string, answers: any) => Promise, + onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise, + onGetFileInfo?: (path: string) => Promise<{ name: string; size: number; mimeType: string }>, ) { const items = filterSubagentItems(rawItems); const askEntries = items.filter(item => isPendingAskUserQuestion(item.tool)); @@ -1118,7 +1360,7 @@ function renderActiveTurnItems( }; if (askEntries.length === 0) { - return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true); + return renderStandardGroups(groupChatItems(items), 'active', now, onCancel, true, onFileDownload, onGetFileInfo); } const beforeAskItems: ChatMessageItem[] = []; @@ -1136,9 +1378,9 @@ function renderActiveTurnItems( return ( <> - {renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true)} + {renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel, true, onFileDownload, onGetFileInfo)} {renderQuestionEntries(askEntries, 'active', onAnswer)} - {renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true)} + {renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel, true, onFileDownload, onGetFileInfo)} ); } @@ -1214,6 +1456,40 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } }, [sessionMgr, setError]); + /** Fetch metadata for a workspace file before the user confirms the download. */ + const handleGetFileInfo = useCallback( + (filePath: string) => sessionMgr.getFileInfo(filePath), + [sessionMgr], + ); + + /** Download a workspace file referenced by a `computer://` link. */ + const handleFileDownload = useCallback(async ( + filePath: string, + onProgress?: (downloaded: number, total: number) => void, + ) => { + try { + const { name, contentBase64, mimeType } = await sessionMgr.readFile(filePath, onProgress); + const byteCharacters = atob(contentBase64); + const byteNumbers = new Uint8Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const blob = new Blob([byteNumbers], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } catch (err) { + // Use the backend's message directly; it's already user-readable. + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + } + }, [sessionMgr, setError]); + useEffect(() => { if (!isStreaming) return; const timer = setInterval(() => setNow(Date.now()), 500); @@ -1245,9 +1521,16 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } }, [sessionMgr, sessionId, setMessages, setError, getMessages, isLoadingMore, hasMore]); + const isNearBottomRef = useRef(true); + const BOTTOM_THRESHOLD = 80; + const handleScroll = useCallback(() => { const container = messagesContainerRef.current; if (!container) return; + + const gap = container.scrollHeight - container.scrollTop - container.clientHeight; + isNearBottomRef.current = gap < BOTTOM_THRESHOLD; + if (container.scrollTop < 100 && hasMore && !isLoadingMore) { const msgs = getMessages(sessionId); if (msgs.length > 0) loadMessages(msgs[0].id); @@ -1256,20 +1539,13 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, // Initial load + start poller const initialScrollDone = useRef(false); + const pendingInitialScroll = useRef(false); useEffect(() => { initialScrollDone.current = false; + pendingInitialScroll.current = false; loadMessages().then(() => { const initialMsgCount = useMobileStore.getState().getMessages(sessionId).length; - - // Scroll to bottom after initial load — use rAF + setTimeout to ensure - // the DOM has finished laying out the newly rendered messages. - requestAnimationFrame(() => { - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); - initialScrollDone.current = true; - prevMsgCountRef.current = useMobileStore.getState().getMessages(sessionId).length; - }, 50); - }); + pendingInitialScroll.current = true; const poller = new SessionPoller(sessionMgr, sessionId, (resp: PollResponse) => { if (resp.new_messages && resp.new_messages.length > 0) { @@ -1307,12 +1583,26 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, }, [sessionId, sessionMgr]); const prevMsgCountRef = useRef(0); + + // Scroll to bottom BEFORE paint on initial message load, + // so the user never sees the list at scroll-top then flash to bottom. + useLayoutEffect(() => { + if (!pendingInitialScroll.current || messages.length === 0) return; + pendingInitialScroll.current = false; + const container = messagesContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + } + initialScrollDone.current = true; + prevMsgCountRef.current = messages.length; + }, [messages]); + useEffect(() => { if (!initialScrollDone.current) return; if (messages.length !== prevMsgCountRef.current) { const isNewAppend = messages.length > prevMsgCountRef.current; prevMsgCountRef.current = messages.length; - if (isNewAppend && !isLoadingMore) { + if (isNewAppend && !isLoadingMore && isNearBottomRef.current) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } } @@ -1320,11 +1610,13 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, useEffect(() => { if (!initialScrollDone.current || !isStreaming) return; + if (!isNearBottomRef.current) return; messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); }, [activeTurn, isStreaming]); useEffect(() => { if (optimisticMsg) { + isNearBottomRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [optimisticMsg]); @@ -1334,11 +1626,12 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const container = messagesContainerRef.current; if (!container) return; const tid = setInterval(() => { + if (!isNearBottomRef.current) return; const gap = container.scrollHeight - container.scrollTop - container.clientHeight; - if (gap > 10 && gap < 300) { - container.scrollTop = container.scrollHeight; + if (gap > 10 && gap < 400) { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } - }, 200); + }, 300); return () => clearInterval(tid); }, [isStreaming]); @@ -1445,8 +1738,25 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, return () => document.removeEventListener('mousedown', handleClickOutside); }, [inputExpanded, input, pendingImages.length]); + const isComposingRef = useRef(false); + + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback(() => { + // Delay clearing to handle Safari's event ordering where + // compositionend fires before the final keydown(Enter) + setTimeout(() => { + isComposingRef.current = false; + }, 0); + }, []); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { + if ((e.nativeEvent as KeyboardEvent).isComposing || isComposingRef.current) { + return; + } e.preventDefault(); handleSend(); } @@ -1591,14 +1901,14 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, )} {hasItems ? ( - renderOrderedItems(m.items!, now, undefined, handleAnswerQuestion) + renderOrderedItems(m.items!, now, undefined, handleAnswerQuestion, handleFileDownload, handleGetFileInfo) ) : ( <> {m.thinking && } {m.tools && m.tools.length > 0 && } {m.content && (
- +
)} @@ -1617,8 +1927,8 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, return (
{turnIsActive - ? renderActiveTurnItems(turn.items, now, sessionMgr, setError, handleAnswerQuestion) - : renderOrderedItems(turn.items, now)} + ? renderActiveTurnItems(turn.items, now, sessionMgr, setError, handleAnswerQuestion, handleFileDownload, handleGetFileInfo) + : renderOrderedItems(turn.items, now, undefined, undefined, handleFileDownload, handleGetFileInfo)} {turnIsActive && !turn.thinking && !turn.text && turn.tools.length === 0 && (
)} @@ -1672,7 +1982,9 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, ))} {!hasRunningSubagent && turn.text ? (
- {turnIsActive ? : } + {turnIsActive + ? + : }
) : turnIsActive && !turn.thinking && turn.tools.length === 0 ? (
@@ -1748,6 +2060,8 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} rows={1} disabled={isStreaming || imageAnalyzing} /> diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index a4604754..01fb3547 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -266,6 +266,85 @@ export class RemoteSessionManager { async ping(): Promise { await this.request({ cmd: 'ping' }); } + + /** + * Fetch metadata for a workspace file (name, size, MIME type) without + * transferring its content. Used to render file cards before the user + * confirms a download. + */ + async getFileInfo(path: string): Promise<{ + name: string; + size: number; + mimeType: string; + }> { + const resp = await this.request<{ + resp: string; + name: string; + size: number; + mime_type: string; + }>({ cmd: 'get_file_info', path }); + return { + name: resp.name, + size: resp.size, + mimeType: resp.mime_type, + }; + } + + /** + * Read a workspace file using chunked transfer. + * + * Downloads the file in 4 MB chunks, reassembles the base64 pieces, and + * calls `onProgress(downloaded, total)` after each chunk so the UI can + * display a progress bar. + */ + async readFile( + path: string, + onProgress?: (downloaded: number, total: number) => void, + ): Promise<{ + name: string; + contentBase64: string; + mimeType: string; + size: number; + }> { + // Must be divisible by 3 so intermediate base64 chunks have no `=` padding; + // joining padded chunks would produce invalid base64 for `atob()`. + const CHUNK_SIZE = 3 * 1024 * 1024; // 3 MB per request + let offset = 0; + const chunks: string[] = []; + let fileName = ''; + let mimeType = ''; + let totalSize = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const resp = await this.request<{ + resp: string; + name: string; + chunk_base64: string; + offset: number; + chunk_size: number; + total_size: number; + mime_type: string; + }>({ cmd: 'read_file_chunk', path, offset, limit: CHUNK_SIZE }); + + chunks.push(resp.chunk_base64); + fileName = resp.name; + mimeType = resp.mime_type; + totalSize = resp.total_size; + offset += resp.chunk_size; + + onProgress?.(Math.min(offset, totalSize), totalSize); + + if (offset >= totalSize || resp.chunk_size === 0) break; + } + + return { + name: fileName, + contentBase64: chunks.join(''), + mimeType, + size: totalSize, + }; + } } // ── SessionPoller ───────────────────────────────────────────────── diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss index 3b0633ac..615a5248 100644 --- a/src/mobile-web/src/styles/components/markdown.scss +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -109,6 +109,16 @@ padding-left: 1.2rem; } + ul:has(> li > .file-card) { + list-style: none; + padding-left: 0; + + > li { + margin-bottom: var(--size-gap-2); + &:last-child { margin-bottom: 0; } + } + } + // ─── Inline code ──────────────────────────────────────────────────────────── .inline-code { diff --git a/src/mobile-web/src/styles/global.scss b/src/mobile-web/src/styles/global.scss index 67201ff8..60d8169d 100644 --- a/src/mobile-web/src/styles/global.scss +++ b/src/mobile-web/src/styles/global.scss @@ -101,37 +101,45 @@ body { .nav-page { position: absolute; inset: 0; - will-change: transform, opacity; + background: var(--color-bg-primary); + z-index: 1; } +// Push: new page slides in from right ON TOP, old page shifts left underneath .nav-push-enter { - animation: navPushIn 0.35s var(--easing-decelerate) both; + z-index: 2; + animation: navPushIn 0.3s cubic-bezier(0.2, 0.9, 0.3, 1) both; } .nav-push-exit { - animation: navPushOut 0.35s var(--easing-accelerate) both; + z-index: 1; + animation: navPushOut 0.3s cubic-bezier(0.2, 0.9, 0.3, 1) both; } + +// Pop: old page slides out to right ON TOP, underlying page shifts in from left .nav-pop-enter { - animation: navPopIn 0.25s var(--easing-decelerate) both; + z-index: 1; + animation: navPopIn 0.3s cubic-bezier(0.2, 0.9, 0.3, 1) both; } .nav-pop-exit { - animation: navPopOut 0.25s var(--easing-accelerate) both; + z-index: 2; + animation: navPopOut 0.3s cubic-bezier(0.2, 0.9, 0.3, 1) both; } @keyframes navPushIn { - from { transform: translateX(30%); opacity: 0.4; } - to { transform: translateX(0); opacity: 1; } + from { transform: translateX(100%); } + to { transform: translateX(0); } } @keyframes navPushOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(-15%); opacity: 0.3; } + from { transform: translateX(0); } + to { transform: translateX(-25%); } } @keyframes navPopIn { - from { transform: translateX(-15%); opacity: 0.3; } - to { transform: translateX(0); opacity: 1; } + from { transform: translateX(-25%); } + to { transform: translateX(0); } } @keyframes navPopOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(30%); opacity: 0.4; } + from { transform: translateX(0); } + to { transform: translateX(100%); } } // Spinner utility @@ -149,3 +157,20 @@ body { height: 14px; border-width: 2px; } + +// Uniform theme-switch transition — applied temporarily via JS during toggle +// so that ALL color-related properties animate at the same pace. +html.theme-switching { + &, + & *, + & *::before, + & *::after { + transition: background-color 0.28s ease, + color 0.28s ease, + border-color 0.28s ease, + box-shadow 0.28s ease, + fill 0.28s ease, + stroke 0.28s ease, + background 0.28s ease !important; + } +} diff --git a/src/mobile-web/src/theme/ThemeProvider.tsx b/src/mobile-web/src/theme/ThemeProvider.tsx index e8971794..6298aaa0 100644 --- a/src/mobile-web/src/theme/ThemeProvider.tsx +++ b/src/mobile-web/src/theme/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useLayoutEffect, useState } from 'react'; import { darkTheme } from './presets/dark'; import { lightTheme } from './presets/light'; @@ -12,7 +12,7 @@ interface ThemeContextValue { } const STORAGE_KEY = 'bitfun-mobile-theme'; -const THEME_STYLE_ID = 'bitfun-theme-vars'; +const THEME_STYLE_ATTR = 'data-bitfun-theme'; const themeMap: Record> = { dark: darkTheme, @@ -29,84 +29,65 @@ const textColors: Record = { light: '#1c1c1e', }; -let fadeTimer: ReturnType | undefined; -let fadeRaf: number | undefined; - -/** - * Build a complete CSS stylesheet string that defines all theme variables - * on :root plus explicit background/color on html and body. - * Injecting a